From f53b0c6c9d2ae966f6d2a66d210e2c29b30074e7 Mon Sep 17 00:00:00 2001 From: Sander van Rossen Date: Sat, 24 Oct 2026 17:29:30 +0200 Subject: [PATCH] Add the tests: Core, Components and Editor Chisel's EditMode tests move here from the private repository; CONTRIBUTING.md says where they are and how to run them. --- CONTRIBUTING.md | 6 +- Components/Tests.meta | 8 + Components/Tests/ChiselBoxComponentTests.cs | 110 ++ .../Tests/ChiselBoxComponentTests.cs.meta | 2 + .../Tests/ChiselCollisionHelperTests.cs | 181 ++ .../Tests/ChiselCollisionHelperTests.cs.meta | 2 + Components/Tests/ChiselDecalComponentTests.cs | 441 +++++ .../Tests/ChiselDecalComponentTests.cs.meta | 2 + Components/Tests/ChiselDestroyedModelTests.cs | 194 ++ .../Tests/ChiselDestroyedModelTests.cs.meta | 2 + .../ChiselDiscardedVisualizationTests.cs | 154 ++ .../ChiselDiscardedVisualizationTests.cs.meta | 2 + Components/Tests/ChiselDoubleSidedTests.cs | 150 ++ .../Tests/ChiselDoubleSidedTests.cs.meta | 2 + Components/Tests/ChiselHierarchyEditTests.cs | 188 ++ .../Tests/ChiselHierarchyEditTests.cs.meta | 2 + Components/Tests/ChiselHotspotMappingTests.cs | 1380 +++++++++++++++ .../Tests/ChiselHotspotMappingTests.cs.meta | 2 + Components/Tests/ChiselLightmapUVTests.cs | 33 + .../Tests/ChiselLightmapUVTests.cs.meta | 2 + Components/Tests/ChiselMaterialMeshTests.cs | 406 +++++ .../Tests/ChiselMaterialMeshTests.cs.meta | 2 + Components/Tests/ChiselModelManagerTests.cs | 89 + .../Tests/ChiselModelManagerTests.cs.meta | 2 + Components/Tests/ChiselModelShadowTests.cs | 132 ++ .../Tests/ChiselModelShadowTests.cs.meta | 2 + Components/Tests/ChiselPlayModeResetTests.cs | 152 ++ .../Tests/ChiselPlayModeResetTests.cs.meta | 2 + Components/Tests/ChiselRenderLightmapTests.cs | 62 + .../Tests/ChiselRenderLightmapTests.cs.meta | 2 + Components/Tests/ChiselSavedOutputTests.cs | 656 +++++++ .../Tests/ChiselSavedOutputTests.cs.meta | 2 + Components/Tests/ChiselSceneLoadTests.cs | 123 ++ Components/Tests/ChiselSceneLoadTests.cs.meta | 2 + Components/Tests/ChiselShadowOnlyTests.cs | 175 ++ .../Tests/ChiselShadowOnlyTests.cs.meta | 2 + Components/Tests/ChiselTrimDecalTests.cs | 509 ++++++ Components/Tests/ChiselTrimDecalTests.cs.meta | 2 + Components/Tests/ChiselUnsavedMeshTests.cs | 85 + .../Tests/ChiselUnsavedMeshTests.cs.meta | 2 + Components/Tests/SceneLeftClean.cs | 29 + Components/Tests/SceneLeftClean.cs.meta | 2 + Components/Tests/StagedInputHashTests.cs | 255 +++ Components/Tests/StagedInputHashTests.cs.meta | 2 + .../com.chisel.unity.components.tests.asmdef | 30 + ....chisel.unity.components.tests.asmdef.meta | 7 + Core/Tests.meta | 8 + Core/Tests/AlignedFaceDetectionTests.cs | 131 ++ Core/Tests/AlignedFaceDetectionTests.cs.meta | 2 + Core/Tests/AlignedFaceLoopDeliveryTests.cs | 241 +++ .../AlignedFaceLoopDeliveryTests.cs.meta | 2 + Core/Tests/BooleanEdgesUtilityTests.cs | 138 ++ Core/Tests/BooleanEdgesUtilityTests.cs.meta | 2 + Core/Tests/BoundsExtensionsTests.cs | 145 ++ Core/Tests/BoundsExtensionsTests.cs.meta | 2 + Core/Tests/BrushFromPlanesPrecisionTests.cs | 140 ++ .../BrushFromPlanesPrecisionTests.cs.meta | 2 + .../BrushFromPlanesTouchingPlaneTests.cs | 131 ++ .../BrushFromPlanesTouchingPlaneTests.cs.meta | 2 + Core/Tests/BrushMeshRegistrationTests.cs | 314 ++++ Core/Tests/BrushMeshRegistrationTests.cs.meta | 2 + Core/Tests/BrushMeshTests.cs | 169 ++ Core/Tests/BrushMeshTests.cs.meta | 2 + Core/Tests/BurstRegistrationTests.cs | 112 ++ Core/Tests/BurstRegistrationTests.cs.meta | 2 + Core/Tests/CSGConstantsTests.cs | 42 + Core/Tests/CSGConstantsTests.cs.meta | 2 + Core/Tests/CSGMathTests.cs | 181 ++ Core/Tests/CSGMathTests.cs.meta | 2 + Core/Tests/CanonicalVerticesTests.cs | 318 ++++ Core/Tests/CanonicalVerticesTests.cs.meta | 2 + Core/Tests/CategoryRoutingRowTests.cs | 173 ++ Core/Tests/CategoryRoutingRowTests.cs.meta | 2 + Core/Tests/ChiselSurfaceMetadataFlagsTests.cs | 72 + .../ChiselSurfaceMetadataFlagsTests.cs.meta | 2 + Core/Tests/ChiselTestUtility.cs | 39 + Core/Tests/ChiselTestUtility.cs.meta | 2 + Core/Tests/ChiselTreeLookupTests.cs | 78 + Core/Tests/ChiselTreeLookupTests.cs.meta | 2 + Core/Tests/Contents.meta | 8 + Core/Tests/Contents/BrushRemovalTests.cs | 116 ++ Core/Tests/Contents/BrushRemovalTests.cs.meta | 2 + Core/Tests/Contents/BrushWithoutMeshTests.cs | 36 + .../Contents/BrushWithoutMeshTests.cs.meta | 2 + .../Contents/CanonicalVertexEditTests.cs | 116 ++ .../Contents/CanonicalVertexEditTests.cs.meta | 2 + Core/Tests/Contents/ConcretePadTests.cs | 786 +++++++++ Core/Tests/Contents/ConcretePadTests.cs.meta | 2 + Core/Tests/Contents/ContentsComparison.cs | 423 +++++ .../Tests/Contents/ContentsComparison.cs.meta | 2 + Core/Tests/Contents/ContentsFaceSampler.cs | 286 +++ .../Contents/ContentsFaceSampler.cs.meta | 2 + Core/Tests/Contents/ContentsHarvest.cs | 758 ++++++++ Core/Tests/Contents/ContentsHarvest.cs.meta | 2 + Core/Tests/Contents/ContentsListScope.cs | 41 + Core/Tests/Contents/ContentsListScope.cs.meta | 2 + Core/Tests/Contents/ContentsListTests.cs | 50 + Core/Tests/Contents/ContentsListTests.cs.meta | 2 + Core/Tests/Contents/ContentsOracle.cs | 271 +++ Core/Tests/Contents/ContentsOracle.cs.meta | 2 + Core/Tests/Contents/ContentsOracleTests.cs | 166 ++ .../Contents/ContentsOracleTests.cs.meta | 2 + Core/Tests/Contents/ContentsPipelineTests.cs | 50 + .../Contents/ContentsPipelineTests.cs.meta | 2 + Core/Tests/Contents/ContentsRulesTests.cs | 169 ++ .../Tests/Contents/ContentsRulesTests.cs.meta | 2 + .../Contents/ContentsSceneDescription.cs | 133 ++ .../Contents/ContentsSceneDescription.cs.meta | 2 + .../Tests/Contents/ContentsSceneGenerators.cs | 297 ++++ .../Contents/ContentsSceneGenerators.cs.meta | 2 + Core/Tests/Contents/ContentsSceneShrinker.cs | 201 +++ .../Contents/ContentsSceneShrinker.cs.meta | 2 + Core/Tests/Contents/ContentsSceneTests.cs | 388 ++++ .../Tests/Contents/ContentsSceneTests.cs.meta | 2 + Core/Tests/Contents/ContentsTrace.cs | 113 ++ Core/Tests/Contents/ContentsTrace.cs.meta | 2 + Core/Tests/Contents/ContentsTreeHarness.cs | 758 ++++++++ .../Contents/ContentsTreeHarness.cs.meta | 2 + Core/Tests/Contents/DecalPipelineTests.cs | 626 +++++++ .../Tests/Contents/DecalPipelineTests.cs.meta | 2 + Core/Tests/Contents/ExactCSGProbeTests.cs | 352 ++++ .../Tests/Contents/ExactCSGProbeTests.cs.meta | 2 + Core/Tests/Contents/ExactDelaunayCheck.cs | 112 ++ .../Tests/Contents/ExactDelaunayCheck.cs.meta | 2 + Core/Tests/Contents/ExactDelaunayTests.cs | 97 + .../Tests/Contents/ExactDelaunayTests.cs.meta | 2 + Core/Tests/Contents/ExactFloatLine.cs | 52 + Core/Tests/Contents/ExactFloatLine.cs.meta | 2 + Core/Tests/Contents/ExactInputTests.cs | 93 + Core/Tests/Contents/ExactInputTests.cs.meta | 2 + Core/Tests/Contents/ExactJudge.cs | 1557 +++++++++++++++++ Core/Tests/Contents/ExactJudge.cs.meta | 2 + Core/Tests/Contents/ExactJudgeCaseTests.cs | 209 +++ .../Contents/ExactJudgeCaseTests.cs.meta | 2 + Core/Tests/Contents/ExactJudgeTests.cs | 231 +++ Core/Tests/Contents/ExactJudgeTests.cs.meta | 2 + Core/Tests/Contents/FlushContactFaceTests.cs | 270 +++ .../Contents/FlushContactFaceTests.cs.meta | 2 + Core/Tests/Contents/IntersectingNodeTests.cs | 241 +++ .../Contents/IntersectingNodeTests.cs.meta | 2 + .../Contents/IntersectionLoopCapacityTests.cs | 49 + .../IntersectionLoopCapacityTests.cs.meta | 2 + .../Tests/Contents/LightmapUVPipelineTests.cs | 128 ++ .../Contents/LightmapUVPipelineTests.cs.meta | 2 + Core/Tests/Contents/LiveOutputCheck.cs | 93 + Core/Tests/Contents/LiveOutputCheck.cs.meta | 2 + .../Contents/NestedSubtractionRoutingTests.cs | 49 + .../NestedSubtractionRoutingTests.cs.meta | 2 + .../Contents/SampleSceneExtraFaceTests.cs | 852 +++++++++ .../SampleSceneExtraFaceTests.cs.meta | 2 + .../Contents/SampleSceneExtrudedShapeTests.cs | 125 ++ .../SampleSceneExtrudedShapeTests.cs.meta | 2 + .../Tests/Contents/SampleSceneHarvestTests.cs | 516 ++++++ .../Contents/SampleSceneHarvestTests.cs.meta | 11 + Core/Tests/Contents/SampleSceneHoleTests.cs | 277 +++ .../Contents/SampleSceneHoleTests.cs.meta | 2 + .../Tests/Contents/SampleSceneOpeningTests.cs | 204 +++ .../Contents/SampleSceneOpeningTests.cs.meta | 2 + Core/Tests/Contents/SetStateTests.cs | 77 + Core/Tests/Contents/SetStateTests.cs.meta | 2 + .../Contents/SharedCornerExpectationTests.cs | 133 ++ .../SharedCornerExpectationTests.cs.meta | 2 + Core/Tests/Contents/SharedCornerSurvey.cs | 456 +++++ .../Tests/Contents/SharedCornerSurvey.cs.meta | 2 + .../Tests/Contents/SharedCornerSurveyTests.cs | 609 +++++++ .../Contents/SharedCornerSurveyTests.cs.meta | 2 + Core/Tests/ConvertToPlaneSpaceTests.cs | 97 + Core/Tests/ConvertToPlaneSpaceTests.cs.meta | 2 + Core/Tests/Decals.meta | 8 + Core/Tests/Decals/DecalClippingTests.cs | 484 +++++ Core/Tests/Decals/DecalClippingTests.cs.meta | 2 + Core/Tests/EndWeldTests.cs | 641 +++++++ Core/Tests/EndWeldTests.cs.meta | 2 + Core/Tests/ExactPlaneSnapTests.cs | 61 + Core/Tests/ExactPlaneSnapTests.cs.meta | 2 + .../FindHotspotAffectedBrushesJobTests.cs | 112 ++ ...FindHotspotAffectedBrushesJobTests.cs.meta | 2 + Core/Tests/FixupBrushCacheIndicesTests.cs | 90 + .../Tests/FixupBrushCacheIndicesTests.cs.meta | 2 + Core/Tests/GeneratorBranchJobPoolTests.cs | 117 ++ .../Tests/GeneratorBranchJobPoolTests.cs.meta | 2 + Core/Tests/HotspotBorderTests.cs | 350 ++++ Core/Tests/HotspotBorderTests.cs.meta | 2 + Core/Tests/HotspotConformingTests.cs | 964 ++++++++++ Core/Tests/HotspotConformingTests.cs.meta | 2 + Core/Tests/HotspotCutCacheTests.cs | 224 +++ Core/Tests/HotspotCutCacheTests.cs.meta | 2 + Core/Tests/HotspotDiagnosticsTests.cs | 104 ++ Core/Tests/HotspotDiagnosticsTests.cs.meta | 2 + Core/Tests/HotspotGroupsIncrementalTests.cs | 344 ++++ .../HotspotGroupsIncrementalTests.cs.meta | 2 + Core/Tests/HotspotGroupsTests.cs | 494 ++++++ Core/Tests/HotspotGroupsTests.cs.meta | 2 + Core/Tests/HotspotLayoutCutsTests.cs | 302 ++++ Core/Tests/HotspotLayoutCutsTests.cs.meta | 2 + Core/Tests/HotspotLayoutImportTests.cs | 144 ++ Core/Tests/HotspotLayoutImportTests.cs.meta | 2 + Core/Tests/HotspotMatcherTests.cs | 607 +++++++ Core/Tests/HotspotMatcherTests.cs.meta | 2 + Core/Tests/HotspotRepeatTests.cs | 224 +++ Core/Tests/HotspotRepeatTests.cs.meta | 2 + Core/Tests/HotspotStoreTests.cs | 128 ++ Core/Tests/HotspotStoreTests.cs.meta | 2 + Core/Tests/InternedPlanesTests.cs | 274 +++ Core/Tests/InternedPlanesTests.cs.meta | 2 + Core/Tests/LightmapUVLayoutTests.cs | 220 +++ Core/Tests/LightmapUVLayoutTests.cs.meta | 2 + Core/Tests/LoopEdgeSplitterTests.cs | 325 ++++ Core/Tests/LoopEdgeSplitterTests.cs.meta | 2 + Core/Tests/LoopSplitCapacityTests.cs | 204 +++ Core/Tests/LoopSplitCapacityTests.cs.meta | 2 + Core/Tests/LoopValidationTests.cs | 150 ++ Core/Tests/LoopValidationTests.cs.meta | 2 + Core/Tests/MathExtensionsTests.cs | 348 ++++ Core/Tests/MathExtensionsTests.cs.meta | 2 + Core/Tests/MeshManifoldValidationTests.cs | 252 +++ .../Tests/MeshManifoldValidationTests.cs.meta | 2 + Core/Tests/MeshQueryRoutingTests.cs | 257 +++ Core/Tests/MeshQueryRoutingTests.cs.meta | 2 + Core/Tests/OutputMeshBoundsTests.cs | 124 ++ Core/Tests/OutputMeshBoundsTests.cs.meta | 2 + Core/Tests/OutputModelWeldTests.cs | 700 ++++++++ Core/Tests/OutputModelWeldTests.cs.meta | 2 + Core/Tests/OutputWeldTests.cs | 580 ++++++ Core/Tests/OutputWeldTests.cs.meta | 2 + Core/Tests/PlaneDefinedBrushTests.cs | 296 ++++ Core/Tests/PlaneDefinedBrushTests.cs.meta | 2 + Core/Tests/PlaneExtensionsTests.cs | 91 + Core/Tests/PlaneExtensionsTests.cs.meta | 2 + Core/Tests/RangeAndEdgeTests.cs | 54 + Core/Tests/RangeAndEdgeTests.cs.meta | 2 + Core/Tests/RoutingTableCapacityTests.cs | 540 ++++++ Core/Tests/RoutingTableCapacityTests.cs.meta | 2 + Core/Tests/SkippedTreeUpdateTests.cs | 62 + Core/Tests/SkippedTreeUpdateTests.cs.meta | 11 + Core/Tests/TreeInputHashTests.cs | 169 ++ Core/Tests/TreeInputHashTests.cs.meta | 2 + Core/Tests/UVMatrixTests.cs | 96 + Core/Tests/UVMatrixTests.cs.meta | 2 + Core/Tests/WeldIncidenceTests.cs | 208 +++ Core/Tests/WeldIncidenceTests.cs.meta | 2 + Core/Tests/com.chisel.core.tests.asmdef | 31 + Core/Tests/com.chisel.core.tests.asmdef.meta | 7 + Editor/Tests.meta | 8 + Editor/Tests/ChiselHotspotGroupEditTests.cs | 154 ++ .../Tests/ChiselHotspotGroupEditTests.cs.meta | 2 + .../com.chisel.unity.editor.tests.asmdef | 31 + .../com.chisel.unity.editor.tests.asmdef.meta | 7 + 248 files changed, 31497 insertions(+), 1 deletion(-) create mode 100644 Components/Tests.meta create mode 100644 Components/Tests/ChiselBoxComponentTests.cs create mode 100644 Components/Tests/ChiselBoxComponentTests.cs.meta create mode 100644 Components/Tests/ChiselCollisionHelperTests.cs create mode 100644 Components/Tests/ChiselCollisionHelperTests.cs.meta create mode 100644 Components/Tests/ChiselDecalComponentTests.cs create mode 100644 Components/Tests/ChiselDecalComponentTests.cs.meta create mode 100644 Components/Tests/ChiselDestroyedModelTests.cs create mode 100644 Components/Tests/ChiselDestroyedModelTests.cs.meta create mode 100644 Components/Tests/ChiselDiscardedVisualizationTests.cs create mode 100644 Components/Tests/ChiselDiscardedVisualizationTests.cs.meta create mode 100644 Components/Tests/ChiselDoubleSidedTests.cs create mode 100644 Components/Tests/ChiselDoubleSidedTests.cs.meta create mode 100644 Components/Tests/ChiselHierarchyEditTests.cs create mode 100644 Components/Tests/ChiselHierarchyEditTests.cs.meta create mode 100644 Components/Tests/ChiselHotspotMappingTests.cs create mode 100644 Components/Tests/ChiselHotspotMappingTests.cs.meta create mode 100644 Components/Tests/ChiselLightmapUVTests.cs create mode 100644 Components/Tests/ChiselLightmapUVTests.cs.meta create mode 100644 Components/Tests/ChiselMaterialMeshTests.cs create mode 100644 Components/Tests/ChiselMaterialMeshTests.cs.meta create mode 100644 Components/Tests/ChiselModelManagerTests.cs create mode 100644 Components/Tests/ChiselModelManagerTests.cs.meta create mode 100644 Components/Tests/ChiselModelShadowTests.cs create mode 100644 Components/Tests/ChiselModelShadowTests.cs.meta create mode 100644 Components/Tests/ChiselPlayModeResetTests.cs create mode 100644 Components/Tests/ChiselPlayModeResetTests.cs.meta create mode 100644 Components/Tests/ChiselRenderLightmapTests.cs create mode 100644 Components/Tests/ChiselRenderLightmapTests.cs.meta create mode 100644 Components/Tests/ChiselSavedOutputTests.cs create mode 100644 Components/Tests/ChiselSavedOutputTests.cs.meta create mode 100644 Components/Tests/ChiselSceneLoadTests.cs create mode 100644 Components/Tests/ChiselSceneLoadTests.cs.meta create mode 100644 Components/Tests/ChiselShadowOnlyTests.cs create mode 100644 Components/Tests/ChiselShadowOnlyTests.cs.meta create mode 100644 Components/Tests/ChiselTrimDecalTests.cs create mode 100644 Components/Tests/ChiselTrimDecalTests.cs.meta create mode 100644 Components/Tests/ChiselUnsavedMeshTests.cs create mode 100644 Components/Tests/ChiselUnsavedMeshTests.cs.meta create mode 100644 Components/Tests/SceneLeftClean.cs create mode 100644 Components/Tests/SceneLeftClean.cs.meta create mode 100644 Components/Tests/StagedInputHashTests.cs create mode 100644 Components/Tests/StagedInputHashTests.cs.meta create mode 100644 Components/Tests/com.chisel.unity.components.tests.asmdef create mode 100644 Components/Tests/com.chisel.unity.components.tests.asmdef.meta create mode 100644 Core/Tests.meta create mode 100644 Core/Tests/AlignedFaceDetectionTests.cs create mode 100644 Core/Tests/AlignedFaceDetectionTests.cs.meta create mode 100644 Core/Tests/AlignedFaceLoopDeliveryTests.cs create mode 100644 Core/Tests/AlignedFaceLoopDeliveryTests.cs.meta create mode 100644 Core/Tests/BooleanEdgesUtilityTests.cs create mode 100644 Core/Tests/BooleanEdgesUtilityTests.cs.meta create mode 100644 Core/Tests/BoundsExtensionsTests.cs create mode 100644 Core/Tests/BoundsExtensionsTests.cs.meta create mode 100644 Core/Tests/BrushFromPlanesPrecisionTests.cs create mode 100644 Core/Tests/BrushFromPlanesPrecisionTests.cs.meta create mode 100644 Core/Tests/BrushFromPlanesTouchingPlaneTests.cs create mode 100644 Core/Tests/BrushFromPlanesTouchingPlaneTests.cs.meta create mode 100644 Core/Tests/BrushMeshRegistrationTests.cs create mode 100644 Core/Tests/BrushMeshRegistrationTests.cs.meta create mode 100644 Core/Tests/BrushMeshTests.cs create mode 100644 Core/Tests/BrushMeshTests.cs.meta create mode 100644 Core/Tests/BurstRegistrationTests.cs create mode 100644 Core/Tests/BurstRegistrationTests.cs.meta create mode 100644 Core/Tests/CSGConstantsTests.cs create mode 100644 Core/Tests/CSGConstantsTests.cs.meta create mode 100644 Core/Tests/CSGMathTests.cs create mode 100644 Core/Tests/CSGMathTests.cs.meta create mode 100644 Core/Tests/CanonicalVerticesTests.cs create mode 100644 Core/Tests/CanonicalVerticesTests.cs.meta create mode 100644 Core/Tests/CategoryRoutingRowTests.cs create mode 100644 Core/Tests/CategoryRoutingRowTests.cs.meta create mode 100644 Core/Tests/ChiselSurfaceMetadataFlagsTests.cs create mode 100644 Core/Tests/ChiselSurfaceMetadataFlagsTests.cs.meta create mode 100644 Core/Tests/ChiselTestUtility.cs create mode 100644 Core/Tests/ChiselTestUtility.cs.meta create mode 100644 Core/Tests/ChiselTreeLookupTests.cs create mode 100644 Core/Tests/ChiselTreeLookupTests.cs.meta create mode 100644 Core/Tests/Contents.meta create mode 100644 Core/Tests/Contents/BrushRemovalTests.cs create mode 100644 Core/Tests/Contents/BrushRemovalTests.cs.meta create mode 100644 Core/Tests/Contents/BrushWithoutMeshTests.cs create mode 100644 Core/Tests/Contents/BrushWithoutMeshTests.cs.meta create mode 100644 Core/Tests/Contents/CanonicalVertexEditTests.cs create mode 100644 Core/Tests/Contents/CanonicalVertexEditTests.cs.meta create mode 100644 Core/Tests/Contents/ConcretePadTests.cs create mode 100644 Core/Tests/Contents/ConcretePadTests.cs.meta create mode 100644 Core/Tests/Contents/ContentsComparison.cs create mode 100644 Core/Tests/Contents/ContentsComparison.cs.meta create mode 100644 Core/Tests/Contents/ContentsFaceSampler.cs create mode 100644 Core/Tests/Contents/ContentsFaceSampler.cs.meta create mode 100644 Core/Tests/Contents/ContentsHarvest.cs create mode 100644 Core/Tests/Contents/ContentsHarvest.cs.meta create mode 100644 Core/Tests/Contents/ContentsListScope.cs create mode 100644 Core/Tests/Contents/ContentsListScope.cs.meta create mode 100644 Core/Tests/Contents/ContentsListTests.cs create mode 100644 Core/Tests/Contents/ContentsListTests.cs.meta create mode 100644 Core/Tests/Contents/ContentsOracle.cs create mode 100644 Core/Tests/Contents/ContentsOracle.cs.meta create mode 100644 Core/Tests/Contents/ContentsOracleTests.cs create mode 100644 Core/Tests/Contents/ContentsOracleTests.cs.meta create mode 100644 Core/Tests/Contents/ContentsPipelineTests.cs create mode 100644 Core/Tests/Contents/ContentsPipelineTests.cs.meta create mode 100644 Core/Tests/Contents/ContentsRulesTests.cs create mode 100644 Core/Tests/Contents/ContentsRulesTests.cs.meta create mode 100644 Core/Tests/Contents/ContentsSceneDescription.cs create mode 100644 Core/Tests/Contents/ContentsSceneDescription.cs.meta create mode 100644 Core/Tests/Contents/ContentsSceneGenerators.cs create mode 100644 Core/Tests/Contents/ContentsSceneGenerators.cs.meta create mode 100644 Core/Tests/Contents/ContentsSceneShrinker.cs create mode 100644 Core/Tests/Contents/ContentsSceneShrinker.cs.meta create mode 100644 Core/Tests/Contents/ContentsSceneTests.cs create mode 100644 Core/Tests/Contents/ContentsSceneTests.cs.meta create mode 100644 Core/Tests/Contents/ContentsTrace.cs create mode 100644 Core/Tests/Contents/ContentsTrace.cs.meta create mode 100644 Core/Tests/Contents/ContentsTreeHarness.cs create mode 100644 Core/Tests/Contents/ContentsTreeHarness.cs.meta create mode 100644 Core/Tests/Contents/DecalPipelineTests.cs create mode 100644 Core/Tests/Contents/DecalPipelineTests.cs.meta create mode 100644 Core/Tests/Contents/ExactCSGProbeTests.cs create mode 100644 Core/Tests/Contents/ExactCSGProbeTests.cs.meta create mode 100644 Core/Tests/Contents/ExactDelaunayCheck.cs create mode 100644 Core/Tests/Contents/ExactDelaunayCheck.cs.meta create mode 100644 Core/Tests/Contents/ExactDelaunayTests.cs create mode 100644 Core/Tests/Contents/ExactDelaunayTests.cs.meta create mode 100644 Core/Tests/Contents/ExactFloatLine.cs create mode 100644 Core/Tests/Contents/ExactFloatLine.cs.meta create mode 100644 Core/Tests/Contents/ExactInputTests.cs create mode 100644 Core/Tests/Contents/ExactInputTests.cs.meta create mode 100644 Core/Tests/Contents/ExactJudge.cs create mode 100644 Core/Tests/Contents/ExactJudge.cs.meta create mode 100644 Core/Tests/Contents/ExactJudgeCaseTests.cs create mode 100644 Core/Tests/Contents/ExactJudgeCaseTests.cs.meta create mode 100644 Core/Tests/Contents/ExactJudgeTests.cs create mode 100644 Core/Tests/Contents/ExactJudgeTests.cs.meta create mode 100644 Core/Tests/Contents/FlushContactFaceTests.cs create mode 100644 Core/Tests/Contents/FlushContactFaceTests.cs.meta create mode 100644 Core/Tests/Contents/IntersectingNodeTests.cs create mode 100644 Core/Tests/Contents/IntersectingNodeTests.cs.meta create mode 100644 Core/Tests/Contents/IntersectionLoopCapacityTests.cs create mode 100644 Core/Tests/Contents/IntersectionLoopCapacityTests.cs.meta create mode 100644 Core/Tests/Contents/LightmapUVPipelineTests.cs create mode 100644 Core/Tests/Contents/LightmapUVPipelineTests.cs.meta create mode 100644 Core/Tests/Contents/LiveOutputCheck.cs create mode 100644 Core/Tests/Contents/LiveOutputCheck.cs.meta create mode 100644 Core/Tests/Contents/NestedSubtractionRoutingTests.cs create mode 100644 Core/Tests/Contents/NestedSubtractionRoutingTests.cs.meta create mode 100644 Core/Tests/Contents/SampleSceneExtraFaceTests.cs create mode 100644 Core/Tests/Contents/SampleSceneExtraFaceTests.cs.meta create mode 100644 Core/Tests/Contents/SampleSceneExtrudedShapeTests.cs create mode 100644 Core/Tests/Contents/SampleSceneExtrudedShapeTests.cs.meta create mode 100644 Core/Tests/Contents/SampleSceneHarvestTests.cs create mode 100644 Core/Tests/Contents/SampleSceneHarvestTests.cs.meta create mode 100644 Core/Tests/Contents/SampleSceneHoleTests.cs create mode 100644 Core/Tests/Contents/SampleSceneHoleTests.cs.meta create mode 100644 Core/Tests/Contents/SampleSceneOpeningTests.cs create mode 100644 Core/Tests/Contents/SampleSceneOpeningTests.cs.meta create mode 100644 Core/Tests/Contents/SetStateTests.cs create mode 100644 Core/Tests/Contents/SetStateTests.cs.meta create mode 100644 Core/Tests/Contents/SharedCornerExpectationTests.cs create mode 100644 Core/Tests/Contents/SharedCornerExpectationTests.cs.meta create mode 100644 Core/Tests/Contents/SharedCornerSurvey.cs create mode 100644 Core/Tests/Contents/SharedCornerSurvey.cs.meta create mode 100644 Core/Tests/Contents/SharedCornerSurveyTests.cs create mode 100644 Core/Tests/Contents/SharedCornerSurveyTests.cs.meta create mode 100644 Core/Tests/ConvertToPlaneSpaceTests.cs create mode 100644 Core/Tests/ConvertToPlaneSpaceTests.cs.meta create mode 100644 Core/Tests/Decals.meta create mode 100644 Core/Tests/Decals/DecalClippingTests.cs create mode 100644 Core/Tests/Decals/DecalClippingTests.cs.meta create mode 100644 Core/Tests/EndWeldTests.cs create mode 100644 Core/Tests/EndWeldTests.cs.meta create mode 100644 Core/Tests/ExactPlaneSnapTests.cs create mode 100644 Core/Tests/ExactPlaneSnapTests.cs.meta create mode 100644 Core/Tests/FindHotspotAffectedBrushesJobTests.cs create mode 100644 Core/Tests/FindHotspotAffectedBrushesJobTests.cs.meta create mode 100644 Core/Tests/FixupBrushCacheIndicesTests.cs create mode 100644 Core/Tests/FixupBrushCacheIndicesTests.cs.meta create mode 100644 Core/Tests/GeneratorBranchJobPoolTests.cs create mode 100644 Core/Tests/GeneratorBranchJobPoolTests.cs.meta create mode 100644 Core/Tests/HotspotBorderTests.cs create mode 100644 Core/Tests/HotspotBorderTests.cs.meta create mode 100644 Core/Tests/HotspotConformingTests.cs create mode 100644 Core/Tests/HotspotConformingTests.cs.meta create mode 100644 Core/Tests/HotspotCutCacheTests.cs create mode 100644 Core/Tests/HotspotCutCacheTests.cs.meta create mode 100644 Core/Tests/HotspotDiagnosticsTests.cs create mode 100644 Core/Tests/HotspotDiagnosticsTests.cs.meta create mode 100644 Core/Tests/HotspotGroupsIncrementalTests.cs create mode 100644 Core/Tests/HotspotGroupsIncrementalTests.cs.meta create mode 100644 Core/Tests/HotspotGroupsTests.cs create mode 100644 Core/Tests/HotspotGroupsTests.cs.meta create mode 100644 Core/Tests/HotspotLayoutCutsTests.cs create mode 100644 Core/Tests/HotspotLayoutCutsTests.cs.meta create mode 100644 Core/Tests/HotspotLayoutImportTests.cs create mode 100644 Core/Tests/HotspotLayoutImportTests.cs.meta create mode 100644 Core/Tests/HotspotMatcherTests.cs create mode 100644 Core/Tests/HotspotMatcherTests.cs.meta create mode 100644 Core/Tests/HotspotRepeatTests.cs create mode 100644 Core/Tests/HotspotRepeatTests.cs.meta create mode 100644 Core/Tests/HotspotStoreTests.cs create mode 100644 Core/Tests/HotspotStoreTests.cs.meta create mode 100644 Core/Tests/InternedPlanesTests.cs create mode 100644 Core/Tests/InternedPlanesTests.cs.meta create mode 100644 Core/Tests/LightmapUVLayoutTests.cs create mode 100644 Core/Tests/LightmapUVLayoutTests.cs.meta create mode 100644 Core/Tests/LoopEdgeSplitterTests.cs create mode 100644 Core/Tests/LoopEdgeSplitterTests.cs.meta create mode 100644 Core/Tests/LoopSplitCapacityTests.cs create mode 100644 Core/Tests/LoopSplitCapacityTests.cs.meta create mode 100644 Core/Tests/LoopValidationTests.cs create mode 100644 Core/Tests/LoopValidationTests.cs.meta create mode 100644 Core/Tests/MathExtensionsTests.cs create mode 100644 Core/Tests/MathExtensionsTests.cs.meta create mode 100644 Core/Tests/MeshManifoldValidationTests.cs create mode 100644 Core/Tests/MeshManifoldValidationTests.cs.meta create mode 100644 Core/Tests/MeshQueryRoutingTests.cs create mode 100644 Core/Tests/MeshQueryRoutingTests.cs.meta create mode 100644 Core/Tests/OutputMeshBoundsTests.cs create mode 100644 Core/Tests/OutputMeshBoundsTests.cs.meta create mode 100644 Core/Tests/OutputModelWeldTests.cs create mode 100644 Core/Tests/OutputModelWeldTests.cs.meta create mode 100644 Core/Tests/OutputWeldTests.cs create mode 100644 Core/Tests/OutputWeldTests.cs.meta create mode 100644 Core/Tests/PlaneDefinedBrushTests.cs create mode 100644 Core/Tests/PlaneDefinedBrushTests.cs.meta create mode 100644 Core/Tests/PlaneExtensionsTests.cs create mode 100644 Core/Tests/PlaneExtensionsTests.cs.meta create mode 100644 Core/Tests/RangeAndEdgeTests.cs create mode 100644 Core/Tests/RangeAndEdgeTests.cs.meta create mode 100644 Core/Tests/RoutingTableCapacityTests.cs create mode 100644 Core/Tests/RoutingTableCapacityTests.cs.meta create mode 100644 Core/Tests/SkippedTreeUpdateTests.cs create mode 100644 Core/Tests/SkippedTreeUpdateTests.cs.meta create mode 100644 Core/Tests/TreeInputHashTests.cs create mode 100644 Core/Tests/TreeInputHashTests.cs.meta create mode 100644 Core/Tests/UVMatrixTests.cs create mode 100644 Core/Tests/UVMatrixTests.cs.meta create mode 100644 Core/Tests/WeldIncidenceTests.cs create mode 100644 Core/Tests/WeldIncidenceTests.cs.meta create mode 100644 Core/Tests/com.chisel.core.tests.asmdef create mode 100644 Core/Tests/com.chisel.core.tests.asmdef.meta create mode 100644 Editor/Tests.meta create mode 100644 Editor/Tests/ChiselHotspotGroupEditTests.cs create mode 100644 Editor/Tests/ChiselHotspotGroupEditTests.cs.meta create mode 100644 Editor/Tests/com.chisel.unity.editor.tests.asmdef create mode 100644 Editor/Tests/com.chisel.unity.editor.tests.asmdef.meta diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee50a67e..be287c07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,11 @@ almost always belongs in the CSG, not in a special case in the generator. * New features should work for any level someone builds by hand, not only for one kind of content, and be reachable from the editor's UI. -The automated test suites aren't part of this repository yet, so please describe how you checked your change. +## Tests + +The tests are in `Core/Tests`, `Components/Tests` and `Editor/Tests`. With Chisel embedded in your project's +`Packages` folder, run them from **Window > General > Test Runner** (EditMode), in a scene without a Chisel model. +Please run them before opening a pull request, and add tests for what you fix or build. ## License diff --git a/Components/Tests.meta b/Components/Tests.meta new file mode 100644 index 00000000..ffaf2ca0 --- /dev/null +++ b/Components/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b2ea2357adb75db45b75c8bea82c6b8d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Components/Tests/ChiselBoxComponentTests.cs b/Components/Tests/ChiselBoxComponentTests.cs new file mode 100644 index 00000000..b96f0018 --- /dev/null +++ b/Components/Tests/ChiselBoxComponentTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselBoxComponentTests + { + readonly List roots = new(); + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselBoxComponent Box(out ChiselModelComponent model) + { + var root = new GameObject("box test"); + roots.Add(root); + model = root.AddComponent(); + var box = new GameObject("box"); + box.transform.SetParent(root.transform, false); + return box.AddComponent(); + } + + // The bounds of what the model draws + static Bounds DrawnBounds(ChiselModelComponent model) + { + var bounds = new Bounds(); + var first = true; + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.sharedMesh == null || renderable.sharedMesh.vertexCount == 0) + continue; + if (first) bounds = renderable.sharedMesh.bounds; + else bounds.Encapsulate(renderable.sharedMesh.bounds); + first = false; + } + Assert.That(first, Is.False, "the model draws nothing"); + return bounds; + } + + static void AreEqual(Vector3 expected, Vector3 actual, string message) + { + Assert.That(Vector3.Distance(expected, actual), Is.LessThan(1e-4f), $"{message}: expected {expected}, got {actual}"); + } + + [Test] + public void MinAndMax_SetFromCode_ResizeTheBrush() + { + var box = Box(out var model); + box.Min = new float3(-2, -1, -2); + box.Max = new float3(2, 0, 2); + Update(); + var drawn = DrawnBounds(model); + AreEqual(new Vector3(-2, -1, -2), drawn.min, "min"); + AreEqual(new Vector3(2, 0, 2), drawn.max, "max"); + } + + [Test] + public void SizeSetFromCode_ResizesABoxThatWasAlreadyBuilt() + { + var box = Box(out var model); + box.Size = new float3(2, 2, 2); + Update(); + AreEqual(new Vector3(2, 2, 2), DrawnBounds(model).size, "the box wasn't built at its first size"); + + box.Size = new float3(6, 2, 2); + Update(); + AreEqual(new Vector3(6, 2, 2), DrawnBounds(model).size, "resizing a built box never reached its mesh"); + } + + [Test] + public void SizeAndCenter_SetFromCode_MoveAndResizeTheBrush() + { + var box = Box(out var model); + box.Size = new float3(2, 4, 6); + box.Center = new float3(-5, 3, 1); + Update(); + var drawn = DrawnBounds(model); + AreEqual(new Vector3(-5, 3, 1), drawn.center, "center"); + AreEqual(new Vector3(2, 4, 6), drawn.size, "size"); + } + } +} diff --git a/Components/Tests/ChiselBoxComponentTests.cs.meta b/Components/Tests/ChiselBoxComponentTests.cs.meta new file mode 100644 index 00000000..bde27479 --- /dev/null +++ b/Components/Tests/ChiselBoxComponentTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e9558237fc18480d971fbda542380d51 diff --git a/Components/Tests/ChiselCollisionHelperTests.cs b/Components/Tests/ChiselCollisionHelperTests.cs new file mode 100644 index 00000000..fa73e043 --- /dev/null +++ b/Components/Tests/ChiselCollisionHelperTests.cs @@ -0,0 +1,181 @@ +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselCollisionHelperTests + { + const int kCollisionHelper = ChiselGeneratedObjects.kColliderDebugVisualizationIndex; + + readonly List roots = new(); + int debugVisualizations; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + // the collision helper is a debug visualization: made while a draw mode shows it + debugVisualizations = CompactHierarchyManager.DebugVisualizations; + CompactHierarchyManager.DebugVisualizations = debugVisualizations | (1 << kCollisionHelper); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + CompactHierarchyManager.DebugVisualizations = debugVisualizations; + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + // A box, with every face on the given material, or on the defaults without one + ChiselModelComponent ModelWithABox(Material material = null) + { + var root = new GameObject("Collision helper Model"); + roots.Add(root); + var model = root.AddComponent(); + AddBox(model, Vector3.zero, material); + Update(); + return model; + } + + static ChiselBoxComponent AddBox(ChiselModelComponent model, Vector3 position, Material material = null) + { + var boxObject = new GameObject("Box"); + boxObject.transform.SetParent(model.transform, false); + boxObject.transform.localPosition = position; + var box = boxObject.AddComponent(); + box.OnValidate(); + if (material) + { + var surfaces = box.surfaceArray.surfaces; + Assert.That(surfaces, Is.Not.Empty); + for (int i = 0; i < surfaces.Length; i++) + surfaces[i] = ChiselSurface.Create(material); + box.OnValidate(); + } + return box; + } + + // The collision helper's renderers: one per chunk it shows + static List Helpers(ChiselModelComponent model) + { + var helpers = new List(); + foreach (var renderable in model.generated.debugVisualizationRenderables) + { + if (renderable != null && renderable.debugMode == kCollisionHelper) + helpers.Add(renderable); + } + return helpers; + } + + static int IndexCount(Mesh mesh) + { + var count = 0; + for (int s = 0; s < mesh.subMeshCount; s++) + count += (int)mesh.GetIndexCount(s); + return count; + } + + static void AssertShowsTheColliders(ChiselModelComponent model) + { + var helpers = Helpers(model); + var helperVertices = 0; + var helperIndices = 0; + foreach (var helper in helpers) + { + helperVertices += helper.sharedMesh.vertexCount; + helperIndices += IndexCount(helper.sharedMesh); + Assert.That(helper.renderMaterials, Is.Not.Empty); + Assert.That(helper.renderMaterials, Is.All.EqualTo(ChiselProjectSettings.CollisionSurfacesMaterial)); + } + Assert.That(helperVertices, Is.GreaterThan(0), "the collision helper shows nothing"); + + // The same triangles: a surface's collider and render vertices share one index buffer + var colliderIndices = 0; + foreach (var collider in model.generated.colliders) + colliderIndices += IndexCount(collider.sharedMesh); + Assert.That(colliderIndices, Is.GreaterThan(0), "the model has no collider"); + Assert.AreEqual(colliderIndices, helperIndices, "the helper shows other triangles than the colliders"); + } + + [Test] + public void TheCollisionHelper_ShowsWhatTheModelCollidesWith() + { + AssertShowsTheColliders(ModelWithABox()); + } + + // A debug visualization no draw mode shows is not made; a draw mode that shows it makes it at the next update, + // without building a brush again + [Test] + public void TheCollisionHelper_IsMadeOnlyWhileADrawModeShowsIt() + { + CompactHierarchyManager.DebugVisualizations = debugVisualizations & ~(1 << kCollisionHelper); + var model = ModelWithABox(); + Assert.That(Helpers(model), Is.Empty, "the helper was made with no draw mode showing it"); + + CompactHierarchyManager.DebugVisualizations = debugVisualizations | (1 << kCollisionHelper); + Update(); + AssertShowsTheColliders(model); + Assert.That(CompactHierarchyManager.LastUpdateModifiedBrushCount, Is.EqualTo(0), "a brush was built again"); + } + + static readonly Bounds kMarker = new(new Vector3(1000, 1000, 1000), new Vector3(1, 1, 1)); + + // Like the render meshes, a mesh per 32 m chunk, and one that is made from the same as last time is kept + [Test] + public void TheCollisionHelper_IsAMeshPerChunk_AndAnEditInOneKeepsTheOther() + { + var model = ModelWithABox(); + var near = model.GetComponentInChildren(); + AddBox(model, new Vector3(40, 0, 0)); + Update(); + var helpers = Helpers(model); + Assert.That(helpers.Count, Is.EqualTo(2), "a mesh per chunk"); + AssertShowsTheColliders(model); + var far = helpers.Find(h => h.sharedMesh.bounds.center.x > 32); + Assert.That(far, Is.Not.Null, "the far box's chunk"); + far.sharedMesh.bounds = kMarker; + + near.transform.position += new Vector3(0, 0, 0.5f); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(near); + Update(); + + Assert.That(Helpers(model), Does.Contain(far), "the far chunk's renderer"); + Assert.That(far.sharedMesh.bounds, Is.EqualTo(kMarker), "its mesh was not made again"); + } + + // A surface that only collides (the Collision helper material) draws nothing, and the helper is where it shows + [Test] + public void TheCollisionHelper_ShowsSurfacesThatOnlyCollide() + { + var collision = ChiselProjectSettings.CollisionSurfacesMaterial; + Assert.IsNotNull(collision, "Collision"); + var model = ModelWithABox(collision); + AssertShowsTheColliders(model); + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || !renderable.Valid) + continue; + Assert.IsFalse(renderable.meshRenderer.enabled && renderable.sharedMesh.vertexCount > 0, renderable.container.name); + } + } + } +} diff --git a/Components/Tests/ChiselCollisionHelperTests.cs.meta b/Components/Tests/ChiselCollisionHelperTests.cs.meta new file mode 100644 index 00000000..f793b0bd --- /dev/null +++ b/Components/Tests/ChiselCollisionHelperTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6aa2b1ef8e7747d0a86d0164bd196adc diff --git a/Components/Tests/ChiselDecalComponentTests.cs b/Components/Tests/ChiselDecalComponentTests.cs new file mode 100644 index 00000000..89370444 --- /dev/null +++ b/Components/Tests/ChiselDecalComponentTests.cs @@ -0,0 +1,441 @@ +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselDecalComponentTests + { + readonly List created = new(); + Material decalMaterial; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + decalMaterial = new Material(Shader.Find("Standard")) { name = "DecalTestMaterial" }; + created.Add(decalMaterial); + } + + [TearDown] + public void TearDown() + { + foreach (var item in created) + { + if (item) + Object.DestroyImmediate(item); + } + created.Clear(); + Update(); + DeleteSheets(); + } + + const string kSheetFolder = "Assets/DecalSheetTests"; + static readonly List s_Layouts = new(); + + // A material asset (metadata needs one) whose hotspot layout has tile 1 at u 0.5 to 1, v 0 to 0.25 + internal static Material SheetMaterial(Material source) + { + var layout = ScriptableObject.CreateInstance(); + s_Layouts.Add(layout); + layout.size = new Vector2Int(4, 4); + // rows run down from the top: v 0 to 0.25 is the bottom row + layout.tiles = new List { new HotspotTile { id = 1, name = "Trim", points = new[] { new Vector2Int(2, 3), new Vector2Int(4, 3), new Vector2Int(4, 4), new Vector2Int(2, 4) } } }; + if (!AssetDatabase.IsValidFolder(kSheetFolder)) + AssetDatabase.CreateFolder("Assets", kSheetFolder.Substring("Assets/".Length)); + var material = new Material(source); + AssetDatabase.CreateAsset(material, AssetDatabase.GenerateUniqueAssetPath(kSheetFolder + "/sheet.mat")); + material.AddMetadataOfType().hotspotLayout = layout; + return material; + } + + internal static void DeleteSheets() + { + if (AssetDatabase.IsValidFolder(kSheetFolder)) + AssetDatabase.DeleteAsset(kSheetFolder); + foreach (var layout in s_Layouts) + { + if (layout) + Object.DestroyImmediate(layout); + } + s_Layouts.Clear(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselModelComponent Floors(out ChiselBoxComponent a, out ChiselBoxComponent b, out ChiselDecalComponent decal) + { + var root = new GameObject("decal test"); + created.Add(root); + var model = root.AddComponent(); + a = Box("A", model.transform, new Vector3(-1, -1, -0.5f)); + b = Box("B", model.transform, new Vector3(0, -1, -0.5f)); + + var decalObject = new GameObject("Decal"); + decalObject.transform.SetParent(model.transform, false); + decalObject.transform.rotation = Quaternion.LookRotation(Vector3.down, Vector3.forward); + decal = decalObject.AddComponent(); + decal.Material = decalMaterial; + decal.Size = new Vector3(1, 1, 0.5f); + decal.Transparent = false; + return model; + } + + static ChiselBoxComponent Box(string name, Transform parent, Vector3 localPosition) + { + var gameObject = new GameObject(name); + gameObject.transform.SetParent(parent, false); + gameObject.transform.localPosition = localPosition; + return gameObject.AddComponent(); + } + + // The area of the upward triangles drawn with the decal's material, and of the others + (double decal, double other) UpAreas(ChiselModelComponent model) + { + double decal = 0, other = 0; + if (model.generated?.renderables == null) + return (decal, other); + foreach (var r in model.generated.renderables) + { + if (r == null || !r.Valid || r.sharedMesh == null || r.sharedMesh.vertexCount == 0) + continue; + const SurfaceDestinationFlags kDrawsAndCasts = SurfaceDestinationFlags.RenderShadowReceiveAndCasting; + if ((r.query & kDrawsAndCasts) != kDrawsAndCasts) + continue; + var vertices = r.sharedMesh.vertices; + for (int s = 0; s < r.sharedMesh.subMeshCount; s++) + { + var isDecal = r.renderMaterials != null && s < r.renderMaterials.Length && r.renderMaterials[s] == decalMaterial; + var triangles = r.sharedMesh.GetTriangles(s); + for (int t = 0; t < triangles.Length; t += 3) + { + var normal = Vector3.Cross(vertices[triangles[t + 1]] - vertices[triangles[t]], vertices[triangles[t + 2]] - vertices[triangles[t]]); + if (normal.normalized.y < 0.99f) + continue; + if (isDecal) decal += normal.magnitude * 0.5; + else other += normal.magnitude * 0.5; + } + } + } + return (decal, other); + } + + static ChiselDecalSurfaceTarget Target(ChiselNodeComponent brush, int surface) + { + return new ChiselDecalSurfaceTarget { brush = brush, surfaceIndex = surface }; + } + + const double kTolerance = 1e-4; + + // The decals of each stacking order, as the tree stacks them (lowest first) + static List Stacked(ChiselModelComponent model) + { + var data = ChiselTreeLookup.Value[model.Node]; + data.lastJobHandle.Complete(); + var stacked = new List(); + foreach (var volume in data.decalVolumes) + stacked.Add(volume.entityID); + return stacked; + } + + static ulong ID(Object item) => EntityId.ToULong(item.GetEntityId()); + + // Decals of one order stack in hierarchy order, the last on top, the same every time a scene is loaded + [Test] + public void Decals_StackInHierarchyOrder() + { + var model = Floors(out _, out _, out var first); + var secondObject = Object.Instantiate(first.gameObject, model.transform); + secondObject.name = "Decal 2"; + var second = secondObject.GetComponent(); + Update(); + Assert.That(Stacked(model), Is.EqualTo(new[] { ID(first), ID(second) })); + + secondObject.transform.SetSiblingIndex(first.transform.GetSiblingIndex()); + ChiselDecalManager.HierarchyChanged(); + Update(); + Assert.That(Stacked(model), Is.EqualTo(new[] { ID(second), ID(first) }), "moved up, it is under the other"); + } + + // Transparent decals of other materials, overlapping: one mesh, a sub-mesh per material in hierarchy order, so + // they draw stacked as the hierarchy has them + ChiselDecalComponent TransparentDecal(ChiselModelComponent model, string name, Material material, Vector3 position) + { + var decalObject = new GameObject(name); + decalObject.transform.SetParent(model.transform, false); + decalObject.transform.localPosition = position; + decalObject.transform.rotation = Quaternion.LookRotation(Vector3.down, Vector3.forward); + var decal = decalObject.AddComponent(); + decal.Material = material; + decal.Size = new Vector3(1, 1, 0.5f); + decal.Transparent = true; + return decal; + } + + // The materials of each chunk's transparent decals' renderer, in sub-mesh order (the decals here straddle x = 0, + // so they are in two 32 m cells) + static void AssertTransparentDecalsDrawn(ChiselModelComponent model, Material[] expected, string message = null) + { + var found = 0; + foreach (var r in model.generated.renderables) + { + if (r == null || !r.Valid || r.surfaceParameter != ChiselDecalMeshes.kTransparentDecals) + continue; + Assert.That(r.renderMaterials, Is.EqualTo(expected), message); + found++; + } + Assert.That(found, Is.GreaterThan(0), "the transparent decals have no renderer of their own"); + } + + [Test] + public void TransparentDecalsOfOtherMaterials_DrawInHierarchyOrder() + { + var model = Floors(out _, out _, out var first); + first.Transparent = true; + var other = new Material(decalMaterial) { name = "OtherDecalTestMaterial" }; + created.Add(other); + var second = TransparentDecal(model, "Decal 2", other, new Vector3(0.25f, 0, 0)); + Update(); + AssertTransparentDecalsDrawn(model, new[] { decalMaterial, other }, "lowest first"); + + second.transform.SetSiblingIndex(first.transform.GetSiblingIndex()); + ChiselDecalManager.HierarchyChanged(); + Update(); + AssertTransparentDecalsDrawn(model, new[] { other, decalMaterial }, "moved up, it is under the other"); + } + + // A material between two decals of another is drawn between them + [Test] + public void TransparentDecals_StackAcrossMaterials() + { + var model = Floors(out _, out _, out var first); + first.Transparent = true; + var other = new Material(decalMaterial) { name = "OtherDecalTestMaterial" }; + created.Add(other); + TransparentDecal(model, "Decal 2", other, new Vector3(0.25f, 0, 0)); + TransparentDecal(model, "Decal 3", decalMaterial, new Vector3(-0.25f, 0, 0)); + Update(); + AssertTransparentDecalsDrawn(model, new[] { decalMaterial, other, decalMaterial }); + } + + // The texture coordinates of the triangles drawn with the decal's material + List DecalUVs(ChiselModelComponent model) + { + var found = new List(); + foreach (var r in model.generated.renderables) + { + if (r == null || !r.Valid || r.sharedMesh == null || r.sharedMesh.vertexCount == 0 || (r.query & SurfaceDestinationFlags.Renderable) == 0) + continue; + var uvs = r.sharedMesh.uv; + for (int s = 0; s < r.sharedMesh.subMeshCount; s++) + { + if (r.renderMaterials == null || s >= r.renderMaterials.Length || r.renderMaterials[s] != decalMaterial) + continue; + foreach (var index in r.sharedMesh.GetTriangles(s)) + found.Add(uvs[index]); + } + } + return found; + } + + static Vector2 Range(List uvs, out Vector2 min) + { + min = new Vector2(float.PositiveInfinity, float.PositiveInfinity); + var max = new Vector2(float.NegativeInfinity, float.NegativeInfinity); + foreach (var uv in uvs) + { + min = Vector2.Min(min, uv); + max = Vector2.Max(max, uv); + } + return max - min; + } + + // The whole texture repeats by wrapping: one decal, its image 2 by 3 times across it + [Test] + public void ARepeatedDecal_RepeatsItsImageAcrossTheBox() + { + var model = Floors(out _, out _, out var decal); + decal.Repeat = new Vector2(2, 3); + Update(); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(1)); + var range = Range(DecalUVs(model), out _); + Assert.That(range.x, Is.EqualTo(2).Within(1e-4f)); + Assert.That(range.y, Is.EqualTo(3).Within(1e-4f)); + } + + // An image in a trim sheet can't wrap: a tile per image, every one inside its rect, the last one cut short + [Test] + public void ARepeatedSheetImage_IsATilePerImage_InsideItsRect() + { + var model = Floors(out _, out _, out var decal); + decal.Material = decalMaterial = SheetMaterial(decalMaterial); + decal.ImageTile = 1; + Assert.That(decal.ImageRect, Is.EqualTo(new Rect(0.5f, 0, 0.5f, 0.25f))); + decal.Repeat = new Vector2(2.5f, 1); + Update(); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(3), "two whole images and half of one"); + Assert.That(UpAreas(model).decal, Is.EqualTo(1.0).Within(kTolerance), "the whole box still"); + foreach (var uv in DecalUVs(model)) + { + Assert.That(uv.x, Is.InRange(0.5f - 1e-4f, 1 + 1e-4f), $"u {uv}"); + Assert.That(uv.y, Is.InRange(-1e-4f, 0.25f + 1e-4f), $"v {uv}"); + } + } + + [Test] + public void Decal_IsDrawnOnTheFloors() + { + var model = Floors(out _, out _, out _); + Update(); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(1)); + var (decal, other) = UpAreas(model); + Assert.That(decal, Is.EqualTo(1.0).Within(kTolerance)); + Assert.That(other, Is.EqualTo(1.0).Within(kTolerance), "the opaque decal replaces a square metre of the two floors"); + } + + // Nothing tells the manager about a moved transform: it has to see it + [Test] + public void MovedDecal_MovesOnTheFloors() + { + var model = Floors(out _, out _, out var decal); + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(1.0).Within(kTolerance)); + + decal.transform.localPosition = new Vector3(1, 0, 0); + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(0.5).Within(kTolerance), "half of it is past the floors' end"); + } + + [Test] + public void MovedModel_MovesItsFloorsUnderADecalOutsideIt() + { + var model = Floors(out _, out _, out var decal); + decal.transform.SetParent(null, true); + created.Add(decal.gameObject); + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(1.0).Within(kTolerance)); + + model.transform.position = new Vector3(-1, 0, 0); + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(0.5).Within(kTolerance), "the floors moved half out from under it"); + } + + // Rebuild recreates every tree, and a new tree can get the handle the old one had + [Test] + public void Rebuild_KeepsTheDecals() + { + var model = Floors(out _, out _, out _); + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(1.0).Within(kTolerance)); + + ChiselNodeHierarchyManager.Rebuild(); + Update(); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(1), "the rebuilt tree has the decal again"); + Assert.That(UpAreas(model).decal, Is.EqualTo(1.0).Within(kTolerance)); + } + + [Test] + public void Targets_LimitTheDecalToTheirSurfaces() + { + // A box's top is its surface 0 and its bottom surface 1 (ChiselBoxBrushMeshFactory.BoxSides) + const int kTop = 0, kBottom = 1; + var model = Floors(out var a, out var b, out var decal); + + decal.SetTargets(new[] { Target(a, ChiselDecalSurfaceTarget.kAllSurfaces) }); + Update(); + var areas = UpAreas(model); + Assert.That(areas.decal, Is.EqualTo(0.5).Within(kTolerance), "on A only"); + Assert.That(areas.other, Is.EqualTo(1.5).Within(kTolerance), "B keeps its top"); + + decal.SetTargets(new[] { Target(b, kTop) }); + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(0.5).Within(kTolerance), "on B's top"); + + // the second is a trim's, kept for switching back: a box decal only takes the first + decal.SetTargets(new[] { Target(b, kTop), Target(a, ChiselDecalSurfaceTarget.kAllSurfaces) }); + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(0.5).Within(kTolerance), "on B's top only"); + + decal.SetTargets(new[] { Target(b, kBottom) }); + Update(); + areas = UpAreas(model); + Assert.That(areas.decal, Is.EqualTo(0).Within(kTolerance), "B's bottom is out of reach, and nothing else is a target"); + Assert.That(areas.other, Is.EqualTo(2).Within(kTolerance)); + + // A deleted target: nothing is drawn, and once the decal is looked at again it is left out altogether + decal.SetTargets(new[] { Target(a, ChiselDecalSurfaceTarget.kAllSurfaces) }); + Update(); + Object.DestroyImmediate(a.gameObject); + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(0).Within(kTolerance)); + ChiselDecalManager.SetDirty(); + Update(); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(0)); + Assert.That(UpAreas(model).other, Is.EqualTo(1).Within(kTolerance), "B's whole top"); + } + + [Test] + public void ARemovedModel_LeavesNoMeshLoaded_AfterTheUpdateThatFindsItGone() + { + ChiselDestroyedModelTests.AssertNoneLoadedAfterAnUnload(DrawAndRemoveFloors()); + } + + // In a method of its own, so that the test refers to nothing of the model when the unload runs + List<(string what, EntityId id)> DrawAndRemoveFloors() + { + var model = Floors(out _, out _, out _); + Update(); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(1), "no decal drawn: the decal manager never had the model"); + var meshes = new List<(string what, EntityId id)>(); + ChiselDestroyedModelTests.RecordMeshes(model, meshes); + Assert.That(meshes.Count, Is.GreaterThan(0), "the model made no meshes: nothing is measured"); + + var root = model.gameObject; + created.Remove(root); + Object.DestroyImmediate(root); + // One update: the next one would start by clearing what this one kept + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + return meshes; + } + + [Test] + public void ARemovedDecal_LeavesItsMaterialUnloadable() + { + var material = DrawAndRemoveADecalWithAMaterialOfItsOwn(); + System.GC.Collect(); + System.GC.WaitForPendingFinalizers(); + System.GC.Collect(); + EditorUtility.UnloadUnusedAssetsImmediate(true); + Assert.That((bool)Resources.EntityIdToObject(material), Is.False, "the removed decal's material is still loaded: something still refers to it"); + } + + // In a method of its own, so that the test refers to nothing of the decal or its material when the unload runs. + // The material is the test's own and never destroyed: only an unload can take it. + EntityId DrawAndRemoveADecalWithAMaterialOfItsOwn() + { + var material = new Material(Shader.Find("Standard")) { name = "Removed Decal Material" }; + var model = Floors(out _, out _, out var decal); + decal.Material = material; + Update(); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(1), "no decal drawn: the decal manager never had the material"); + + var root = model.gameObject; + created.Remove(root); + Object.DestroyImmediate(root); + Update(); + return material.GetEntityId(); + } + } +} diff --git a/Components/Tests/ChiselDecalComponentTests.cs.meta b/Components/Tests/ChiselDecalComponentTests.cs.meta new file mode 100644 index 00000000..33d2acf6 --- /dev/null +++ b/Components/Tests/ChiselDecalComponentTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a8136119617a4cf4af87a6907bcfe0e3 diff --git a/Components/Tests/ChiselDestroyedModelTests.cs b/Components/Tests/ChiselDestroyedModelTests.cs new file mode 100644 index 00000000..a903122b --- /dev/null +++ b/Components/Tests/ChiselDestroyedModelTests.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselDestroyedModelTests + { + const int kModelCount = 2; + + (Scene scene, bool wasDirty) sceneAtStart; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + sceneAtStart = SceneLeftClean.Remember(); + } + + // After everything a test measured + [TearDown] + public void TearDown() + { + SceneLeftClean.Restore(sceneAtStart); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + // Every mesh a model made, by id: what the unload is asked about + internal static void RecordMeshes(ChiselModelComponent model, List<(string what, EntityId id)> meshes) + { + foreach (var renderables in new[] { model.generated.renderables, model.generated.debugVisualizationRenderables }) + { + foreach (var renderable in renderables) + { + if (renderable == null || renderable.invalid) + continue; + if (renderable.sharedMesh) meshes.Add((renderable.sharedMesh.name + " (render mesh)", renderable.sharedMesh.GetEntityId())); + if (renderable.partialMesh) meshes.Add((renderable.partialMesh.name + " (partial mesh)", renderable.partialMesh.GetEntityId())); + if (renderable.selectionMesh) meshes.Add((renderable.selectionMesh.name + " (selection mesh)", renderable.selectionMesh.GetEntityId())); + } + } + foreach (var collider in model.generated.colliders) + { + if (collider != null && collider.sharedMesh) + meshes.Add((collider.sharedMesh.name + " (collider mesh)", collider.sharedMesh.GetEntityId())); + } + } + + static List<(string what, EntityId id)> BuildAndDestroyModels(bool keptTheirSavedMeshes, bool rebuildFirst = false) + { + var roots = new List(); + var models = new List(); + for (int i = 0; i < kModelCount; i++) + { + var root = new GameObject("Destroyed Model " + i); + roots.Add(root); + models.Add(root.AddComponent()); + var boxObject = new GameObject("Box"); + boxObject.transform.SetParent(root.transform, false); + boxObject.AddComponent().OnValidate(); + } + Update(); + + if (keptTheirSavedMeshes) + { + ChiselModelManager.Instance.StoreInputHashes(roots[0].scene); + foreach (var root in roots) + root.SetActive(false); + Update(); + foreach (var root in roots) + root.SetActive(true); + Update(); + foreach (var model in models) + Assert.That(ChiselModelManager.HasNoNodes(model), Is.True, $"{model.name} did not keep its saved meshes: this measures nothing"); + } + + var meshes = new List<(string what, EntityId id)>(); + foreach (var model in models) + RecordMeshes(model, meshes); + Assert.That(meshes.Count, Is.GreaterThan(0), "the models made no meshes: nothing is measured"); + + foreach (var root in roots) + Object.DestroyImmediate(root); + if (rebuildFirst) + ChiselNodeHierarchyManager.Rebuild(); + Update(); + return meshes; + } + + internal static void AssertNoneLoadedAfterAnUnload(List<(string what, EntityId id)> meshes) + { + System.GC.Collect(); + System.GC.WaitForPendingFinalizers(); + System.GC.Collect(); + EditorUtility.UnloadUnusedAssetsImmediate(true); + + var stillLoaded = new List(); + foreach (var (what, id) in meshes) + { + if (Resources.EntityIdToObject(id)) + stillLoaded.Add(what); + } + Assert.That(stillLoaded, Is.Empty, $"{stillLoaded.Count} of the {meshes.Count} meshes of the destroyed models are still loaded: something still refers to the models"); + } + + [Test] + public void ADestroyedModel_LeavesNoMeshLoaded() + { + AssertNoneLoadedAfterAnUnload(BuildAndDestroyModels(keptTheirSavedMeshes: false)); + } + + [Test] + public void ADestroyedModelThatKeptItsSavedMeshes_LeavesNoMeshLoaded() + { + AssertNoneLoadedAfterAnUnload(BuildAndDestroyModels(keptTheirSavedMeshes: true)); + } + + [Test] + public void ADestroyedModel_LeavesNoMeshLoaded_WhenARebuildComesFirst() + { + AssertNoneLoadedAfterAnUnload(BuildAndDestroyModels(keptTheirSavedMeshes: false, rebuildFirst: true)); + } + + [Test] + public void ADestroyedModelThatKeptItsSavedMeshes_LeavesNoMeshLoaded_WhenARebuildComesFirst() + { + AssertNoneLoadedAfterAnUnload(BuildAndDestroyModels(keptTheirSavedMeshes: true, rebuildFirst: true)); + } + + // Models under a root that stays inactive, destroyed before it was ever activated + static List<(string what, EntityId id)> BuildAndDestroyANeverActiveModel() + { + var root = new GameObject("Never Active"); + var meshes = new List<(string what, EntityId id)>(); + try + { + root.SetActive(false); + var model = ChiselModelManager.Instance.CreateNewModel(root.transform); + ChiselComponentFactory.Create(model); + Assert.That(model.generated, Is.Not.Null, "the model made no generated objects: this measures nothing"); + // its renderers come with its meshes, so it has none yet; whatever it ever makes goes with it + RecordMeshes(model, meshes); + } + finally + { + Object.DestroyImmediate(root); + Update(); + } + return meshes; + } + + [Test] + public void AModelThatNeverBecameActive_LeavesNoMeshLoaded() + { + AssertNoneLoadedAfterAnUnload(BuildAndDestroyANeverActiveModel()); + } + + // Only the component: its GameObject stays, and with it the generated objects under it, which drew the model's + // last meshes for as long as the scene stayed open + [Test] + public void ARemovedModelComponent_TakesWhatItMadeWithIt() + { + var root = new GameObject("Removed Model Component"); + try + { + // No brush: once the model is gone it would go to the scene's default model, and make one + var model = root.AddComponent(); + Update(); + var container = model.generated?.generatedDataContainer; + Assert.That((bool)container, Is.True, "the model made no generated objects: this measures nothing"); + + Object.DestroyImmediate(model); + Update(); + Assert.That((bool)container, Is.False, "the removed model component's generated objects are still there"); + } + finally + { + Object.DestroyImmediate(root); + } + } + } +} diff --git a/Components/Tests/ChiselDestroyedModelTests.cs.meta b/Components/Tests/ChiselDestroyedModelTests.cs.meta new file mode 100644 index 00000000..db5ccb5a --- /dev/null +++ b/Components/Tests/ChiselDestroyedModelTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bcd791c506ec4883ad92514f5b2f88cc \ No newline at end of file diff --git a/Components/Tests/ChiselDiscardedVisualizationTests.cs b/Components/Tests/ChiselDiscardedVisualizationTests.cs new file mode 100644 index 00000000..e3a25fa5 --- /dev/null +++ b/Components/Tests/ChiselDiscardedVisualizationTests.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + // The Discarded debug visualization: what the CSG removed of each surface, only while a draw mode shows it + [TestFixture] + public class ChiselDiscardedVisualizationTests + { + const int kDiscarded = AssignMeshesJob.kDiscardedVisualization; + + readonly List roots = new(); + int debugVisualizations; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + debugVisualizations = CompactHierarchyManager.DebugVisualizations; + CompactHierarchyManager.DebugVisualizations = debugVisualizations & ~(1 << kDiscarded); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + CompactHierarchyManager.DebugVisualizations = debugVisualizations; + Update(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + // Two unit boxes overlapping by half: each has a side inside the other, and a half of four sides on the other's + ChiselModelComponent OverlappingBoxes() + { + var root = new GameObject("Discarded visualization Model"); + roots.Add(root); + var model = root.AddComponent(); + Box(model, new float3(0, 0, 0), new float3(1, 1, 1)); + Box(model, new float3(0.5f, 0, 0), new float3(1.5f, 1, 1)); + Update(); + return model; + } + + static void Box(ChiselModelComponent model, float3 min, float3 max) + { + var go = new GameObject("Box"); + go.transform.SetParent(model.transform, false); + var box = go.AddComponent(); + if (math.any(min > box.Max)) { box.Max = max; box.Min = min; } + else { box.Min = min; box.Max = max; } + } + + static double Area(Mesh mesh) + { + var area = 0.0; + var vertices = mesh.vertices; + for (int s = 0; s < mesh.subMeshCount; s++) + { + var triangles = mesh.GetTriangles(s); + for (int t = 0; t + 2 < triangles.Length; t += 3) + area += Vector3.Cross(vertices[triangles[t + 1]] - vertices[triangles[t]], vertices[triangles[t + 2]] - vertices[triangles[t]]).magnitude * 0.5; + } + return area; + } + + static double DiscardedArea(ChiselModelComponent model) + { + var area = 0.0; + foreach (var renderable in model.generated.debugVisualizationRenderables) + { + if (renderable != null && renderable.debugMode == kDiscarded && renderable.sharedMesh) + area += Area(renderable.sharedMesh); + } + return area; + } + + static double DrawnArea(ChiselModelComponent model) + { + var area = 0.0; + foreach (var renderable in model.generated.renderables) + { + if (renderable != null && renderable.Valid && renderable.sharedMesh) + area += Area(renderable.sharedMesh); + } + return area; + } + + static double ColliderArea(ChiselModelComponent model) + { + var area = 0.0; + foreach (var collider in model.generated.colliders) + { + if (collider != null && collider.sharedMesh) + area += Area(collider.sharedMesh); + } + return area; + } + + [Test] + public void NothingIsDiscarded_WhileNoDrawModeShowsIt() + { + var model = OverlappingBoxes(); + Assert.That(DiscardedArea(model), Is.EqualTo(0)); + } + + // Each box loses the side inside the other (1 m2 each), and where their top, bottom, front and back lie on one + // another (0.5 m2 each) one of the two is removed + [Test] + public void ShowingIt_ShowsWhatTheCSGRemoved() + { + var model = OverlappingBoxes(); + CompactHierarchyManager.DebugVisualizations |= 1 << kDiscarded; + Update(); + Assert.That(DiscardedArea(model), Is.EqualTo(4.0).Within(1e-4), "the two inner sides and one of each pair on one another"); + } + + // What is removed doesn't change what is drawn or what collides + [Test] + public void ShowingIt_LeavesTheOutputAsItIs() + { + var model = OverlappingBoxes(); + var drawn = DrawnArea(model); + var collider = ColliderArea(model); + Assert.That(drawn, Is.EqualTo(8.0).Within(1e-4), "a 1.5 x 1 x 1 box"); + + CompactHierarchyManager.DebugVisualizations |= 1 << kDiscarded; + Update(); + Assert.That(DrawnArea(model), Is.EqualTo(drawn).Within(1e-5)); + Assert.That(ColliderArea(model), Is.EqualTo(collider).Within(1e-5)); + + CompactHierarchyManager.DebugVisualizations &= ~(1 << kDiscarded); + Update(); + Assert.That(DiscardedArea(model), Is.EqualTo(0), "hidden again, it is made no more"); + Assert.That(DrawnArea(model), Is.EqualTo(drawn).Within(1e-5)); + } + } +} diff --git a/Components/Tests/ChiselDiscardedVisualizationTests.cs.meta b/Components/Tests/ChiselDiscardedVisualizationTests.cs.meta new file mode 100644 index 00000000..ba99c65b --- /dev/null +++ b/Components/Tests/ChiselDiscardedVisualizationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 025af8f809bafa44aba257ab5f2b155d \ No newline at end of file diff --git a/Components/Tests/ChiselDoubleSidedTests.cs b/Components/Tests/ChiselDoubleSidedTests.cs new file mode 100644 index 00000000..7b7d2bb5 --- /dev/null +++ b/Components/Tests/ChiselDoubleSidedTests.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEditor; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + // A surface whose material is double sided (SurfaceOutputFlags.DoubleSided) is drawn from behind too, and collides once + [TestFixture] + public class ChiselDoubleSidedTests + { + const string kFolder = "Assets/ChiselDoubleSidedTests"; + + readonly List roots = new(); + Material material; + ChiselSurfaceMetadata metadata; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + if (!AssetDatabase.IsValidFolder(kFolder)) + AssetDatabase.CreateFolder("Assets", kFolder.Substring("Assets/".Length)); + material = new Material(ChiselProjectSettings.DefaultWallMaterial); + AssetDatabase.CreateAsset(material, kFolder + "/double sided.mat"); + metadata = material.AddMetadataOfType(); + metadata.outputFlags = SurfaceOutputFlags.DoubleSided; + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + AssetDatabase.DeleteAsset(kFolder); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselModelComponent ModelWithABox() + { + var root = new GameObject("double sided test"); + roots.Add(root); + var model = root.AddComponent(); + var go = new GameObject("Box"); + go.transform.SetParent(model.transform, false); + var box = go.AddComponent(); + box.Min = float3.zero; + box.Max = new float3(1); + Update(); + foreach (var surface in box.SurfaceDefinition.surfaces) + surface.SetMaterial(material); + box.OnValidate(); + Update(); + return model; + } + + // The triangles drawn with the material, as (normal times twice its area) + static List DrawnTriangles(ChiselModelComponent model, Material material) + { + var found = new List(); + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || !renderable.Valid || !renderable.HasGeometry || renderable.meshRenderer.sharedMaterial != material) + continue; + var mesh = renderable.sharedMesh; + var vertices = mesh.vertices; + for (int s = 0; s < mesh.subMeshCount; s++) + { + var triangles = mesh.GetTriangles(s); + for (int t = 0; t + 2 < triangles.Length; t += 3) + found.Add(Vector3.Cross(vertices[triangles[t + 1]] - vertices[triangles[t]], vertices[triangles[t + 2]] - vertices[triangles[t]])); + } + } + return found; + } + + static int ColliderTriangles(ChiselModelComponent model) + { + var count = 0; + foreach (var collider in model.generated.colliders) + { + if (collider != null && collider.sharedMesh) + count += collider.sharedMesh.triangles.Length / 3; + } + return count; + } + + static double Area(List triangles) + { + var area = 0.0; + foreach (var t in triangles) + area += t.magnitude * 0.5; + return area; + } + + [Test] + public void ADoubleSidedBox_IsDrawnFromBothSides() + { + var model = ModelWithABox(); + var triangles = DrawnTriangles(model, material); + Assert.That(Area(triangles), Is.EqualTo(12.0).Within(1e-4), "each face twice"); + // every face is drawn facing out and facing in + foreach (var axis in new[] { Vector3.right, Vector3.up, Vector3.forward }) + { + foreach (var sign in new[] { 1, -1 }) + { + var facing = 0.0; + foreach (var t in triangles) + { + if (Vector3.Dot(t.normalized, axis * sign) > 0.999f) + facing += t.magnitude * 0.5; + } + Assert.That(facing, Is.EqualTo(2.0).Within(1e-4), $"facing {axis * sign}: a face of the box, and the back of the opposite one"); + } + } + } + + [Test] + public void ADoubleSidedBox_CollidesOnce() + { + var model = ModelWithABox(); + Assert.That(ColliderTriangles(model), Is.EqualTo(12), "the back faces don't collide"); + } + + [Test] + public void ASingleSidedBox_IsDrawnFromOutsideOnly() + { + metadata.outputFlags = SurfaceOutputFlags.Default; + var model = ModelWithABox(); + Assert.That(Area(DrawnTriangles(model, material)), Is.EqualTo(6.0).Within(1e-4)); + Assert.That(ColliderTriangles(model), Is.EqualTo(12)); + } + } +} diff --git a/Components/Tests/ChiselDoubleSidedTests.cs.meta b/Components/Tests/ChiselDoubleSidedTests.cs.meta new file mode 100644 index 00000000..620c579b --- /dev/null +++ b/Components/Tests/ChiselDoubleSidedTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ed705a90e800dfb4ca5b20f9e34dab85 \ No newline at end of file diff --git a/Components/Tests/ChiselHierarchyEditTests.cs b/Components/Tests/ChiselHierarchyEditTests.cs new file mode 100644 index 00000000..aa094cb3 --- /dev/null +++ b/Components/Tests/ChiselHierarchyEditTests.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using System.Text; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselHierarchyEditTests + { + // A box of the default size that touches nothing draws two triangles per face + const int kLoneBox = 12; + + readonly List roots = new(); + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselModelComponent Model(string name) + { + var root = new GameObject(name); + roots.Add(root); + return root.AddComponent(); + } + + static GameObject Child(string name, Transform parent, Vector3 localPosition) + { + var gameObject = new GameObject(name); + gameObject.transform.SetParent(parent, false); + gameObject.transform.localPosition = localPosition; + return gameObject; + } + + static GameObject Box(string name, Transform parent, Vector3 localPosition) + { + var gameObject = Child(name, parent, localPosition); + gameObject.AddComponent(); + return gameObject; + } + + static int RenderedTriangles(ChiselModelComponent model) + { + var count = 0; + if (model.generated == null || model.generated.renderables == null) + return count; + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.sharedMesh == null) + continue; + for (int s = 0; s < renderable.sharedMesh.subMeshCount; s++) + count += (int)renderable.sharedMesh.GetIndexCount(s) / 3; + } + return count; + } + + // The names of the Chisel children of a GameObject, in scene order + static string SceneOrder(Transform parent) + { + var names = new StringBuilder(); + for (int i = 0; i < parent.childCount; i++) + { + var child = parent.GetChild(i); + if (!child.TryGetComponent(out var node) || !node.isActiveAndEnabled) + continue; + names.Append(names.Length > 0 ? "," : "").Append(child.name); + } + return names.ToString(); + } + + // The names of the components behind the model's top-level tree nodes, in tree order + static string TreeOrder(ChiselModelComponent model) + { + var names = new StringBuilder(); + var components = model.GetComponentsInChildren(true); + for (int i = 0; i < model.Node.Count; i++) + { + var node = model.Node[i]; + var name = "?"; + foreach (var component in components) + { + if (component != model && component.TopTreeNode == node) + { + name = component.name; + break; + } + } + names.Append(names.Length > 0 ? "," : "").Append(name); + } + return names.ToString(); + } + + [Test] + public void DeletingABrush_RebuildsTheBrushItTouched() + { + var model = Model("deletion"); + Box("wall", model.transform, Vector3.zero); + var step = Box("step", model.transform, new Vector3(0.5f, 0.5f, 0.5f)); + Update(); + Assert.That(RenderedTriangles(model), Is.GreaterThan(kLoneBox), "the two boxes draw their union"); + + Object.DestroyImmediate(step); + Update(); + Assert.That(RenderedTriangles(model), Is.EqualTo(kLoneBox)); + } + + // The editor doesn't set the children of a composite that has none left, so after this delete no brush that + // remains is flagged, and only the update itself can notice the removal + [Test] + public void DeletingTheOnlyBrushOfAComposite_RemovesIt() + { + var model = Model("composite deletion"); + Box("wall", model.transform, Vector3.zero); + var steps = Child("steps", model.transform, Vector3.zero); + steps.AddComponent(); + var step = Box("step", steps.transform, new Vector3(0.5f, 0.5f, 0.5f)); + Update(); + Assert.That(RenderedTriangles(model), Is.GreaterThan(kLoneBox), "the two boxes draw their union"); + + Object.DestroyImmediate(step); + Update(); + Assert.That(RenderedTriangles(model), Is.EqualTo(kLoneBox)); + } + + // A Chisel parent refreshes its children's sibling indices when they change; a plain GameObject doesn't, so + // there the tree order relies on the refresh the update does after an insert + [Test] + public void InsertingIntoAPlainGroup_KeepsTheSceneOrder() + { + var model = Model("plain group"); + var group = Child("group", model.transform, Vector3.zero); + var p = Box("p", group.transform, Vector3.zero); + Box("q", group.transform, new Vector3(3, 0, 0)); + Update(); + Assert.That(TreeOrder(model), Is.EqualTo(SceneOrder(group.transform))); + + var r = Box("r", group.transform, new Vector3(6, 0, 0)); + r.transform.SetSiblingIndex(1); + Update(); + Assert.That(TreeOrder(model), Is.EqualTo(SceneOrder(group.transform)), "a brush inserted in the middle"); + + // What Duplicate does: a copy right after the original + var p2 = Object.Instantiate(p, group.transform); + p2.name = "p2"; + p2.transform.SetSiblingIndex(1); + Update(); + Assert.That(TreeOrder(model), Is.EqualTo(SceneOrder(group.transform)), "a duplicated brush"); + } + + [Test] + public void ReorderingUnderAModel_KeepsTheSceneOrder() + { + var model = Model("reorder"); + Box("a", model.transform, Vector3.zero); + var b = Box("b", model.transform, new Vector3(3, 0, 0)); + Box("c", model.transform, new Vector3(6, 0, 0)); + Update(); + + b.transform.SetSiblingIndex(0); + Update(); + Assert.That(TreeOrder(model), Is.EqualTo(SceneOrder(model.transform))); + } + } +} diff --git a/Components/Tests/ChiselHierarchyEditTests.cs.meta b/Components/Tests/ChiselHierarchyEditTests.cs.meta new file mode 100644 index 00000000..ab91bd76 --- /dev/null +++ b/Components/Tests/ChiselHierarchyEditTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 18d195f40172fdd4f949503d5a317e7b \ No newline at end of file diff --git a/Components/Tests/ChiselHotspotMappingTests.cs b/Components/Tests/ChiselHotspotMappingTests.cs new file mode 100644 index 00000000..0c126d4f --- /dev/null +++ b/Components/Tests/ChiselHotspotMappingTests.cs @@ -0,0 +1,1380 @@ +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEditor; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + // Hotspot mapping through the whole pipeline: brushes whose material has a hotspot layout take their uvs from the + // tile that fits what they draw + [TestFixture] + public class ChiselHotspotMappingTests + { + const string kFolder = "Assets/__ChiselHotspotMappingTests"; + + readonly List roots = new(); + ChiselHotspotLayout layout; + Material material; + ChiselSurfaceMetadata metadata; + + static HotspotTile Rect(int id, string name, int x, int y, int w, int h, HotspotTileFlags flags = HotspotTileFlags.None) + { + return new HotspotTile { id = id, name = name, flags = flags, + points = new[] { new Vector2Int(x, y), new Vector2Int(x + w, y), new Vector2Int(x + w, y + h), new Vector2Int(x, y + h) } }; + } + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List + { + Rect(1, "A1", 0, 0, 512, 512), + Rect(2, "A2", 512, 0, 512, 512), + Rect(3, "B1", 1024, 0, 1024, 512), + Rect(4, "C1", 0, 512, 1024, 256), + Rect(5, "D1", 1024, 512, 256, 256), + Rect(6, "E1", 1280, 512, 256, 512), + Rect(7, "G1", 1536, 512, 128, 128), + new HotspotTile { id = 10, name = "Tr6", points = new[] { new Vector2Int(1792, 1024), new Vector2Int(1792, 1152), new Vector2Int(1536, 1152) } }, + Rect(11, "R1", 1536, 1536, 512, 512, HotspotTileFlags.Radial), + }; + + if (!AssetDatabase.IsValidFolder(kFolder)) + AssetDatabase.CreateFolder("Assets", kFolder.Substring("Assets/".Length)); + material = new Material(ChiselProjectSettings.DefaultWallMaterial); + AssetDatabase.CreateAsset(material, kFolder + "/hotspot.mat"); + metadata = material.AddMetadataOfType(); + metadata.hotspotLayout = layout; + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + AssetDatabase.DeleteAsset(kFolder); + if (layout) + Object.DestroyImmediate(layout); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselModelComponent Model() + { + var root = new GameObject("hotspot test"); + roots.Add(root); + return root.AddComponent(); + } + + T Add(ChiselModelComponent model, string name) where T : ChiselNodeComponent + { + var go = new GameObject(name); + go.transform.SetParent(model.transform, false); + return go.AddComponent(); + } + + // Every surface with the hotspot material; settings can change each surface's details + void UseHotspotMaterial(ChiselGeneratorComponent generator, System.Func settings = null) + { + Update(); + var surfaces = generator.SurfaceDefinition.surfaces; + Assert.That(surfaces, Is.Not.Null.And.Not.Empty, generator.name + " has no surfaces"); + foreach (var surface in surfaces) + { + surface.SetMaterial(material); + if (settings != null) + surface.surfaceDetails = settings(surface.surfaceDetails); + } + generator.OnValidate(); + Update(); + } + + ChiselBoxComponent Box(ChiselModelComponent model, float3 min, float3 max, System.Func settings = null) + { + var box = Add(model, "box"); + // a Min beyond the old Max would be put right first + if (math.any(min > box.Max)) { box.Max = max; box.Min = min; } + else { box.Min = min; box.Max = max; } + UseHotspotMaterial(box, settings); + return box; + } + + // The uv of the drawn vertex at a position on a face with this normal + static float2 UVAt(ChiselModelComponent model, float3 position, float3 normal) + { + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.sharedMesh == null) + continue; + var mesh = renderable.sharedMesh; + var vertices = mesh.vertices; var normals = mesh.normals; var uvs = mesh.uv; + for (int i = 0; i < vertices.Length; i++) + { + if (math.distance((float3)vertices[i], position) < 1e-4f && math.dot((float3)normals[i], normal) > 0.999f) + return uvs[i]; + } + } + Assert.Fail($"no vertex at {position} with normal {normal}"); + return default; + } + + // One of the uvs drawn at a point (where the border and the interior's repeats meet, each has its own) + static void AssertHasUV(float2 expected, List uvs, string what) + { + Assert.That(uvs.Exists(uv => math.distance(uv, expected) < 1e-5f), Is.True, $"{what}: expected {expected} among {string.Join(", ", uvs)}"); + } + + static void AreEqual(float2 expected, float2 actual, string what) + { + Assert.That(math.distance(expected, actual), Is.LessThan(1e-5f), $"{what}: expected {expected}, got {actual}"); + } + + static readonly float3 kFront = new float3(0, 0, -1); + + [Test] + public void AWall4x2_TakesThe2To1Tile_UpStaysUp() + { + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + // B1: pixels 1024..2048 x 0..512 from the top: u 0.5..1, v 0.75..1 + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "bottom left"); + AreEqual(new float2(1.0f, 0.75f), UVAt(model, new float3(2, 0, -0.125f), kFront), "bottom right"); + AreEqual(new float2(0.5f, 1.0f), UVAt(model, new float3(-2, 2, -0.125f), kFront), "top left"); + AreEqual(new float2(1.0f, 1.0f), UVAt(model, new float3(2, 2, -0.125f), kFront), "top right"); + } + + [Test] + public void AFloor_TakesTheTallTile_UpAlongPlusZ() + { + var model = Model(); + Box(model, new float3(-1, -0.25f, -2), new float3(1, 0, 2)); + // its top, 2 x 4, takes E1 (1:2): pixels 1280..1536 x 512..1024 from the top: u 0.625..0.75, v 0.5..0.75 + var up = new float3(0, 1, 0); + AreEqual(new float2(0.625f, 0.5f), UVAt(model, new float3(-1, 0, -2), up), "near left"); + AreEqual(new float2(0.75f, 0.5f), UVAt(model, new float3(1, 0, -2), up), "near right"); + AreEqual(new float2(0.625f, 0.75f), UVAt(model, new float3(-1, 0, 2), up), "far left"); + } + + [Test] + public void TheVisiblePart_Decides_AWallHalfSunkIntoABlock() + { + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 4, 0.125f)); + Box(model, new float3(-3, -1, -1), new float3(3, 2, 1)); + // the wall's face is 4 x 4, but only 4 x 2 of it shows: B1, not a square + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-2, 2, -0.125f), kFront), "bottom left of what shows"); + AreEqual(new float2(1.0f, 1.0f), UVAt(model, new float3(2, 4, -0.125f), kFront), "top right"); + } + + [Test] + public void ATriangularFace_TakesATriangleTile_CornerToCorner() + { + var model = Model(); + var brush = Add(model, "right triangle"); + static float4 Plane(float3 n, float3 p) { n = math.normalize(n); return new float4(n, -math.dot(n, p)); } + // a prism whose front is the right triangle (0,0) (1,0) (1,0.5), its right angle at the bottom right + brush.definition.SetPlanes(new[] { new float4(0, 0, -1, 0), new float4(0, 0, 1, -0.5f), + Plane(new float3(0, -1, 0), float3.zero), Plane(new float3(1, 0, 0), new float3(1, 0, 0)), + Plane(new float3(-0.5f, 1, 0), float3.zero) }); + brush.OnValidate(); + UseHotspotMaterial(brush); + // Tr6: (1792, 1024) top right, (1792, 1152) bottom right - the right angle -, (1536, 1152) bottom left, from the top + AreEqual(new float2(1536f / 2048, 896f / 2048), UVAt(model, new float3(0, 0, 0), kFront), "the sharp corner"); + AreEqual(new float2(1792f / 2048, 896f / 2048), UVAt(model, new float3(1, 0, 0), kFront), "the right angle"); + AreEqual(new float2(1792f / 2048, 1024f / 2048), UVAt(model, new float3(1, 0.5f, 0), kFront), "the top"); + } + + [Test] + public void IdenticalWalls_TakeBothIdenticalTiles_ByTheirSeeds() + { + var model = Model(); + for (uint i = 0; i < 8; i++) + { + var x = i * 3.0f; + var seed = i + 1; + Box(model, new float3(x, 0, -0.125f), new float3(x + 2, 2, 0.125f), d => { d.hotspot.seed = seed; return d; }); + } + var lefts = new HashSet(); + var seen = ""; + var wrong = false; + for (uint i = 0; i < 8; i++) + { + var low = UVAt(model, new float3(i * 3.0f, 0, -0.125f), kFront); + var high = UVAt(model, new float3(i * 3.0f + 2, 2, -0.125f), kFront); + seen += $" wall {i}: {low} .. {high};"; + wrong |= !(math.abs(low.x) < 1e-5f || math.abs(low.x - 0.25f) < 1e-5f) || math.abs(high.y - 1) > 1e-5f; + lefts.Add(math.round(low.x * 4)); + } + Assert.That(wrong, Is.False, "a wall is not on A1 or A2:" + seen); + Assert.That(lefts.Count, Is.EqualTo(2), "eight seeds took only one of the two identical tiles:" + seen); + } + + [Test] + public void APinnedTile_IsKept() + { + var model = Model(); + Box(model, new float3(-1, 0, -0.125f), new float3(1, 2, 0.125f), d => { d.hotspot.pinnedTile = 4; return d; }); + // C1: pixels 0..1024 x 512..768 from the top: u 0..0.5, v 0.625..0.75 + AreEqual(new float2(0.0f, 0.625f), UVAt(model, new float3(-1, 0, -0.125f), kFront), "bottom left"); + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(1, 2, -0.125f), kFront), "top right"); + } + + [Test] + public void AResizedWall_IsMatchedAgain() + { + var model = Model(); + var box = Box(model, new float3(-1, 0, -0.125f), new float3(1, 2, 0.125f), d => { d.hotspot.seed = 1; return d; }); + var square = UVAt(model, new float3(-1, 0, -0.125f), kFront); + Assert.That(square.y, Is.EqualTo(0.75f).Within(1e-5f), "a 2 x 2 wall takes A1 or A2"); + box.Max = new float3(3, 2, 0.125f); + Update(); + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-1, 0, -0.125f), kFront), "4 x 2 now: B1"); + AreEqual(new float2(1.0f, 1.0f), UVAt(model, new float3(3, 2, -0.125f), kFront), "4 x 2 now: B1"); + } + + [Test] + public void AWallOnABorderedTile_KeepsTheBorderAtItsPixelWidth() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + Box(model, new float3(-3, 0, -0.125f), new float3(3, 2, 0.125f)); + // 6 x 2 takes B1 (1024..2048 x 0..512 from the top: u 0.5..1, v 0.75..1); its border is 32 pixels, an eighth of a unit + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-3, 0, -0.125f), kFront), "bottom left"); + AreEqual(new float2((1024 + 32) / 2048f, (1536 + 32) / 2048f), UVAt(model, new float3(-3 + 0.125f, 0.125f, -0.125f), kFront), "the inner corner at the bottom left"); + // the interior repeats at the tile's density: 960 pixels (3.75 units) a repeat, the second one cut off 2 units in + AssertHasUV(new float2((1056 + 512) / 2048f, (2048 - 32) / 2048f), UVsAt(model, new float3(3 - 0.125f, 2 - 0.125f, -0.125f), kFront), "the interior at the inner corner at the top right"); + AreEqual(new float2(1, 1), UVAt(model, new float3(3, 2, -0.125f), kFront), "top right"); + // where the first repeat ends on the bottom border, both repeats + var seam = UVsAt(model, new float3(-3 + 0.125f + 3.75f, 0.125f, -0.125f), kFront); + Assert.That(seam.Count, Is.GreaterThanOrEqualTo(2), "a vertex on each side of the seam"); + Assert.That(seam.Exists(uv => math.abs(uv.x - (2048 - 32) / 2048f) < 1e-5f) && seam.Exists(uv => math.abs(uv.x - (1024 + 32) / 2048f) < 1e-5f), Is.True, + $"the first repeat's end and the second's start: {string.Join(", ", seam)}"); + } + + void BorderOnB1(int border) + { + var b1 = layout.tiles[2]; + b1.border = border; + layout.tiles[2] = b1; + } + + // A brush placed (moved, turned, scaled) as the editor tells Chisel of it + static void Place(ChiselNodeComponent node, Vector3 position, Quaternion rotation, Vector3 scale) + { + node.transform.localPosition = position; + node.transform.localRotation = rotation; + node.transform.localScale = scale; + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(node); + Update(); + } + + // Where the drawn vertices along a line of the front are, for a failure's message + static string XsAt(ChiselModelComponent model, float y) + { + var xs = new List(); + foreach (var (position, _) in Drawn(model, kFront)) + { + if (math.abs(position.y - y) < 1e-4f && !xs.Exists(x => math.abs(x - position.x) < 1e-4f)) + xs.Add(position.x); + } + xs.Sort(); + var ys = new List(); + foreach (var (position, _) in Drawn(model, kFront)) + { + if (!ys.Exists(other => math.abs(other - position.y) < 1e-4f)) + ys.Add(position.y); + } + ys.Sort(); + return "x " + string.Join(" ", xs) + " (front vertices' y: " + string.Join(" ", ys) + ")"; + } + + static SurfaceDetails InWorld(SurfaceDetails details) + { + details.detailFlags |= SurfaceDetailFlags.TextureIsInWorldSpace; + return details; + } + + // Every drawn vertex facing this way: its position and uv + static List<(float3 position, float2 uv)> Drawn(ChiselModelComponent model, float3 normal) + { + var drawn = new List<(float3, float2)>(); + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.sharedMesh == null) + continue; + var mesh = renderable.sharedMesh; + var vertices = mesh.vertices; var normals = mesh.normals; var uvs = mesh.uv; + for (int i = 0; i < vertices.Length; i++) + { + if (math.dot((float3)normals[i], normal) > 0.999f) + drawn.Add((vertices[i], uvs[i])); + } + } + return drawn; + } + + // A generator's surfaces, each polygon of its brush + static List SurfacesOf(ChiselGeneratorComponent generator) + { + var surfaces = new List(); + var brush = (CSGTreeBrush)generator.TopTreeNode; + var blob = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh.BrushMeshID); + for (int i = 0; i < blob.Value.polygons.Length; i++) + surfaces.Add(new SurfaceReference(generator, blob.Value.polygons[i].descriptionIndex, brush, i)); + return surfaces; + } + + // Lock texture to object toggled on every surface, each surface definition once, as the editor does + static void ToggleLock(ChiselGeneratorComponent generator) + { + var done = new HashSet(); + foreach (var surface in SurfacesOf(generator)) + { + if (!done.Add(surface.descriptionIndex)) + continue; + surface.SurfaceDetails = ChiselTextureLock.Toggled(surface, surface.SurfaceDetails); + } + generator.OnValidate(); + Update(); + } + + // Every vertex drawn before is drawn with the same uv + static void AssertSameLook(List<(float3 position, float2 uv)> before, ChiselModelComponent model, float3 normal, string what) + { + Assert.That(before.Count, Is.GreaterThan(0), what + ": nothing drawn"); + foreach (var (position, uv) in before) + { + var now = UVsAt(model, position, normal); + Assert.That(now.Exists(other => math.distance(other, uv) < 1e-4f), Is.True, $"{what}: at {position} {uv} became {string.Join(", ", now)}"); + } + } + + [Test] + public void ALockedWall_ScaledHalf_LooksAsItDoesUnscaled() + { + BorderOnB1(32); + var model = Model(); + var box = Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + Place(box, Vector3.zero, Quaternion.identity, new Vector3(0.5f, 0.5f, 0.5f)); + // the texture scales with the brush: its 32 pixel border is an eighth of the brush's own unit, a sixteenth here + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-1, 0, -0.0625f), kFront), "bottom left"); + AreEqual(new float2((1024 + 32) / 2048f, (1536 + 32) / 2048f), UVAt(model, new float3(-1 + 0.0625f, 0.0625f, -0.0625f), kFront), "the inner corner"); + AreEqual(new float2(1, 1), UVAt(model, new float3(1, 1, -0.0625f), kFront), "top right"); + } + + [Test] + public void AnUnlockedWall_KeepsItsInteriorRepeatsInTheWorld_ALockedOneMovesThem() + { + BorderOnB1(32); + // a 6 x 2 wall takes B1: its interior repeats every 3.75 units (960 pixels); in the world on the world's grid, + // a seam at x = 0 wherever the wall is + var world = Model(); + var unlocked = Box(world, new float3(-3, 0, -0.125f), new float3(3, 2, 0.125f), InWorld); + Assert.That(UVsAt(world, new float3(0, 0.125f, -0.125f), kFront).Count, Is.GreaterThanOrEqualTo(2), "a seam at 0: " + XsAt(world, 0.125f)); + Place(unlocked, new Vector3(1, 0, 0), Quaternion.identity, Vector3.one); + Assert.That(UVsAt(world, new float3(0, 0.125f, -0.125f), kFront).Count, Is.GreaterThanOrEqualTo(2), "still at 0 when the wall moved: " + XsAt(world, 0.125f)); + + // locked, from the wall's inner corner: -3 + 0.125 + 3.75, and along with it + var own = Model(); + var locked = Box(own, new float3(-3, 0, -0.125f), new float3(3, 2, 0.125f)); + Assert.That(UVsAt(own, new float3(0.875f, 0.125f, -0.125f), kFront).Count, Is.GreaterThanOrEqualTo(2), "a seam at 0.875: " + XsAt(own, 0.125f)); + Place(locked, new Vector3(1, 0, 0), Quaternion.identity, Vector3.one); + Assert.That(UVsAt(own, new float3(1.875f, 0.125f, -0.125f), kFront).Count, Is.GreaterThanOrEqualTo(2), "moved along to 1.875: " + XsAt(own, 0.125f)); + } + + ChiselBoxComponent PlainBox(ChiselModelComponent model, float x, bool inWorld) + { + var box = Add(model, "plain"); + box.Max = new float3(2, 2, 0.125f); + box.Min = new float3(-2, 0, -0.125f); + Update(); + foreach (var surface in box.SurfaceDefinition.surfaces) + { + var details = surface.surfaceDetails; + details.UV0 = UVMatrix.TRS(new Vector2(0.25f, 0.5f), Vector3.forward, 30, new float2(0.5f, 2)); + surface.surfaceDetails = inWorld ? InWorld(details) : details; + } + box.OnValidate(); + Update(); + Place(box, new Vector3(x, 0, 0), Quaternion.identity, Vector3.one); + return box; + } + + [Test] + public void AnUnlockedPlainWall_TakesItsUVsFromTheWorld_ALockedOneFromItself() + { + // two walls side by side, the right one's left edge on the left one's right edge (at x = 2) + var a = Model(); PlainBox(a, 0, true); + var b = Model(); PlainBox(b, 4, true); + AreEqual(UVAt(a, new float3(2, 0, -0.125f), kFront), UVAt(b, new float3(2, 0, -0.125f), kFront), "in the world, the same point the same uv"); + var c = Model(); PlainBox(c, 0, false); + var d = Model(); PlainBox(d, 4, false); + AreEqual(UVAt(c, new float3(-2, 0, -0.125f), kFront), UVAt(d, new float3(2, 0, -0.125f), kFront), "locked, the same corner of each the same uv"); + } + + [Test] + public void TogglingTheLock_ChangesNothingOnScreen() + { + BorderOnB1(32); + var model = Model(); + // turned, scaled and moved, its interior repeating + var hotspot = Box(model, new float3(-2, 0, -0.125f), new float3(6, 2, 0.125f), d => { d.hotspot.rotation = 90; return d; }); + Place(hotspot, new Vector3(0.3f, 0.2f, 0), Quaternion.Euler(0, 0, 20), new Vector3(0.75f, 0.75f, 0.75f)); + var front = math.normalize((float3)(hotspot.transform.localRotation * Vector3.back)); + var before = Drawn(model, front); + ToggleLock(hotspot); + Assert.That(ChiselTextureLock.IsInWorldSpace(hotspot.SurfaceDefinition.surfaces[0].surfaceDetails), Is.True); + AssertSameLook(before, model, front, "hotspot, to the world"); + ToggleLock(hotspot); + AssertSameLook(before, model, front, "hotspot, back to the object"); + + // a plain material's uv matrix + var plainModel = Model(); + var plain = PlainBox(plainModel, 0.3f, false); + Place(plain, new Vector3(0.3f, 0.2f, 0), Quaternion.Euler(0, 0, 20), Vector3.one); + var plainFront = math.normalize((float3)(plain.transform.localRotation * Vector3.back)); + var plainBefore = Drawn(plainModel, plainFront); + ToggleLock(plain); + AssertSameLook(plainBefore, plainModel, plainFront, "plain, to the world"); + } + + // Every uv drawn at a position on a face with this normal + static List UVsAt(ChiselModelComponent model, float3 position, float3 normal) + { + var found = new List(); + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.sharedMesh == null) + continue; + var mesh = renderable.sharedMesh; + var vertices = mesh.vertices; var normals = mesh.normals; var uvs = mesh.uv; + for (int i = 0; i < vertices.Length; i++) + { + if (math.distance((float3)vertices[i], position) < 1e-4f && math.dot((float3)normals[i], normal) > 0.999f) + found.Add(uvs[i]); + } + } + return found; + } + + [Test] + public void ABaseboard_RepeatsAStripTile_WithASeamBetweenRepeats() + { + // 2048 x 128 pixels, repeating across: 8 x 0.5 units + layout.tiles.Add(Rect(11, "T1", 0, 1536, 2048, 128, HotspotTileFlags.RepeatU)); + var model = Model(); + Box(model, new float3(0, 0, -0.125f), new float3(40, 0.5f, 0.125f)); + // T1 is pixels 1536..1664 from the top: v 0.1875 .. 0.25 + AreEqual(new float2(0, 0.1875f), UVAt(model, new float3(0, 0, -0.125f), kFront), "the start"); + AreEqual(new float2(1, 0.25f), UVAt(model, new float3(40, 0.5f, -0.125f), kFront), "the end of the fifth repeat"); + var seam = UVsAt(model, new float3(8, 0, -0.125f), kFront); + Assert.That(seam.Count, Is.EqualTo(2), "the seam between the first two repeats: a vertex for each side"); + Assert.That(seam.Exists(uv => math.distance(uv, new float2(1, 0.1875f)) < 1e-5f), Is.True, $"the end of the first: {string.Join(", ", seam)}"); + Assert.That(seam.Exists(uv => math.distance(uv, new float2(0, 0.1875f)) < 1e-5f), Is.True, $"the start of the second: {string.Join(", ", seam)}"); + } + + [Test] + public void ABaseboardMovedAlong_StartsPartWayAlongItsTile() + { + layout.tiles.Add(Rect(11, "T1", 0, 1536, 2048, 128, HotspotTileFlags.RepeatU)); + var model = Model(); + Box(model, new float3(0, 0, -0.125f), new float3(40, 0.5f, 0.125f), d => { d.hotspot.offset = new float2(0.5f, 0); return d; }); + // T1 is pixels 1536..1664 from the top: v 0.1875 .. 0.25; half a repeat along, the seams are 4 units in + AreEqual(new float2(0.5f, 0.1875f), UVAt(model, new float3(0, 0, -0.125f), kFront), "the start"); + var seam = UVsAt(model, new float3(4, 0, -0.125f), kFront); + Assert.That(seam.Exists(uv => math.distance(uv, new float2(1, 0.1875f)) < 1e-5f), Is.True, $"the end of the first: {string.Join(", ", seam)}"); + Assert.That(seam.Exists(uv => math.distance(uv, new float2(0, 0.1875f)) < 1e-5f), Is.True, $"the start of the second: {string.Join(", ", seam)}"); + } + + static int ColliderTriangles(ChiselModelComponent model) + { + var count = 0; + foreach (var collider in model.generated.colliders) + { + if (collider != null && collider.sharedMesh != null) + count += collider.sharedMesh.triangles.Length / 3; + } + return count; + } + + static int DrawnTriangles(ChiselModelComponent model) + { + var count = 0; + foreach (var renderable in model.generated.renderables) + { + if (renderable != null && renderable.sharedMesh != null) + count += renderable.sharedMesh.triangles.Length / 3; + } + return count; + } + + [Test] + public void ABorderedWall_IsCutForDrawing_ItsColliderIsNot() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + Box(model, new float3(-3, 0, -0.125f), new float3(3, 2, 0.125f)); + // a box: 6 faces of 2 triangles + Assert.That(ColliderTriangles(model), Is.EqualTo(12), "the collider is cut"); + Assert.That(DrawnTriangles(model), Is.GreaterThan(12), "what is drawn is not cut"); + // cut once along the border: each face's sections are two triangles each, or fewer + Assert.That(DrawnTriangles(model), Is.LessThanOrEqualTo(6 * 18), "cut more than the border needs"); + AreEqual(new float2((1024 + 32) / 2048f, (1536 + 32) / 2048f), UVAt(model, new float3(-3 + 0.125f, 0.125f, -0.125f), kFront), "the inner corner"); + } + + [Test] + public void ADoorwayCutByTheCSG_HasABorderAlongItsEdges() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + Box(model, new float3(-3, 0, -0.125f), new float3(3, 2, 0.125f)); + var door = Box(model, new float3(-0.5f, -0.1f, -1), new float3(0.5f, 1.5f, 1)); + door.Operation = CSGOperationType.Subtractive; + Update(); + // 6 x 2 takes B1; where the doorway's left edge meets the floor, 0.125 to its left: the tile's bottom right + // corner piece, the border's whole depth in from the right + var uvs = UVsAt(model, new float3(-0.5f - 0.125f, 0, -0.125f), kFront); + Assert.That(uvs.Exists(uv => math.distance(uv, new float2((2048 - 32) / 2048f, 0.75f)) < 1e-5f), Is.True, + $"no corner piece of the doorway's border there: {string.Join(", ", uvs)}"); + Assert.That(ColliderTriangles(model), Is.LessThan(DrawnTriangles(model)), "only what is drawn is cut"); + } + + [Test] + public void ADoorwaysInsideCorners_TakeTheLinkedPieces() + { + var b1 = layout.tiles[2]; + b1.border = 32; + b1.insideCorners = 20; + layout.tiles[2] = b1; + // four pieces, 32 pixels each, at the sheet's left (pixels 0..64 x 1024..1088 from the top): their middle at + // u 32 / 2048, v 992 / 2048 + layout.tiles.Add(Rect(20, "IC1", 0, 1024, 64, 64, HotspotTileFlags.InsideCorners)); + var model = Model(); + Box(model, new float3(-3, 0, -0.125f), new float3(3, 2, 0.125f)); + var door = Box(model, new float3(-0.5f, -0.1f, -1), new float3(0.5f, 1.5f, 1)); + door.Operation = CSGOperationType.Subtractive; + Update(); + var middle = new float2(32, 992) / 2048f; + // the doorway's top corners: each one's square lies above it, to the left and to the right + AssertHasUV(middle, UVsAt(model, new float3(-0.5f, 1.5f, -0.125f), kFront), "the left corner, the pieces' middle"); + AssertHasUV(new float2(0, 1024) / 2048f, UVsAt(model, new float3(-0.625f, 1.625f, -0.125f), kFront), "the top left piece's far corner"); + AssertHasUV(middle, UVsAt(model, new float3(0.5f, 1.5f, -0.125f), kFront), "the right corner, the pieces' middle"); + AssertHasUV(new float2(64, 1024) / 2048f, UVsAt(model, new float3(0.625f, 1.625f, -0.125f), kFront), "the top right piece's far corner"); + } + + [Test] + public void ATrapezoid_TakesItsTileAsItsBoundsDo() + { + var model = Model(); + var brush = Add(model, "trapezoid"); + static float4 Plane(float3 n, float3 p) { n = math.normalize(n); return new float4(n, -math.dot(n, p)); } + // a prism whose front is the trapezoid (0, 0) (4, 0) (3, 2) (1, 2) + brush.definition.SetPlanes(new[] { new float4(0, 0, -1, 0), new float4(0, 0, 1, -0.5f), + Plane(new float3(0, -1, 0), float3.zero), Plane(new float3(0, 1, 0), new float3(0, 2, 0)), + Plane(new float3(-2, 1, 0), float3.zero), Plane(new float3(2, 1, 0), new float3(4, 0, 0)) }); + brush.OnValidate(); + UseHotspotMaterial(brush); + // its bounds are 4 x 2: B1, u 0.5..1, v 0.75..1, undistorted; its slanted sides cut the tile's top corners off + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(0, 0, 0), kFront), "bottom left"); + AreEqual(new float2(1.0f, 0.75f), UVAt(model, new float3(4, 0, 0), kFront), "bottom right"); + AreEqual(new float2(0.875f, 1.0f), UVAt(model, new float3(3, 2, 0), kFront), "top right"); + AreEqual(new float2(0.625f, 1.0f), UVAt(model, new float3(1, 2, 0), kFront), "top left"); + } + + [Test] + public void AWallTurnedAQuarter_TakesTheTallTile_TurnedWithIt() + { + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f), d => { d.hotspot.rotation = 90; return d; }); + // 4 x 2 turned a quarter is 2 x 4: E1, pixels 1280..1536 x 512..1024 from the top: u 0.625..0.75, v 0.5..0.75; + // the texture's bottom left is on the wall's bottom right + AreEqual(new float2(0.625f, 0.5f), UVAt(model, new float3(2, 0, -0.125f), kFront), "bottom right"); + AreEqual(new float2(0.625f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "bottom left"); + AreEqual(new float2(0.75f, 0.5f), UVAt(model, new float3(2, 2, -0.125f), kFront), "top right"); + } + + [Test] + public void AWallOnATurnedBrush_KeepsItsTile_TurnedWithIt() + { + var model = Model(); + var box = Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + box.transform.localRotation = Quaternion.Euler(0, 0, 90); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(box); + Update(); + // still B1 (u 0.5..1, v 0.75..1), its bottom now along the wall's left edge: (x, y) went to (-y, x) + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(0, -2, -0.125f), kFront), "bottom left"); + AreEqual(new float2(1.0f, 0.75f), UVAt(model, new float3(0, 2, -0.125f), kFront), "bottom right"); + AreEqual(new float2(0.5f, 1.0f), UVAt(model, new float3(-2, -2, -0.125f), kFront), "top left"); + } + + [Test] + public void AFloorOnATurnedBrush_KeepsItsTile_TurnedWithIt() + { + var model = Model(); + var box = Box(model, new float3(-1, -0.25f, -2), new float3(1, 0, 2)); + box.transform.localRotation = Quaternion.Euler(0, 90, 0); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(box); + Update(); + // still E1 (u 0.625..0.75, v 0.5..0.75): (x, y, z) went to (z, y, -x) + var up = new float3(0, 1, 0); + AreEqual(new float2(0.625f, 0.5f), UVAt(model, new float3(-2, 0, 1), up), "near left"); + AreEqual(new float2(0.75f, 0.5f), UVAt(model, new float3(-2, 0, -1), up), "near right"); + AreEqual(new float2(0.625f, 0.75f), UVAt(model, new float3(2, 0, 1), up), "far left"); + } + + [Test] + public void AWallOnAMovedBrush_KeepsItsUVs() + { + var model = Model(); + var box = Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + box.transform.localPosition = new Vector3(0.3f, 1.7f, 0); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(box); + Update(); + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-1.7f, 1.7f, -0.125f), kFront), "bottom left"); + AreEqual(new float2(1.0f, 1.0f), UVAt(model, new float3(2.3f, 3.7f, -0.125f), kFront), "top right"); + } + + [TestCase(typeof(ChiselSphereComponent))] + [TestCase(typeof(ChiselCapsuleComponent))] + public void ThePoleOfARoundGenerator_TakesOneRadialTile(System.Type type) + { + var model = Model(); + var go = new GameObject("round"); + go.transform.SetParent(model.transform, false); + var generator = (ChiselGeneratorComponent)go.AddComponent(type); + UseHotspotMaterial(generator); + // the faces round the top pole: every vertex near the top drawn from the radial tile R1, as one circle + var top = float.NegativeInfinity; + foreach (var renderable in model.generated.renderables) + if (renderable != null && renderable.sharedMesh != null) + foreach (var v in renderable.sharedMesh.vertices) top = math.max(top, v.y); + var count = 0; + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.sharedMesh == null) + continue; + var mesh = renderable.sharedMesh; + var vertices = mesh.vertices; var normals = mesh.normals; var uvs = mesh.uv; + for (int i = 0; i < vertices.Length; i++) + { + if (normals[i].y < 0.9f || vertices[i].y < top - 1e-4f) + continue; + // the pole: the middle of R1's circle + Assert.That(math.distance(new float2(0.875f, 0.125f), uvs[i]), Is.LessThan(1e-4f), $"the pole: {uvs[i]}"); + count++; + } + } + Assert.That(count, Is.GreaterThanOrEqualTo(HotspotMatcher.kRadialCorners), "a vertex at the pole for each face round it"); + } + + [Test] + public void TwoCoplanarBoxFaces_TakeOneTileAcrossBoth() + { + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(0, 2, 0.125f)); + Box(model, new float3(0, 0, -0.125f), new float3(2, 2, 0.125f)); + // each 2 x 2 front alone would take a square tile; together they are 4 x 2: B1, u 0.5..1, v 0.75..1 + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "bottom left"); + AreEqual(new float2(1.0f, 1.0f), UVAt(model, new float3(2, 2, -0.125f), kFront), "top right"); + foreach (var uv in UVsAt(model, new float3(0, 0, -0.125f), kFront)) + AreEqual(new float2(0.75f, 0.75f), uv, "where they meet, both"); + foreach (var uv in UVsAt(model, new float3(0, 2, -0.125f), kFront)) + AreEqual(new float2(0.75f, 1.0f), uv, "where they meet at the top, both"); + } + + [Test] + public void TwoCoplanarBoxFaces_HaveNoBorderWhereTheyMeet() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + Box(model, new float3(-3, 0, -0.125f), new float3(0, 2, 0.125f)); + Box(model, new float3(0, 0, -0.125f), new float3(3, 2, 0.125f)); + // as one 6 x 2 wall on B1 with its 32 pixel border: the border along the outside only + AreEqual(new float2((1024 + 32) / 2048f, (1536 + 32) / 2048f), UVAt(model, new float3(-3 + 0.125f, 0.125f, -0.125f), kFront), "the inner corner at the bottom left"); + AssertHasUV(new float2((1056 + 512) / 2048f, (2048 - 32) / 2048f), UVsAt(model, new float3(3 - 0.125f, 2 - 0.125f, -0.125f), kFront), "the interior at the inner corner at the top right"); + // where they meet on the bottom border's inner edge: the border's strip as along one wall (the tile's middle), + // the interior 2.875 units into its first repeat, the same for both boxes + var interior = new float2((1056 + 736) / 2048f, (1536 + 32) / 2048f); + var border = new float2(0.75f, (1536 + 32) / 2048f); + var meet = UVsAt(model, new float3(0, 0.125f, -0.125f), kFront); + AssertHasUV(interior, meet, "where they meet, the interior"); + AssertHasUV(border, meet, "where they meet, the border"); + foreach (var uv in meet) + Assert.That(math.distance(uv, interior) < 1e-5f || math.distance(uv, border) < 1e-5f, Is.True, $"where they meet: {uv} is neither"); + } + + [Test] + public void CoplanarFacesWithDifferentFrames_AreNotConnected() + { + var model = Model(); + // in one 32 m chunk, so the left one's corner is found first where they meet + Box(model, new float3(2, 0, -0.125f), new float3(4, 2, 0.125f)); + Box(model, new float3(4, 0, -0.125f), new float3(6, 2, 0.125f), d => { d.hotspot.rotation = 90; return d; }); + // the left one alone: a square tile from (2, 0) to (4, 2) + var bottomLeft = UVAt(model, new float3(2, 0, -0.125f), kFront); + var topRight = UVAt(model, new float3(4, 2, -0.125f), kFront); + Assert.That(math.abs((topRight - bottomLeft).x * 2048 - (topRight - bottomLeft).y * 2048), Is.LessThan(1e-2f), $"square: {bottomLeft} .. {topRight}"); + } + + // The face of a generator's first brush toward -z (its corners' middle the furthest toward -z) + static SurfaceReference FrontOf(ChiselGeneratorComponent generator) + { + var brush = (CSGTreeBrush)generator.TopTreeNode; + var blob = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh); + ref var mesh = ref blob.Value; + var best = -1; var bestZ = float.PositiveInfinity; + for (int p = 0; p < mesh.polygons.Length; p++) + { + var z = 0f; + for (int e = 0; e < mesh.polygons[p].edgeCount; e++) + z += mesh.localVertices[mesh.halfEdges[mesh.polygons[p].firstEdge + e].vertexIndex].z; + z /= mesh.polygons[p].edgeCount; + if (z < bestZ) { bestZ = z; best = p; } + } + return new SurfaceReference(generator, mesh.polygons[best].descriptionIndex, brush, best); + } + + [Test] + public void TwoCoplanarBoxFacesInDifferentFrames_ConnectedByHand_TakeOneTile() + { + var model = Model(); + var left = Box(model, new float3(-2, 0, -0.125f), new float3(0, 2, 0.125f)); + var right = Box(model, new float3(0, 0, -0.125f), new float3(2, 2, 0.125f), d => { d.hotspot.rotation = 90; return d; }); + Assert.That(ChiselHotspotLinks.Connect(FrontOf(left), FrontOf(right)), Is.True, "connected"); + Assert.That(ChiselHotspotLinks.StateOf(FrontOf(left), FrontOf(right)), Is.EqualTo(ChiselHotspotLinkState.Connected)); + // as the diagnostic view finds it + var links = ChiselHotspotLinks.LinksOf(model); + Assert.That(links.Count, Is.EqualTo(1), "one link"); + Assert.That(ChiselHotspotLinks.Resolve(links[0], out var brushA, out var brushB), Is.True, "its brushes"); + Assert.That(brushA == (CSGTreeBrush)left.TopTreeNode && brushB == (CSGTreeBrush)right.TopTreeNode, Is.True, "the boxes' brushes"); + Update(); + // 4 x 2 together on the first one's frame: B1, u 0.5..1, v 0.75..1 + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "bottom left"); + AreEqual(new float2(1.0f, 1.0f), UVAt(model, new float3(2, 2, -0.125f), kFront), "top right"); + } + + // Which render buffer the last update left the brush (a rebuilt brush gets a new one), by where it is + static int RenderBufferOf(ChiselModelComponent model, ChiselGeneratorComponent generator) + { + var data = ChiselTreeLookup.Value[model.Node]; + data.lastJobHandle.Complete(); + var brush = (CSGTreeBrush)generator.TopTreeNode; + Assert.That(data.brushRenderBufferLookup.TryGetValue(CompactHierarchyManager.GetCompactNodeID(brush), out var buffer) && buffer.IsCreated, Is.True, generator.name + " has no render buffer"); + return buffer.GetHashCode(); + } + + [Test] + public void AnEditedLayout_RebuildsOnlyTheBrushesDrawnOnIt() + { + var model = Model(); + var mapped = Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + var plain = Add(model, "plain"); + // Max first: a Min beyond the old Max would be put right + plain.Max = new float3(8, 2, 0.125f); plain.Min = new float3(6, 0, -0.125f); + Update(); + var mappedBefore = RenderBufferOf(model, mapped); + var plainBefore = RenderBufferOf(model, plain); + // nothing edited: nothing rebuilt + layout.NotifyChanged(); + Update(); + Assert.That(RenderBufferOf(model, mapped) == mappedBefore, Is.True, "an unchanged layout rebuilds nothing"); + Assert.That(RenderBufferOf(model, plain) == plainBefore, Is.True, "an unchanged layout rebuilds nothing, the plain brush"); + BorderOnB1(32); + layout.NotifyChanged(); + Update(); + Assert.That(RenderBufferOf(model, mapped) == mappedBefore, Is.False, "the brush on the layout is mapped again"); + Assert.That(RenderBufferOf(model, plain) == plainBefore, Is.True, "the brush without it is kept"); + } + + // The hotspot group each of a brush's surfaces was mapped with (0: by itself) + static int[] GroupsOf(ChiselModelComponent model, ChiselGeneratorComponent generator) + { + var data = ChiselTreeLookup.Value[model.Node]; + data.lastJobHandle.Complete(); + Assert.That(data.brushRenderBufferLookup.TryGetValue(CompactHierarchyManager.GetCompactNodeID((CSGTreeBrush)generator.TopTreeNode), out var buffer) && buffer.IsCreated, Is.True); + ref var surfaces = ref buffer.Value.surfaces; + var groups = new int[surfaces.Length]; + for (int s = 0; s < surfaces.Length; s++) + groups[s] = surfaces[s].hotspot.group; + return groups; + } + + [Test] + public void ASliver_IsMappedWithItsNeighbour() + { + // a sheet 2 mm thick: its four sides are narrower than a texel (256 a unit), its front and back are not (and + // its sides are wider than the weld collapses, OutputModelWeld.kCollapseLength) + var model = Model(); + var sheet = Box(model, new float3(-1, 0, -0.001f), new float3(1, 2, 0.001f)); + var groups = GroupsOf(model, sheet); + Assert.That(groups.Length, Is.EqualTo(6)); + // the sides tie (each shares a 2 unit edge with the front and the back) and go with the first of the two; the + // other stays by itself: a sliver never joins two surfaces that show a tile + var alone = 0; var with = new HashSet(); + foreach (var group in groups) + { + if (group == 0) alone++; + else with.Add(group); + } + Assert.That(alone, Is.EqualTo(1), "front or back by itself: " + string.Join(", ", groups)); + Assert.That(with.Count, Is.EqualTo(1), "the sides with the other: " + string.Join(", ", groups)); + + // a step edge 2 cm wide (5 texels) is no sliver: mapped by itself + var model2 = Model(); + var slab = Box(model2, new float3(-1, 0, -0.01f), new float3(1, 2, 0.01f)); + foreach (var group in GroupsOf(model2, slab)) + Assert.That(group, Is.Zero, "a 2 cm side is mapped by itself"); + } + + // The materials the model draws its triangles facing this way with, and the uvs of their corners + static (HashSet materials, List uvs) Facing(ChiselModelComponent model, Vector3 normal) + { + var materials = new HashSet(); var uvs = new List(); + foreach (var r in model.generated.renderables) + { + if (r == null || !r.Valid || r.sharedMesh == null || r.sharedMesh.vertexCount == 0 || r.renderMaterials == null) + continue; + var vertices = r.sharedMesh.vertices; var uv = r.sharedMesh.uv; + for (int s = 0; s < r.sharedMesh.subMeshCount && s < r.renderMaterials.Length; s++) + { + var triangles = r.sharedMesh.GetTriangles(s); + for (int t = 0; t < triangles.Length; t += 3) + { + var n = Vector3.Cross(vertices[triangles[t + 1]] - vertices[triangles[t]], vertices[triangles[t + 2]] - vertices[triangles[t]]).normalized; + if (Vector3.Dot(n, normal) < 0.99f) + continue; + materials.Add(r.renderMaterials[s]); + for (int k = 0; k < 3; k++) uvs.Add(uv[triangles[t + k]]); + } + } + } + return (materials, uvs); + } + + [Test] + public void AnotherSheet_GivesItsTilesAndItsMaterial() + { + // a trim sheet: one strip 8 pixels high along the top of a 2048 texture, repeating at its exact size, and turning + var trimLayout = ScriptableObject.CreateInstance(); + trimLayout.size = new Vector2Int(2048, 2048); + trimLayout.pixelsPerUnit = 256; + trimLayout.tiles = new List { Rect(1, "trim", 0, 0, 2048, 8, HotspotTileFlags.RepeatU | HotspotTileFlags.PartialRepeats | HotspotTileFlags.AllowRotation) }; + var trim = new Material(ChiselProjectSettings.DefaultWallMaterial); + AssetDatabase.CreateAsset(trim, kFolder + "/trim.mat"); + trim.AddMetadataOfType().hotspotLayout = trimLayout; + metadata.hotspotSheets = new List { trim }; + try + { + // a slab 2 cm thick: its edges take the trim (the trim's material), its front a tile of the sheet's own + var model = Model(); + Box(model, new float3(-0.5f, 0, -0.01f), new float3(0.5f, 1, 0.01f)); + var top = Facing(model, Vector3.up); + Assert.That(top.materials, Is.EquivalentTo(new[] { trim }), "the top edge's material"); + foreach (var uv in top.uvs) + Assert.That(uv.y, Is.GreaterThanOrEqualTo(1 - 8 / 2048f - 1e-5f), $"the top edge on the strip: {uv}"); + Assert.That(Facing(model, Vector3.right).materials, Is.EquivalentTo(new[] { trim }), "the side edge, the strip turned"); + Assert.That(Facing(model, Vector3.back).materials, Is.EquivalentTo(new[] { material }), "the front keeps the sheet's own"); + + // tile 1 of each sheet, pinned: the same id, told apart by the sheet + var trimPinned = Model(); + Box(trimPinned, new float3(-0.5f, 0, -0.01f), new float3(0.5f, 1, 0.01f), d => { d.hotspot.pinnedTile = 1; d.hotspot.pinnedSheet = 1; return d; }); + Assert.That(Facing(trimPinned, Vector3.back).materials, Is.EquivalentTo(new[] { trim }), "pinned to the trim sheet's tile 1"); + var ownPinned = Model(); + Box(ownPinned, new float3(-0.5f, 0, -0.01f), new float3(0.5f, 1, 0.01f), d => { d.hotspot.pinnedTile = 1; d.hotspot.pinnedSheet = 0; return d; }); + var front = Facing(ownPinned, Vector3.back); + Assert.That(front.materials, Is.EquivalentTo(new[] { material }), "pinned to the sheet's own tile 1"); + // A1: pixels 0..512 x 0..512 from the top: u 0..0.25, v 0.75..1 + foreach (var uv in front.uvs) + Assert.That(uv.x <= 0.25f + 1e-5f && uv.y >= 0.75f - 1e-5f, Is.True, $"on A1: {uv}"); + } + finally + { + metadata.hotspotSheets.Clear(); + Object.DestroyImmediate(trimLayout); + } + } + + [Test] + public void AGroupOnTheLayoutsOwnTile_TakesTheLayoutsOwnMaterialBack() + { + // a trim sheet; a baseboard's end cap alone takes a strip of it, but together with the end of the wall it + // stands against (coplanar, touching: one group) it is mapped on a tile of the sheet's own + var trimLayout = ScriptableObject.CreateInstance(); + trimLayout.size = new Vector2Int(1024, 64); + trimLayout.pixelsPerUnit = 256; + trimLayout.tiles = new List { Rect(1, "TR4", 0, 32, 1024, 32, HotspotTileFlags.RepeatU | HotspotTileFlags.PartialRepeats | HotspotTileFlags.AllowRotation) }; + var trim = new Material(ChiselProjectSettings.DefaultWallMaterial); + AssetDatabase.CreateAsset(trim, kFolder + "/trim.mat"); + trim.AddMetadataOfType().hotspotLayout = trimLayout; + metadata.hotspotSheets = new List { trim }; + try + { + var model = Model(); + Box(model, new float3(0, 0, 0.125f), new float3(20, 3, 0.375f)); + Box(model, new float3(0, 0, -0.125f), new float3(20, 0.2f, 0.125f)); + var caps = Facing(model, Vector3.left); + Assert.That(caps.materials.Count, Is.EqualTo(1), "one group, one material: " + string.Join(", ", caps.materials)); + if (caps.materials.Contains(material)) + { + // on the sheet's own tiles: uvs of a 2048 sheet, not squeezed into the trim's 64 pixels + var lo = new Vector2(float.MaxValue, float.MaxValue); var hi = -lo; + foreach (var uv in caps.uvs) { lo = Vector2.Min(lo, uv); hi = Vector2.Max(hi, uv); } + Assert.That((hi - lo).y * 2048, Is.GreaterThan(64), $"the group's uvs: {lo} .. {hi}"); + } else + { + // on the trim: every uv on its strip (the bottom half of the trim's texture) + foreach (var uv in caps.uvs) + Assert.That(uv.y, Is.LessThanOrEqualTo(0.5f + 1e-5f), $"a trim uv off the strip: {uv}; all (px) " + string.Join(" ", caps.uvs.ConvertAll(u => (u * new Vector2(1024, 64)).ToString("F0")))); + } + } + finally + { + metadata.hotspotSheets.Clear(); + Object.DestroyImmediate(trimLayout); + } + } + + [Test] + public void TheSavedOutputsHash_FollowsTheHotspotInputs() + { + var model = Model(); + var left = Box(model, new float3(-2, 0, -0.125f), new float3(0, 2, 0.125f)); + var right = Box(model, new float3(0, 0, -0.125f), new float3(2, 2, 0.125f)); + Update(); + var first = ChiselModelManager.GetComponentInputHash(model); + var built = ChiselModelManager.GetInputHash(model); + Assert.That(first.isValid, Is.True); + // a tile's name is no input + var a1 = layout.tiles[0]; a1.name = "renamed"; layout.tiles[0] = a1; + Assert.That(ChiselModelManager.GetComponentInputHash(model), Is.EqualTo(first), "a name"); + // its border is + BorderOnB1(32); + var bordered = ChiselModelManager.GetComponentInputHash(model); + Assert.That(bordered, Is.Not.EqualTo(first), "a tile's border"); + Assert.That(ChiselModelManager.GetInputHash(model), Is.Not.EqualTo(built), "a tile's border, the built tree's hash"); + layout.interiorPadding = 1; + Assert.That(ChiselModelManager.GetComponentInputHash(model), Is.Not.EqualTo(bordered), "the layout's interior padding"); + // and so are the surfaces connected by hand + var padded = ChiselModelManager.GetComponentInputHash(model); + Assert.That(ChiselHotspotLinks.Disconnect(FrontOf(left), FrontOf(right)), Is.True); + Assert.That(ChiselModelManager.GetComponentInputHash(model), Is.Not.EqualTo(padded), "a link"); + } + + [Test] + public void TwoCoplanarBoxFaces_DisconnectedByHand_TakeATileEach() + { + var model = Model(); + // in one 32 m chunk, so the left one's corner is found first where they meet + var left = Box(model, new float3(2, 0, -0.125f), new float3(4, 2, 0.125f)); + var right = Box(model, new float3(4, 0, -0.125f), new float3(6, 2, 0.125f)); + Assert.That(ChiselHotspotLinks.Disconnect(FrontOf(left), FrontOf(right)), Is.True, "disconnected"); + Update(); + // the left one alone: 2 x 2 on a square tile + var bottomLeft = UVAt(model, new float3(2, 0, -0.125f), kFront); + var topRight = UVAt(model, new float3(4, 2, -0.125f), kFront); + Assert.That(math.abs((topRight - bottomLeft).x - (topRight - bottomLeft).y), Is.LessThan(1e-5f), $"square: {bottomLeft} .. {topRight}"); + // and back to what they do by themselves + Assert.That(ChiselHotspotLinks.Forget(FrontOf(left), FrontOf(right)), Is.True, "forgotten"); + Update(); + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(2, 0, -0.125f), kFront), "together again: bottom left"); + } + + [Test] + public void ALayoutEdit_UndoneAndRedone_MapsTheModelsAgain() + { + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + Update(); + var plain = Drawn(model, kFront).Count; + Undo.IncrementCurrentGroup(); + Undo.RecordObject(layout, "Edit Hotspot Layout"); + BorderOnB1(32); + layout.NotifyChanged(); + Undo.IncrementCurrentGroup(); + Update(); + var bordered = Drawn(model, kFront).Count; + Assert.That(bordered, Is.GreaterThan(plain), "the border cuts the wall's front"); + Undo.PerformUndo(); + Update(); + Assert.That(layout.tiles[2].border, Is.Zero, "the tile is as it was"); + Assert.That(Drawn(model, kFront).Count, Is.EqualTo(plain), "undone: the model is mapped as before"); + Undo.PerformRedo(); + Update(); + Assert.That(Drawn(model, kFront).Count, Is.EqualTo(bordered), "redone: mapped with the border again"); + } + + // The lightmap uv of the drawn vertex at a position on a face with this normal + static Vector2 LightmapUVAt(ChiselModelComponent model, float3 position, float3 normal) + { + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.sharedMesh == null) + continue; + var mesh = renderable.sharedMesh; + var vertices = mesh.vertices; var normals = mesh.normals; var uvs = mesh.uv2; + for (int i = 0; i < vertices.Length && i < uvs.Length; i++) + { + if (math.distance((float3)vertices[i], position) < 1e-4f && math.dot((float3)normals[i], normal) > 0.999f) + return uvs[i]; + } + } + Assert.Fail($"no vertex at {position} with normal {normal}"); + return default; + } + + [Test] + public void ABorderOnTheTile_CutsTheWall_ButNotItsColliderOrItsLightmap() + { + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + Update(); + var collider = model.generated.colliders[0]; + var marker = new Bounds(new Vector3(1000, 1000, 1000), Vector3.one); + collider.sharedMesh.bounds = marker; + var drawn = Drawn(model, kFront).Count; + var corners = new[] { new float3(-2, 0, -0.125f), new float3(2, 0, -0.125f), new float3(2, 2, -0.125f), new float3(-2, 2, -0.125f) }; + var lightmapBefore = new Vector2[corners.Length]; + for (int c = 0; c < corners.Length; c++) + lightmapBefore[c] = LightmapUVAt(model, corners[c], kFront); + var hashes = new Dictionary(); + foreach (var renderable in model.generated.renderables) + hashes[renderable] = renderable.lightmapHashValue; + + BorderOnB1(32); + layout.NotifyChanged(); + Update(); + + Assert.That(Drawn(model, kFront).Count, Is.GreaterThan(drawn), "the border cuts what is drawn"); + Assert.That(model.generated.colliders[0], Is.SameAs(collider), "the same collider"); + Assert.That(collider.sharedMesh.bounds, Is.EqualTo(marker), "its mesh was not made again, nor baked"); + foreach (var renderable in model.generated.renderables) + { + if (hashes.TryGetValue(renderable, out var hash)) + Assert.That(renderable.lightmapHashValue, Is.EqualTo(hash), renderable.container.name + ": the lightmap layout"); + } + for (int c = 0; c < corners.Length; c++) + Assert.That(LightmapUVAt(model, corners[c], kFront), Is.EqualTo(lightmapBefore[c]), $"the lightmap uv at {corners[c]}"); + } + + [Test] + public void ALinkThatIsGone_MapsItsSurfacesAgain() + { + // an undo takes the link away without anything marking its surfaces + var model = Model(); + var left = Box(model, new float3(-2, 0, -0.125f), new float3(0, 2, 0.125f)); + var right = Box(model, new float3(0, 0, -0.125f), new float3(2, 2, 0.125f)); + Assert.That(ChiselHotspotLinks.Disconnect(FrontOf(left), FrontOf(right)), Is.True, "disconnected"); + Update(); + var apart = UVAt(model, new float3(-2, 0, -0.125f), kFront); + Assert.That(math.abs(apart.x - 0.5f) + math.abs(apart.y - 0.75f), Is.GreaterThan(1e-3f), "apart, they are mapped one each"); + model.hotspotLinks.Clear(); + Update(); + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "together again without a mark on the surfaces"); + } + + // The face of a generator's first brush toward +x + static SurfaceReference RightOf(ChiselGeneratorComponent generator) + { + var brush = (CSGTreeBrush)generator.TopTreeNode; + var blob = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh); + ref var mesh = ref blob.Value; + var best = -1; var bestX = float.NegativeInfinity; + for (int p = 0; p < mesh.polygons.Length; p++) + { + var x = 0f; + for (int e = 0; e < mesh.polygons[p].edgeCount; e++) + x += mesh.localVertices[mesh.halfEdges[mesh.polygons[p].firstEdge + e].vertexIndex].x; + x /= mesh.polygons[p].edgeCount; + if (x > bestX) { bestX = x; best = p; } + } + return new SurfaceReference(generator, mesh.polygons[best].descriptionIndex, brush, best); + } + + [Test] + public void ABoxsFrontAndSide_ConnectedByHand_WrapOneTileRoundItsCorner() + { + var model = Model(); + var box = Box(model, new float3(-2, 0, -0.125f), new float3(0, 2, 0.125f)); + Assert.That(ChiselHotspotLinks.Connect(FrontOf(box), RightOf(box)), Is.True, "connected"); + Update(); + // where they meet: the same uv on both faces, so the texture runs on round the corner + foreach (var at in new[] { new float3(0, 0, -0.125f), new float3(0, 2, -0.125f) }) + AreEqual(UVAt(model, at, kFront), UVAt(model, at, new float3(1, 0, 0)), $"at {at}"); + // and the side goes on past where the front ends + var frontLeft = UVAt(model, new float3(-2, 0, -0.125f), kFront); + var corner = UVAt(model, new float3(0, 0, -0.125f), kFront); + var sideEnd = UVAt(model, new float3(0, 0, 0.125f), new float3(1, 0, 0)); + Assert.That(math.dot(corner - frontLeft, sideEnd - corner), Is.GreaterThan(0), $"{frontLeft} -> {corner} -> {sideEnd}"); + } + + // The face of a generator's first brush toward +y + static SurfaceReference TopOf(ChiselGeneratorComponent generator) + { + var brush = (CSGTreeBrush)generator.TopTreeNode; + var blob = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh); + ref var mesh = ref blob.Value; + var best = -1; var bestY = float.NegativeInfinity; + for (int p = 0; p < mesh.polygons.Length; p++) + { + var y = 0f; + for (int e = 0; e < mesh.polygons[p].edgeCount; e++) + y += mesh.localVertices[mesh.halfEdges[mesh.polygons[p].firstEdge + e].vertexIndex].y; + y /= mesh.polygons[p].edgeCount; + if (y > bestY) { bestY = y; best = p; } + } + return new SurfaceReference(generator, mesh.polygons[best].descriptionIndex, brush, best); + } + + [Test] + public void TwoCoplanarBoxFaces_WithTheBorderShownByHand_HaveABorderWhereTheyMeet() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + var left = Box(model, new float3(-3, 0, -0.125f), new float3(0, 2, 0.125f)); + var right = Box(model, new float3(0, 0, -0.125f), new float3(3, 2, 0.125f)); + Assert.That(ChiselHotspotLinks.SetBorder(FrontOf(left), FrontOf(right), ChiselHotspotBorderState.Shown), Is.True, "shown"); + Assert.That(ChiselHotspotLinks.BorderOf(FrontOf(left), FrontOf(right)), Is.EqualTo(ChiselHotspotBorderState.Shown)); + Assert.That(ChiselHotspotLinks.StateOf(FrontOf(left), FrontOf(right)), Is.EqualTo(ChiselHotspotLinkState.Automatic), "still connected by themselves"); + Update(); + // still one 6 x 2 wall on B1, but where they meet each has a side of the tile: the left one the tile's right + // side, the right one its left side + var meet = UVsAt(model, new float3(0, 0.125f, -0.125f), kFront); + Assert.That(meet.Exists(uv => math.abs(uv.x - 1) < 1e-5f), Is.True, $"the tile's right side among {string.Join(", ", meet)}"); + Assert.That(meet.Exists(uv => math.abs(uv.x - 0.5f) < 1e-5f), Is.True, $"the tile's left side among {string.Join(", ", meet)}"); + AreEqual(new float2((1024 + 32) / 2048f, (1536 + 32) / 2048f), UVAt(model, new float3(-3 + 0.125f, 0.125f, -0.125f), kFront), "the inner corner at the bottom left, as before"); + // and back + Assert.That(ChiselHotspotLinks.SetBorder(FrontOf(left), FrontOf(right), ChiselHotspotBorderState.Automatic), Is.True, "automatic"); + Assert.That(model.hotspotLinks, Is.Empty, "nothing left to keep"); + Update(); + var after = UVsAt(model, new float3(0, 0.125f, -0.125f), kFront); + Assert.That(after.Count, Is.GreaterThan(0)); + foreach (var uv in after) + Assert.That(math.abs(uv.x - 1) > 1e-3f && math.abs(uv.x - 0.5f) > 1e-3f, Is.True, $"no border where they meet: {uv}"); + } + + [Test] + public void AWallOnAFloor_WithTheBorderAlongItsFootHiddenByHand_HasItsSidesRunDownToIt() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + var wall = Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + var floor = Box(model, new float3(-4, -1, -2), new float3(4, 0, 2)); + // by itself the wall's bottom left corner is the tile's + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "with its border"); + Assert.That(ChiselHotspotLinks.SetBorder(FrontOf(wall), TopOf(floor), ChiselHotspotBorderState.Hidden), Is.True, "hidden"); + Update(); + // without it the left side's strip runs down to the floor, and the tile's inside starts there + var foot = UVsAt(model, new float3(-2, 0, -0.125f), kFront); + Assert.That(foot.Count, Is.GreaterThan(0)); + foreach (var uv in foot) + AreEqual(new float2(0.5f, (1536 + 32) / 2048f), uv, "the left strip at the foot"); + // the top keeps its border + AreEqual(new float2(0.5f, 1), UVAt(model, new float3(-2, 2, -0.125f), kFront), "the top left corner"); + } + + // The edge of a surface's brush polygon whose two corners are the lowest + static int BottomEdgeOf(SurfaceReference surface) + { + var best = -1; var bestY = float.PositiveInfinity; + for (int edge = 0; ChiselHotspotLinks.EdgeOf(surface.brush, surface.surfaceIndex, edge, out var from, out var to); edge++) + { + var y = from.y + to.y; + if (y < bestY) { bestY = y; best = edge; } + } + return best; + } + + [Test] + public void AWall_WithTheBorderAlongItsFootHiddenByHand_HasItsSidesRunDownToIt() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + var wall = Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "with its border"); + var front = FrontOf(wall); + var foot = BottomEdgeOf(front); + Assert.That(ChiselHotspotLinks.SetEdgeBorder(front, foot, ChiselHotspotBorderState.Hidden), Is.True, "hidden"); + Assert.That(ChiselHotspotLinks.EdgeBorderOf(front, foot), Is.EqualTo(ChiselHotspotBorderState.Hidden)); + Update(); + foreach (var uv in UVsAt(model, new float3(-2, 0, -0.125f), kFront)) + AreEqual(new float2(0.5f, (1536 + 32) / 2048f), uv, "the left strip at the foot"); + AreEqual(new float2(0.5f, 1), UVAt(model, new float3(-2, 2, -0.125f), kFront), "the top left corner"); + // and back + Assert.That(ChiselHotspotLinks.SetEdgeBorder(front, foot, ChiselHotspotBorderState.Automatic), Is.True, "automatic"); + Assert.That(model.hotspotLinks, Is.Empty); + Update(); + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "its border again"); + } + + // How many drawn triangles face this way + static int TrianglesFacing(ChiselModelComponent model, float3 normal) + { + var count = 0; + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.sharedMesh == null) + continue; + var mesh = renderable.sharedMesh; + var normals = mesh.normals; + for (int sub = 0; sub < mesh.subMeshCount; sub++) + { + var triangles = mesh.GetTriangles(sub); + for (int i = 0; i + 2 < triangles.Length; i += 3) + if (math.dot((float3)normals[triangles[i]], normal) > 0.999f) count++; + } + } + return count; + } + + [Test] + public void AWallWhoseCutWouldDrawMoreThanItsBudget_IsDrawnUncut() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + var cut = TrianglesFacing(model, kFront); + Assert.That(cut, Is.GreaterThan(2), "cut along its border"); + // a budget it cannot keep, with or without its interior's repeats + layout.maxTriangles = 2; + layout.NotifyChanged(); + Update(); + Assert.That(TrianglesFacing(model, kFront), Is.EqualTo(2), "drawn uncut"); + layout.maxTriangles = 0; + layout.NotifyChanged(); + Update(); + Assert.That(TrianglesFacing(model, kFront), Is.EqualTo(cut), "cut again with the project's budget"); + } + + [Test] + public void OffsetCoplanarBoxFaces_TouchingAlongPartOfAnEdge_AreConnected() + { + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 1, 0.125f)); + Box(model, new float3(0, 1, -0.125f), new float3(4, 2, 0.125f)); + // where the one's corner lies on the other's edge both are drawn with one uv + foreach (var at in new[] { new float3(0, 1, -0.125f), new float3(2, 1, -0.125f) }) + { + var uvs = UVsAt(model, at, kFront); + Assert.That(uvs.Count, Is.GreaterThanOrEqualTo(2), $"vertices at {at}"); + foreach (var uv in uvs) + AreEqual(uvs[0], uv, $"at {at}"); + } + } + + [Test] + public void AMirroredWall_TakesItsTileMirrored() + { + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f), d => { d.hotspot.mirror = true; return d; }); + // B1: u 0.5..1, v 0.75..1, its left on the wall's right + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(2, 0, -0.125f), kFront), "bottom right"); + AreEqual(new float2(1.0f, 0.75f), UVAt(model, new float3(-2, 0, -0.125f), kFront), "bottom left"); + AreEqual(new float2(1.0f, 1.0f), UVAt(model, new float3(-2, 2, -0.125f), kFront), "top left"); + } + + [Test] + public void ADenserWall_HasANarrowerBorder() + { + var b1 = layout.tiles[2]; + b1.border = 32; + layout.tiles[2] = b1; + var model = Model(); + Box(model, new float3(-1.5f, 0, -0.125f), new float3(1.5f, 1, 0.125f), d => { d.hotspot.density = 2; return d; }); + // 3 x 1 at twice the tile's density looks 6 x 2: B1, its 32 pixel border a sixteenth of a unit + AreEqual(new float2(0.5f, 0.75f), UVAt(model, new float3(-1.5f, 0, -0.125f), kFront), "bottom left"); + AreEqual(new float2((1024 + 32) / 2048f, (1536 + 32) / 2048f), UVAt(model, new float3(-1.5f + 0.0625f, 0.0625f, -0.125f), kFront), "the inner corner at the bottom left"); + AreEqual(new float2(1, 1), UVAt(model, new float3(1.5f, 1, -0.125f), kFront), "top right"); + } + + [Test] + public void RemovingTheHotspotMaterial_PutsBackTheSurfacesOwnUVs() + { + var model = Model(); + var box = Add(model, "box"); + box.Max = new float3(2, 2, 0.125f); + box.Min = new float3(-2, 0, -0.125f); + Update(); + // a uv matrix of its own on every surface: turned, scaled and moved + var plain = new List(); + foreach (var surface in box.SurfaceDefinition.surfaces) + { + plain.Add(surface.RenderMaterial); + var details = surface.surfaceDetails; + details.UV0 = UVMatrix.TRS(new Vector2(0.25f, 0.5f), Vector3.forward, 30, new float2(0.5f, 2)); + surface.surfaceDetails = details; + } + box.OnValidate(); + Update(); + var corners = new[] { new float3(-2, 0, -0.125f), new float3(2, 0, -0.125f), new float3(-2, 2, -0.125f), new float3(2, 2, -0.125f) }; + var own = new float2[corners.Length]; + for (int i = 0; i < corners.Length; i++) + own[i] = UVAt(model, corners[i], kFront); + var matrices = new List(); + foreach (var surface in box.SurfaceDefinition.surfaces) + matrices.Add(surface.surfaceDetails.UV0); + + UseHotspotMaterial(box); + AreEqual(new float2(0.5f, 0.75f), UVAt(model, corners[0], kFront), "on B1"); + + var surfaces = box.SurfaceDefinition.surfaces; + for (int s = 0; s < surfaces.Length; s++) + surfaces[s].SetMaterial(plain[s]); + box.OnValidate(); + Update(); + for (int s = 0; s < surfaces.Length; s++) + Assert.That(surfaces[s].surfaceDetails.UV0, Is.EqualTo(matrices[s]), $"surface {s}'s uv matrix changed"); + for (int i = 0; i < corners.Length; i++) + AreEqual(own[i], UVAt(model, corners[i], kFront), $"corner {corners[i]}"); + } + + [Test] + public void AMaterialWithoutALayout_KeepsItsOwnUVs() + { + metadata.hotspotLayout = null; + var model = Model(); + Box(model, new float3(-2, 0, -0.125f), new float3(2, 2, 0.125f)); + var corners = new[] { UVAt(model, new float3(-2, 0, -0.125f), kFront), UVAt(model, new float3(2, 0, -0.125f), kFront), + UVAt(model, new float3(-2, 2, -0.125f), kFront), UVAt(model, new float3(2, 2, -0.125f), kFront) }; + var min = math.min(math.min(corners[0], corners[1]), math.min(corners[2], corners[3])); + var max = math.max(math.max(corners[0], corners[1]), math.max(corners[2], corners[3])); + // the material's own planar uvs: one per unit, so a 4 x 2 wall spans 4 one way + Assert.That(math.cmax(max - min), Is.GreaterThan(1), $"the wall's uvs fit in one tile: {string.Join(", ", corners)}"); + } + } +} diff --git a/Components/Tests/ChiselHotspotMappingTests.cs.meta b/Components/Tests/ChiselHotspotMappingTests.cs.meta new file mode 100644 index 00000000..edddbd3d --- /dev/null +++ b/Components/Tests/ChiselHotspotMappingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 690ee6e155558f043a250fb5d4fad9d3 \ No newline at end of file diff --git a/Components/Tests/ChiselLightmapUVTests.cs b/Components/Tests/ChiselLightmapUVTests.cs new file mode 100644 index 00000000..1e5bdea2 --- /dev/null +++ b/Components/Tests/ChiselLightmapUVTests.cs @@ -0,0 +1,33 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselLightmapUVTests + { + [Test] + public void OnlyModelsLitByLightmaps_AreLightmapped() + { + var root = new GameObject("Lightmap UV Model"); + root.SetActive(false); + try + { + var model = root.AddComponent(); + + model.RenderSettings.receiveGI = ReceiveGI.LightProbes; + Assert.IsFalse(ChiselLightmapUVManager.IsLightmapped(model, StaticEditorFlags.ContributeGI), "lit by light probes"); + + model.RenderSettings.receiveGI = ReceiveGI.Lightmaps; + Assert.IsTrue(ChiselLightmapUVManager.IsLightmapped(model, StaticEditorFlags.ContributeGI | StaticEditorFlags.BatchingStatic)); + Assert.IsFalse(ChiselLightmapUVManager.IsLightmapped(model, StaticEditorFlags.BatchingStatic), "not in the bake at all"); + Assert.IsFalse(ChiselLightmapUVManager.IsLightmapped(null, StaticEditorFlags.ContributeGI)); + } + finally + { + Object.DestroyImmediate(root); + } + } + } +} diff --git a/Components/Tests/ChiselLightmapUVTests.cs.meta b/Components/Tests/ChiselLightmapUVTests.cs.meta new file mode 100644 index 00000000..2ad687ce --- /dev/null +++ b/Components/Tests/ChiselLightmapUVTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d7fb6026569aba14db754fbe940be674 \ No newline at end of file diff --git a/Components/Tests/ChiselMaterialMeshTests.cs b/Components/Tests/ChiselMaterialMeshTests.cs new file mode 100644 index 00000000..85d4b78c --- /dev/null +++ b/Components/Tests/ChiselMaterialMeshTests.cs @@ -0,0 +1,406 @@ +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + // Each material of a model is a mesh and a renderer of its own, so an edit makes again only the meshes of the + // materials it changed (ChiselGeneratedObjects.FindRenderables, CompactHierarchyManager's kept sections) + [TestFixture] + public class ChiselMaterialMeshTests + { + // A rebuild recomputes the bounds of every mesh it writes, so bounds this far off only survive when it didn't run + static readonly Bounds kMarker = new(new Vector3(1000, 1000, 1000), new Vector3(1, 1, 1)); + + readonly List roots = new(); + Material first, second; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + first = new Material(ChiselProjectSettings.DefaultWallMaterial) { name = "first" }; + second = new Material(ChiselProjectSettings.DefaultWallMaterial) { name = "second" }; + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + Object.DestroyImmediate(first); + Object.DestroyImmediate(second); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselModelComponent Model() + { + var root = new GameObject("material mesh test"); + roots.Add(root); + return root.AddComponent(); + } + + static ChiselBoxComponent Box(ChiselModelComponent model, float3 min, float3 max, Material material) + { + var go = new GameObject("box"); + go.transform.SetParent(model.transform, false); + var box = go.AddComponent(); + if (math.any(min > box.Max)) { box.Max = max; box.Min = min; } + else { box.Min = min; box.Max = max; } + Update(); + SetMaterial(box, material); + return box; + } + + static void SetMaterial(ChiselGeneratorComponent generator, Material material) + { + foreach (var surface in generator.SurfaceDefinition.surfaces) + surface.SetMaterial(material); + generator.OnValidate(); + Update(); + } + + // The renderers of the model that draw something with this material + static List RenderersOf(ChiselModelComponent model, Material material) + { + var found = new List(); + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || !renderable.Valid || !renderable.HasGeometry) + continue; + if (renderable.meshRenderer.sharedMaterial == material) + found.Add(renderable); + } + return found; + } + + [Test] + public void TwoMaterials_AreTwoRenderers_OneMaterialEach() + { + var model = Model(); + Box(model, new float3(-5, 0, 0), new float3(-4, 1, 1), first); + Box(model, new float3(4, 0, 0), new float3(5, 1, 1), second); + var withFirst = RenderersOf(model, first); + var withSecond = RenderersOf(model, second); + Assert.That(withFirst.Count, Is.EqualTo(1), "the first material's renderer"); + Assert.That(withSecond.Count, Is.EqualTo(1), "the second material's renderer"); + Assert.That(withFirst[0].sharedMesh.subMeshCount, Is.EqualTo(1)); + Assert.That(withSecond[0].sharedMesh.subMeshCount, Is.EqualTo(1)); + Assert.That(withFirst[0], Is.Not.SameAs(withSecond[0])); + } + + [Test] + public void AnEditToOneMaterial_LeavesTheOtherMaterialsMeshAlone() + { + var model = Model(); + var moved = Box(model, new float3(-5, 0, 0), new float3(-4, 1, 1), first); + Box(model, new float3(4, 0, 0), new float3(5, 1, 1), second); + var untouched = RenderersOf(model, second)[0]; + var changed = RenderersOf(model, first)[0]; + untouched.sharedMesh.bounds = kMarker; + changed.sharedMesh.bounds = kMarker; + + moved.transform.position += new Vector3(0, 0, 0.5f); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(moved); + Update(); + + Assert.That(RenderersOf(model, second)[0], Is.SameAs(untouched), "the same renderer"); + Assert.That(untouched.sharedMesh.bounds, Is.EqualTo(kMarker), "its mesh was not made again"); + Assert.That(RenderersOf(model, first)[0].sharedMesh.bounds, Is.Not.EqualTo(kMarker), "the edited material's mesh was"); + } + + // A material's surfaces in two 32 m chunks are two meshes (Documentation~/Design/Chunking.md), and an edit in one + // keeps the other's mesh + [Test] + public void AMaterialInTwoChunks_IsTwoMeshes_AndAnEditInOneKeepsTheOther() + { + var model = Model(); + var moved = Box(model, new float3(1, 0, 0), new float3(2, 1, 1), first); + Box(model, new float3(40, 0, 0), new float3(41, 1, 1), first); + var renderers = RenderersOf(model, first); + Assert.That(renderers.Count, Is.EqualTo(2), "a mesh per chunk"); + var far = renderers.Find(r => r.sharedMesh.bounds.center.x > 32); + Assert.That(far, Is.Not.Null, "the far box's chunk"); + far.sharedMesh.bounds = kMarker; + + moved.transform.position += new Vector3(0, 0, 0.5f); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(moved); + Update(); + + Assert.That(RenderersOf(model, first), Does.Contain(far), "the far chunk's renderer"); + Assert.That(far.sharedMesh.bounds, Is.EqualTo(kMarker), "its mesh was not made again"); + } + + // A model that bakes lightmaps, whose renderers' lightmaps may be at most a few centimetres across + ChiselModelComponent LightmappedModel() + { + var model = Model(); + UnityEditor.GameObjectUtility.SetStaticEditorFlags(model.gameObject, UnityEditor.StaticEditorFlags.ContributeGI); + model.RenderSettings.receiveGI = ReceiveGI.Lightmaps; + model.RenderSettings.scaleInLightmap = 1000; + model.SyncModelSettingsStore(); + Assert.That(model.RenderSettings.lightmapMaxSize, Is.GreaterThan(0), "the model doesn't bake lightmaps: this measures nothing"); + return model; + } + + // Two boxes in one 32 m cell, in two 8 m cells: one chunk, unless the chunk's lightmap is too large + [Test] + public void AChunkWhoseLightmapIsTooLarge_IsSplit_InAModelThatBakesLightmaps() + { + var model = LightmappedModel(); + Box(model, new float3(1, 0, 0), new float3(2, 1, 1), first); + Box(model, new float3(11, 0, 0), new float3(12, 1, 1), first); + var renderers = RenderersOf(model, first); + Assert.That(renderers.Count, Is.EqualTo(2), "a mesh per 8 m cell"); + var vertices = 0; + foreach (var renderer in renderers) + vertices += renderer.sharedMesh.vertexCount; + Assert.That(vertices, Is.EqualTo(2 * 24), "both boxes, every face"); + } + + [Test] + public void AChunk_IsNotSplit_InAModelLitByProbes() + { + var model = Model(); + model.RenderSettings.scaleInLightmap = 1000; + model.SyncModelSettingsStore(); + Assert.That(model.RenderSettings.lightmapMaxSize, Is.EqualTo(0)); + Box(model, new float3(1, 0, 0), new float3(2, 1, 1), first); + Box(model, new float3(11, 0, 0), new float3(12, 1, 1), first); + Assert.That(RenderersOf(model, first).Count, Is.EqualTo(1), "one mesh for the 32 m cell"); + } + + // Three boxes of about 6 m² of lightmap each: with room for 15, their 32 m cell (18) splits into its 16 m cells (12 and 6) + [Test] + public void ASplitStopsAtTheCellsThatFit() + { + var model = LightmappedModel(); + var budgetSide = math.sqrt(15.0f); + model.RenderSettings.scaleInLightmap = ChiselLightmapUVManager.LightmapAtlasSize() / (budgetSide * ChiselLightmapUVManager.LightmapResolution()); + model.SyncModelSettingsStore(); + Box(model, new float3(1, 0, 0), new float3(2, 1, 1), first); + Box(model, new float3(11, 0, 0), new float3(12, 1, 1), first); + Box(model, new float3(20, 0, 0), new float3(21, 1, 1), first); + Assert.That(RenderersOf(model, first).Count, Is.EqualTo(2), "a mesh per 16 m cell"); + } + + // A chunk that fits is not split: the same boxes at the default lightmap scale + [Test] + public void AChunkWhoseLightmapFits_IsNotSplit() + { + var model = LightmappedModel(); + model.RenderSettings.scaleInLightmap = 1; + model.SyncModelSettingsStore(); + Box(model, new float3(1, 0, 0), new float3(2, 1, 1), first); + Box(model, new float3(11, 0, 0), new float3(12, 1, 1), first); + Assert.That(RenderersOf(model, first).Count, Is.EqualTo(1), "one mesh for the 32 m cell"); + } + + [Test] + public void AMaterialNoLongerDrawn_LosesItsRenderer() + { + var model = Model(); + // both in one 32 m chunk + Box(model, new float3(1, 0, 0), new float3(2, 1, 1), first); + var box = Box(model, new float3(9, 0, 0), new float3(10, 1, 1), second); + SetMaterial(box, first); + Assert.That(RenderersOf(model, second), Is.Empty, "nothing draws the second material"); + foreach (var renderable in model.generated.renderables) + Assert.That(renderable.meshRenderer.sharedMaterial, Is.Not.SameAs(second), "no renderer of it is left"); + Assert.That(RenderersOf(model, first).Count, Is.EqualTo(1), "both boxes in the first material's mesh"); + } + + [Test] + public void AKeptRendererThatWasDeleted_IsMadeAgain() + { + var model = Model(); + var moved = Box(model, new float3(-5, 0, 0), new float3(-4, 1, 1), first); + Box(model, new float3(4, 0, 0), new float3(5, 1, 1), second); + var lost = RenderersOf(model, second)[0]; + Object.DestroyImmediate(lost.container); + + moved.transform.position += new Vector3(0, 0, 0.5f); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(moved); + Update(); + + var again = RenderersOf(model, second); + Assert.That(again.Count, Is.EqualTo(1), "the second material is drawn again"); + Assert.That(again[0].container, Is.Not.Null); + } + + [Test] + public void AChangeToTheUVsOnly_KeepsTheColliderAndTheLightmapLayout() + { + var model = Model(); + var box = Box(model, new float3(-1, 0, -1), new float3(1, 2, 1), first); + var renderer = RenderersOf(model, first)[0]; + var lightmapHash = renderer.lightmapHashValue; + Assert.That(lightmapHash, Is.Not.Zero, "the renderer knows its lightmap layout"); + Assert.That(model.generated.colliders.Length, Is.EqualTo(1)); + var collider = model.generated.colliders[0]; + collider.sharedMesh.bounds = kMarker; + var uvsBefore = renderer.sharedMesh.uv; + var lightmapBefore = renderer.sharedMesh.uv2; + + foreach (var surface in box.SurfaceDefinition.surfaces) + { + var details = surface.surfaceDetails; + details.UV0.U.w += 0.25f; + surface.surfaceDetails = details; + } + box.OnValidate(); + Update(); + + Assert.That(model.generated.colliders[0], Is.SameAs(collider), "the same collider"); + Assert.That(collider.sharedMesh.bounds, Is.EqualTo(kMarker), "its mesh was not made again, nor baked"); + var after = RenderersOf(model, first)[0]; + Assert.That(after.sharedMesh.uv, Is.Not.EqualTo(uvsBefore), "the drawn uvs changed"); + Assert.That(after.lightmapHashValue, Is.EqualTo(lightmapHash), "the lightmap layout did not"); + Assert.That(after.sharedMesh.uv2, Is.EqualTo(lightmapBefore), "nor did the lightmap uvs"); + } + + // The selection owners are stored after an edit, not during it; restoring the selection stores them first + [Test] + public void TheSelectionOwners_LeftForLater_AreStoredBeforeTheyAreRestored() + { + var model = Model(); + var box = Box(model, new float3(-1, 0, -1), new float3(1, 2, 1), first); + box.transform.position += new Vector3(0, 0, 0.5f); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(box); + Update(); + var renderer = RenderersOf(model, first)[0]; + var before = (SelectionDescription[])renderer.GetSelectionIndexDescriptionArray().Clone(); + Assert.That(before, Is.Not.Empty); + + renderer.RestoreSelection(); + + Assert.That(renderer.GetSelectionIndexDescriptionArray(), Is.EqualTo(before), "restored from owners stored first"); + } + + [Test] + public void StoringThePendingSelectionOwners_NamesTheBrushesGenerator() + { + var model = Model(); + var box = Box(model, new float3(-1, 0, -1), new float3(1, 2, 1), first); + ChiselRenderObjects.StorePendingSelectionOwners(); + var renderer = RenderersOf(model, first)[0]; + Assert.That(renderer.selectionOwners, Is.EquivalentTo(new[] { box })); + Assert.That(renderer.selectionOwnerIndices.Length, Is.EqualTo(renderer.GetSelectionIndexDescriptionArray().Length)); + } + + // A collider is a mesh per physics material and chunk of space, so a move bakes only the chunks it touched + [Test] + public void FarApartBrushes_HaveCollidersOfTheirOwn_AndAMoveMakesOnlyItsOwnAgain() + { + var model = Model(); + var moved = Box(model, new float3(-51, 0, -1), new float3(-49, 2, 1), first); + Box(model, new float3(49, 0, -1), new float3(51, 2, 1), first); + Assert.That(model.generated.colliders.Length, Is.EqualTo(2), "one collider per chunk"); + ChiselColliderObjects ColliderAt(float x) + { + foreach (var collider in model.generated.colliders) + { + if (collider.sharedMesh.bounds.center.x * x > 0) + return collider; + } + return null; + } + var untouched = ColliderAt(50); + Assert.That(untouched, Is.Not.Null); + untouched.sharedMesh.bounds = kMarker; + Assert.That(ColliderAt(-50), Is.Not.Null); + + moved.transform.position += new Vector3(0, 0, 0.5f); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(moved); + Update(); + + Assert.That(System.Array.IndexOf(model.generated.colliders, untouched), Is.GreaterThanOrEqualTo(0), "the other chunk's collider is kept"); + Assert.That(untouched.sharedMesh.bounds, Is.EqualTo(kMarker), "and was not made again"); + Assert.That(model.generated.colliders.Length, Is.EqualTo(2)); + } + + static int IndexCount(Mesh mesh) + { + var count = 0; + for (int s = 0; s < mesh.subMeshCount; s++) + count += (int)mesh.GetIndexCount(s); + return count; + } + + static void RefreshVisibility() + { + ChiselUnityVisibilityManager.UpdateVisibility(force: true); + ChiselUnityVisibilityManager.Update(); + } + + // The scene view draws a mesh whose brushes are all shown as it is, and a copy without the hidden ones otherwise + [Test] + public void AMeshWithEveryBrushShown_IsDrawnItself_AndOneWithAHiddenBrush_ThroughACopyWithoutIt() + { + var model = Model(); + // both in one 32 m chunk, so in one mesh + Box(model, new float3(1, 0, 0), new float3(2, 1, 1), first); + var hidden = Box(model, new float3(9, 0, 0), new float3(10, 1, 1), first); + var visualizing = UnityEditor.StaticOcclusionCullingVisualization.showOcclusionCulling; + UnityEditor.StaticOcclusionCullingVisualization.showOcclusionCulling = false; + RefreshVisibility(); + var renderer = RenderersOf(model, first)[0]; + Assert.That(renderer.DrawnMesh, Is.SameAs(renderer.sharedMesh), "every brush shown: the mesh itself"); + Assert.That(renderer.meshRenderer.forceRenderingOff, Is.False, "every brush shown: Unity draws the renderer (and culls it)"); + try + { + UnityEditor.SceneVisibilityManager.instance.Hide(hidden.gameObject, false); + RefreshVisibility(); + Assert.That(renderer.DrawnMesh, Is.SameAs(renderer.partialMesh), "a brush hidden: a copy"); + Assert.That(renderer.meshRenderer.forceRenderingOff, Is.True, "a brush hidden: the copy is drawn instead of the renderer"); + Assert.That(IndexCount(renderer.partialMesh), Is.GreaterThan(0)); + Assert.That(IndexCount(renderer.partialMesh), Is.LessThan(IndexCount(renderer.sharedMesh)), "without the hidden brush"); + + // the occlusion culling visualizer shows what occlusion culling sees: the whole mesh + UnityEditor.StaticOcclusionCullingVisualization.showOcclusionCulling = true; + RefreshVisibility(); + Assert.That(renderer.meshRenderer.forceRenderingOff, Is.False, "a brush hidden, visualizing occlusion culling: Unity draws the whole mesh"); + UnityEditor.StaticOcclusionCullingVisualization.showOcclusionCulling = false; + RefreshVisibility(); + Assert.That(renderer.meshRenderer.forceRenderingOff, Is.True, "and the copy again when the visualizer is off"); + } + finally + { + UnityEditor.StaticOcclusionCullingVisualization.showOcclusionCulling = visualizing; + UnityEditor.SceneVisibilityManager.instance.Show(hidden.gameObject, false); + RefreshVisibility(); + } + Assert.That(renderer.DrawnMesh, Is.SameAs(renderer.sharedMesh), "shown again: the mesh itself"); + Assert.That(renderer.meshRenderer.forceRenderingOff, Is.False, "shown again: Unity draws the renderer"); + } + + [Test] + public void MovingABrush_MakesTheColliderAgain() + { + var model = Model(); + var box = Box(model, new float3(-1, 0, -1), new float3(1, 2, 1), first); + var collider = model.generated.colliders[0]; + collider.sharedMesh.bounds = kMarker; + box.transform.position += new Vector3(0, 0, 0.5f); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(box); + Update(); + Assert.That(model.generated.colliders[0].sharedMesh.bounds, Is.Not.EqualTo(kMarker), "the moved collider was made again"); + } + } +} diff --git a/Components/Tests/ChiselMaterialMeshTests.cs.meta b/Components/Tests/ChiselMaterialMeshTests.cs.meta new file mode 100644 index 00000000..06400e74 --- /dev/null +++ b/Components/Tests/ChiselMaterialMeshTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9b955646065a4359b595a8fd58951fd2 diff --git a/Components/Tests/ChiselModelManagerTests.cs b/Components/Tests/ChiselModelManagerTests.cs new file mode 100644 index 00000000..66cba14e --- /dev/null +++ b/Components/Tests/ChiselModelManagerTests.cs @@ -0,0 +1,89 @@ +using System.Reflection; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselModelManagerTests + { + ChiselModelManager manager; + GameObject gameObject; + + [SetUp] + public void SetUp() + { + manager = ScriptableObject.CreateInstance(); + manager.hideFlags = HideFlags.HideAndDontSave; + gameObject = new GameObject(nameof(ChiselModelManagerTests)); + gameObject.SetActive(false); // so the model never enables, and never registers itself + } + + [TearDown] + public void TearDown() + { + if (gameObject) + Object.DestroyImmediate(gameObject); + if (manager) + Object.DestroyImmediate(manager); + } + + [Test] + public void Reset_ForgetsAModelThatWasDestroyedBeforeItCouldBeUnregistered() + { + var model = gameObject.AddComponent(); + manager.Register(model); + Assert.That(manager.Models, Does.Contain(model)); + + Object.DestroyImmediate(gameObject); + manager.Reset(); + + Assert.That(manager.Models, Is.Empty); + Assert.That(manager.Nodes, Is.Empty); + } + + // Live nodes are forgotten as well; the node hierarchy registers them again right after its reset. + [Test] + public void Reset_StartsOverForLiveNodesToo() + { + var model = gameObject.AddComponent(); + manager.Register(model); + + manager.Reset(); + Assert.That(manager.Models, Is.Empty); + + manager.Register(model); + Assert.That(manager.Models, Does.Contain(model)); + } + + [Test] + public void Instance_FindsTheManagerThereIs_AfterADomainReload() + { + var inUse = ChiselModelManager.Instance; + var instanceField = typeof(ChiselModelManager).GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(instanceField, Is.Not.Null, "ChiselModelManager._instance is gone: this test no longer looks at it"); + DestroyManagersBut(inUse); + instanceField.SetValue(null, null); // all a domain reload does to the manager + try + { + var found = ChiselModelManager.Instance; + Assert.That(ReferenceEquals(found, inUse), Is.True, "Instance made a manager of its own instead of finding the one there is"); + Assert.That(Resources.FindObjectsOfTypeAll(), Has.Length.EqualTo(1), "one manager"); + } + finally + { + instanceField.SetValue(null, inUse); + DestroyManagersBut(inUse); + } + } + + static void DestroyManagersBut(ChiselModelManager inUse) + { + foreach (var other in Resources.FindObjectsOfTypeAll()) + { + if (!ReferenceEquals(other, inUse)) + Object.DestroyImmediate(other); + } + } + } +} diff --git a/Components/Tests/ChiselModelManagerTests.cs.meta b/Components/Tests/ChiselModelManagerTests.cs.meta new file mode 100644 index 00000000..267a7767 --- /dev/null +++ b/Components/Tests/ChiselModelManagerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 05e887c9c4bfc374bae4e3e7670af033 \ No newline at end of file diff --git a/Components/Tests/ChiselModelShadowTests.cs b/Components/Tests/ChiselModelShadowTests.cs new file mode 100644 index 00000000..22864e43 --- /dev/null +++ b/Components/Tests/ChiselModelShadowTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselModelShadowTests + { + readonly List roots = new(); + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselModelComponent ModelWithABox() + { + var root = new GameObject("Shadow Model"); + roots.Add(root); + var model = root.AddComponent(); + var box = new GameObject("Box"); + box.transform.SetParent(root.transform, false); + box.AddComponent(); + Update(); + return model; + } + + // The model's settings as the inspector changes them: the change goes through OnValidate, which rebuilds the model + static void SetShadows(ChiselModelComponent model, bool cast, bool receive) + { + model.RenderSettings.castShadows = cast; + model.RenderSettings.receiveShadows = receive; + model.SyncModelSettingsStore(); + Update(); + } + + static List DrawingRenderers(ChiselModelComponent model) + { + var renderers = new List(); + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || !renderable.Valid || !renderable.meshRenderer || + renderable.sharedMesh == null || renderable.sharedMesh.vertexCount == 0) + continue; + renderers.Add(renderable.meshRenderer); + } + Assert.That(renderers, Is.Not.Empty, "the box drew nothing"); + return renderers; + } + + [Test] + public void AModel_CastsAndReceivesShadows() + { + var model = ModelWithABox(); + Assert.IsTrue(model.RenderSettings.castShadows); + Assert.IsTrue(model.RenderSettings.receiveShadows); + foreach (var renderer in DrawingRenderers(model)) + { + Assert.AreEqual(ShadowCastingMode.On, renderer.shadowCastingMode, renderer.name); + Assert.IsTrue(renderer.receiveShadows, renderer.name); + } + } + + [Test] + public void AModelWithoutShadows_NeitherCastsNorReceivesThem() + { + var model = ModelWithABox(); + SetShadows(model, cast: false, receive: false); + foreach (var renderer in DrawingRenderers(model)) + { + Assert.AreEqual(ShadowCastingMode.Off, renderer.shadowCastingMode, renderer.name); + Assert.IsFalse(renderer.receiveShadows, renderer.name); + } + } + + [Test] + public void CastingAndReceiving_AreSeparateSwitches() + { + var model = ModelWithABox(); + SetShadows(model, cast: false, receive: true); + foreach (var renderer in DrawingRenderers(model)) + { + Assert.AreEqual(ShadowCastingMode.Off, renderer.shadowCastingMode, renderer.name); + Assert.IsTrue(renderer.receiveShadows, renderer.name); + } + SetShadows(model, cast: true, receive: false); + foreach (var renderer in DrawingRenderers(model)) + { + Assert.AreEqual(ShadowCastingMode.On, renderer.shadowCastingMode, renderer.name); + Assert.IsFalse(renderer.receiveShadows, renderer.name); + } + } + + [Test] + public void TurningShadowsBackOn_RestoresThem() + { + var model = ModelWithABox(); + SetShadows(model, cast: false, receive: false); + SetShadows(model, cast: true, receive: true); + foreach (var renderer in DrawingRenderers(model)) + { + Assert.AreEqual(ShadowCastingMode.On, renderer.shadowCastingMode, renderer.name); + Assert.IsTrue(renderer.receiveShadows, renderer.name); + } + } + } +} diff --git a/Components/Tests/ChiselModelShadowTests.cs.meta b/Components/Tests/ChiselModelShadowTests.cs.meta new file mode 100644 index 00000000..b296ff44 --- /dev/null +++ b/Components/Tests/ChiselModelShadowTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 82e5927c84d521f4180095d038ace263 \ No newline at end of file diff --git a/Components/Tests/ChiselPlayModeResetTests.cs b/Components/Tests/ChiselPlayModeResetTests.cs new file mode 100644 index 00000000..dfc1b7bf --- /dev/null +++ b/Components/Tests/ChiselPlayModeResetTests.cs @@ -0,0 +1,152 @@ +using System.Reflection; +using Chisel.Core; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + // Entering play mode without a domain reload (Enter Play Mode Options) runs the managers' ResetState methods on + // statics that are still there; the editor has to go on working after them + [TestFixture] + public class ChiselPlayModeResetTests + { + (UnityEngine.SceneManagement.Scene scene, bool wasDirty) sceneAtStart; + GameObject root; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + sceneAtStart = SceneLeftClean.Remember(); + } + + [TearDown] + public void TearDown() + { + RestorePools(); + if (root) + Object.DestroyImmediate(root); + Update(); + ChiselUnityVisibilityManager.UpdateVisibility(force: true); + SceneLeftClean.Restore(sceneAtStart); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + // What Unity calls on entering play mode + static void ResetState(System.Type manager) + { + var method = manager.GetMethod("ResetState", BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(method, Is.Not.Null, manager.Name + ".ResetState"); + try { method.Invoke(null, null); } + catch (TargetInvocationException e) { throw e.InnerException; } + } + + // Every generator type's pool registered and allocated again, whatever a reset left of it + static void RestorePools() + { + foreach (var type in typeof(ChiselGeneratorComponent).Assembly.GetTypes()) + { + if (type.IsAbstract || !typeof(ChiselGeneratorComponent).IsAssignableFrom(type)) + continue; + for (var t = type; t != null; t = t.BaseType) + { + if (t.ContainsGenericParameters) + continue; + var field = t.GetField("s_JobPool", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly); + if (field?.GetValue(null) is IGeneratorJobPool pool) + { + GeneratorJobPoolManager.Register(pool); + pool.AllocateOrClear(); + } + } + } + } + + static int RegisteredPools() + { + var instance = typeof(GeneratorJobPoolManager).GetProperty("Instance", BindingFlags.Public | BindingFlags.Static).GetValue(null); + var pools = typeof(GeneratorJobPoolManager).GetField("generatorPools", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(instance); + return (int)pools.GetType().GetProperty("Count").GetValue(pools); + } + + // Everything the model draws + static Bounds Drawn(ChiselModelComponent model) + { + var bounds = default(Bounds); + var any = false; + foreach (var r in model.generated.renderables) + { + if (r == null || !r.Valid || r.sharedMesh == null || r.sharedMesh.vertexCount == 0) + continue; + if (any) bounds.Encapsulate(r.sharedMesh.bounds); else bounds = r.sharedMesh.bounds; + any = true; + } + return bounds; + } + + ChiselModelComponent Model(out ChiselBoxComponent box) + { + root = new GameObject("play mode reset test"); + var model = root.AddComponent(); + // generators of three types, with a pool per type + box = new GameObject("Box").AddComponent(); + box.transform.SetParent(root.transform, false); + var cylinder = new GameObject("Cylinder").AddComponent(); + cylinder.transform.SetParent(root.transform, false); + cylinder.transform.localPosition = new Vector3(4, 0, 0); + var stairs = new GameObject("Stairs").AddComponent(); + stairs.transform.SetParent(root.transform, false); + stairs.transform.localPosition = new Vector3(-4, 0, 0); + Update(); + return model; + } + + [Test] + public void ResettingTheGeneratorPools_DoesNotThrow() + { + Model(out _); + Assert.That(RegisteredPools(), Is.GreaterThanOrEqualTo(2), "one pool can't show a pool unregistering itself while they are gone through"); + Assert.DoesNotThrow(() => ResetState(typeof(GeneratorJobPoolManager))); + } + + [Test] + public void ResettingTheGeneratorPools_KeepsThemRegistered() + { + Model(out _); + var pools = RegisteredPools(); + try { ResetState(typeof(GeneratorJobPoolManager)); } catch { } + Assert.That(RegisteredPools(), Is.EqualTo(pools), "a pool that isn't registered never runs again"); + } + + [Test] + public void AfterResettingTheGeneratorPools_AnEditedGeneratorIsBuilt() + { + var model = Model(out var box); + Assert.That(Drawn(model).size.x, Is.GreaterThan(0), "the model draws nothing to start with"); + try { ResetState(typeof(GeneratorJobPoolManager)); } catch { } + + var min = box.Min; + min.y -= 10; + box.Min = min; + Update(); + Assert.That(Drawn(model).min.y, Is.EqualTo(min.y).Within(1e-3f), "the box grew 10 m downwards"); + } + + // Its lookup tables don't exist yet after a domain reload, until visibility is first updated + [Test] + public void ResettingTheVisibilityManager_BeforeItsTablesExist_DoesNotThrow() + { + typeof(ChiselUnityVisibilityManager).GetMethod("OnBeforeAssemblyReload", BindingFlags.NonPublic | BindingFlags.Static).Invoke(null, null); + Assert.DoesNotThrow(() => ResetState(typeof(ChiselUnityVisibilityManager))); + } + } +} diff --git a/Components/Tests/ChiselPlayModeResetTests.cs.meta b/Components/Tests/ChiselPlayModeResetTests.cs.meta new file mode 100644 index 00000000..d2961e24 --- /dev/null +++ b/Components/Tests/ChiselPlayModeResetTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 547b757e93a947ec94495463061bb686 diff --git a/Components/Tests/ChiselRenderLightmapTests.cs b/Components/Tests/ChiselRenderLightmapTests.cs new file mode 100644 index 00000000..0641443e --- /dev/null +++ b/Components/Tests/ChiselRenderLightmapTests.cs @@ -0,0 +1,62 @@ +using Chisel.Core; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselRenderLightmapTests + { + [Test] + public void ARendererTheBakeOnlyTookIntoAccount_DrawsWithoutALightmap() + { + DrawsWithoutALightmap(0xFFFE); + } + + [Test] + public void AnIndexPastTheBakedLightmaps_DrawsWithoutALightmap() + { + DrawsWithoutALightmap(LightmapSettings.lightmaps.Length); + } + + [Test] + public void ARendererThatWasNeverBaked_DrawsWithoutALightmap() + { + DrawsWithoutALightmap(-1); + } + + static void DrawsWithoutALightmap(int lightmapIndex) + { + var root = new GameObject("Lightmapped Model"); + root.SetActive(false); + var material = new Material(Shader.Find("Universal Render Pipeline/Lit")); + ChiselRenderObjects renderable = null; + try + { + var camera = new GameObject("Camera", typeof(Camera)).GetComponent(); + camera.transform.SetParent(root.transform, false); + camera.enabled = false; + + renderable = ChiselRenderObjects.Create("Renderable", root.transform, GameObjectState.Create(root), SurfaceDestinationFlags.Renderable); + renderable.sharedMesh.SetVertices(new[] { Vector3.zero, Vector3.right, Vector3.up }); + renderable.sharedMesh.SetTriangles(new[] { 0, 1, 2 }, 0); + renderable.renderMaterials = new[] { material }; + renderable.meshRenderer.enabled = true; + renderable.meshRenderer.forceRenderingOff = true; + renderable.meshRenderer.lightmapIndex = lightmapIndex; + Assert.IsTrue(renderable.IsEnabled()); + material.EnableKeyword("LIGHTMAP_ON"); + + Assert.DoesNotThrow(() => ChiselRenderObjects.RenderChiselRenderObjects(new[] { renderable }, new MaterialPropertyBlock(), + Matrix4x4.identity, 0, camera, hasLightmaps: true)); + Assert.IsFalse(material.IsKeywordEnabled("LIGHTMAP_ON"), "drawn with a lightmap it doesn't have"); + } + finally + { + renderable?.Destroy(); + Object.DestroyImmediate(root); + Object.DestroyImmediate(material); + } + } + } +} diff --git a/Components/Tests/ChiselRenderLightmapTests.cs.meta b/Components/Tests/ChiselRenderLightmapTests.cs.meta new file mode 100644 index 00000000..624cdba2 --- /dev/null +++ b/Components/Tests/ChiselRenderLightmapTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 50ac1e83b8cbdaa4794d7f1df11026d6 \ No newline at end of file diff --git a/Components/Tests/ChiselSavedOutputTests.cs b/Components/Tests/ChiselSavedOutputTests.cs new file mode 100644 index 00000000..bec7cf95 --- /dev/null +++ b/Components/Tests/ChiselSavedOutputTests.cs @@ -0,0 +1,656 @@ +using System; +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselSavedOutputTests + { + const int kModelCount = 2; + + // A rebuild recomputes the bounds of every mesh it writes, so bounds this far off only survive when it didn't run + static readonly Bounds kMarker = new(new Vector3(1000, 1000, 1000), new Vector3(1, 1, 1)); + + readonly List roots = new(); + readonly List models = new(); + readonly List boxes = new(); + + [SetUp] + public void SetUp() + { + if (UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + + for (int i = 0; i < kModelCount; i++) + { + var root = new GameObject("Model " + i); + roots.Add(root); + models.Add(root.AddComponent()); + var boxObject = new GameObject("Box"); + boxObject.transform.SetParent(root.transform, false); + boxObject.transform.localPosition = new Vector3(i * 4, 0, 0); + var box = boxObject.AddComponent(); + box.OnValidate(); + boxes.Add(box); + } + Update(); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + UnityEngine.Object.DestroyImmediate(root); + } + roots.Clear(); + models.Clear(); + boxes.Clear(); + Update(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + // What saving the scene does + void Save() + { + ChiselModelManager.Instance.StoreInputHashes(roots[0].scene); + } + + static ChiselRenderObjects RenderableWithGeometry(ChiselModelComponent model) + { + foreach (var renderable in model.generated.renderables) + { + if (renderable != null && !renderable.invalid && renderable.sharedMesh.vertexCount > 0) + return renderable; + } + Assert.Fail($"{model.name} has no geometry"); + return null; + } + + void MarkSavedMeshes() + { + foreach (var model in models) + RenderableWithGeometry(model).sharedMesh.bounds = kMarker; + } + + static bool IsMarked(ChiselModelComponent model) + { + return RenderableWithGeometry(model).sharedMesh.bounds == kMarker; + } + + // What opening the scene again leaves: every node registered anew, and the renderables without what the scene + // doesn't keep, the meshes that are never saved and what only lasts a session + void Reopen(Action whileClosed = null) + { + foreach (var root in roots) + root.SetActive(false); + Update(); + whileClosed?.Invoke(); + foreach (var model in models) + { + foreach (var renderable in model.generated.renderables) + Unload(renderable); + foreach (var renderable in model.generated.debugVisualizationRenderables) + Unload(renderable); + } + foreach (var root in roots) + root.SetActive(true); + Update(); + } + + static void Unload(ChiselRenderObjects renderable) + { + if (renderable == null || renderable.invalid) + return; + UnityEngine.Object.DestroyImmediate(renderable.partialMesh); + UnityEngine.Object.DestroyImmediate(renderable.selectionMesh); + renderable.partialMesh = null; + renderable.selectionMesh = null; + renderable.triangleBrushes.selectionIndexDescriptions = Array.Empty(); + renderable.triangleBrushes.perTriangleNodeIDLookup = Array.Empty(); + renderable.triangleBrushes.validTriangleCount = 0; + } + + [Test] + public void AnUnchangedModel_KeepsItsSavedMeshes() + { + Save(); + MarkSavedMeshes(); + Reopen(); + foreach (var model in models) + { + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(model.Node), Is.True, model.name); + Assert.That(IsMarked(model), Is.True, $"{model.name} was built again"); + } + } + + [Test] + public void AModelThatKeptItsMeshes_CanBePicked() + { + Save(); + Reopen(); + for (int i = 0; i < kModelCount; i++) + { + var renderable = RenderableWithGeometry(models[i]); + var lookup = renderable.triangleBrushes; + var boxEntityID = UnityEngine.EntityId.ToULong(boxes[i].GetEntityId()); + Assert.That(lookup.selectionIndexDescriptions, Is.Not.Empty, "no selection ids"); + foreach (var description in lookup.selectionIndexDescriptions) + Assert.That(description.entityID, Is.EqualTo(boxEntityID), "a selection id picks something else"); + Assert.That(boxes[i].TopTreeNode.Valid, Is.False, + "reading the picking data built the node graph, and a picking render reads it for every " + + "model on screen - the skip would be gone at the first mouse move"); + } + } + + [Test] + public void PickingASurfaceOnAModelThatKeptItsMeshes_BuildsThatModel() + { + Save(); + MarkSavedMeshes(); + Reopen(); + Assume.That(boxes[0].TopTreeNode.Valid, Is.False, "nothing was skipped, so there is no door to go through"); + + var renderable = RenderableWithGeometry(models[0]); + var description = renderable.DescriptionForPicking(models[0], 0); + Update(); + + Assert.That(boxes[0].TopTreeNode.Valid, Is.True, "the picked model was not built"); + var boxNodeID = CompactHierarchyManager.GetCompactNodeID(boxes[0].TopTreeNode); + Assert.That(description.brushNodeID, Is.EqualTo(boxNodeID), "the picked surface is on another brush"); + + var lookup = RenderableWithGeometry(models[0]).triangleBrushes; + Assert.That(lookup.validTriangleCount, Is.GreaterThan(0), "no triangles"); + for (int n = 0; n < lookup.validTriangleCount; n++) + Assert.That(lookup.perTriangleNodeIDLookup[n], Is.EqualTo(boxNodeID), $"triangle {n} is on another brush"); + + // Per model: picking in one model mustn't build the others + Assert.That(boxes[1].TopTreeNode.Valid, Is.False, "picking one model built another one too"); + Assert.That(IsMarked(models[1]), Is.True, "the model nobody picked was built"); + } + + [Test] + public void TheBrushesOfAModelThatKeptItsMeshes_HaveOutlines() + { + Save(); + Reopen(); + var found = new HashSet(); + foreach (var box in boxes) + { + found.Clear(); + ChiselModelManager.Instance.GetAllTreeBrushes(box, found); + Assert.That(found, Is.Not.Empty, box.name + " has no brushes to outline"); + foreach (var brush in found) + Assert.That(brush.Outline.IsCreated, Is.True, box.name); + } + } + + // An incremental update needs what building the whole model leaves behind, so the first one builds all of it + [Test] + public void AModelThatKeptItsMeshes_IsBuiltWhenItChanges() + { + Save(); + MarkSavedMeshes(); + Reopen(); + boxes[0].transform.localPosition += new Vector3(0, 1, 0); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(boxes[0]); + Update(); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[0].Node), Is.False); + Assert.That(IsMarked(models[0]), Is.False, "the changed model wasn't built"); + Assert.That(IsMarked(models[1]), Is.True, "the model that didn't change was built too"); + } + + [Test] + public void AModelThatChangedWhileClosed_IsBuiltWhenItOpens() + { + Save(); + MarkSavedMeshes(); + Reopen(whileClosed: () => boxes[0].transform.localPosition += new Vector3(0, 1, 0)); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[0].Node), Is.False); + Assert.That(IsMarked(models[0]), Is.False, "the changed model kept its old meshes"); + Assert.That(IsMarked(models[1]), Is.True); + } + + [Test] + public void AModelSavedWithoutAHash_IsBuiltWhenItOpens() + { + MarkSavedMeshes(); + Reopen(); + foreach (var model in models) + Assert.That(IsMarked(model), Is.False, model.name); + } + + [Test] + public void AMoveTheTreeHasNotSeenYet_IsBuiltBeforeTheSaveDescribesIt() + { + Save(); + MarkSavedMeshes(); + boxes[0].transform.localPosition += new Vector3(0, 1, 0); + // What a move in the editor does (ChiselUnityEventsManager): queued, passed on by the next update + ChiselNodeHierarchyManager.NotifyTransformationChanged(new HashSet { boxes[0] }); + Save(); + Reopen(); + Assert.That(IsMarked(models[0]), Is.False, "the moved box's model kept the meshes of where the box was"); + Assert.That(IsMarked(models[1]), Is.True, "the model that did not change was built too"); + } + + [Test] + public void ABrushAddedButNotYetRegistered_IsBuiltBeforeTheSaveDescribesIt() + { + Save(); + MarkSavedMeshes(); + var added = new GameObject("Added box"); // a child of model 0, destroyed with it in TearDown + added.transform.SetParent(roots[0].transform, false); + added.transform.localPosition = new Vector3(0, 2, 0); + added.AddComponent().OnValidate(); // its registration waits in the queue + Save(); + Reopen(); + Assert.That(IsMarked(models[0]), Is.False, "the model kept meshes without the box added to it"); + Assert.That(IsMarked(models[1]), Is.True, "the model that did not change was built too"); + } + + [Test] + public void AModelBuiltOnDemand_HasItsBrushesWhereTheyAre() + { + // where the box's brush is, and where the CSG draws it, when the model is built the ordinary way + var expectedMatrix = ((CSGTreeBrush)boxes[1].TopTreeNode).NodeToTreeSpaceMatrix; + var expectedCenter = RenderableWithGeometry(models[1]).sharedMesh.bounds.center; + Assume.That(expectedMatrix.c3.x, Is.EqualTo(4f), "the box is not where the fixture put it"); + + Save(); + Reopen(); + Assume.That(boxes[1].TopTreeNode.Valid, Is.False, "nothing was skipped, so nothing is built on demand"); + + ChiselModelManager.BuildTheNodesOfNow(models[1]); + Assert.That(boxes[1].TopTreeNode.Valid, Is.True, "the model was not built"); + Assert.That(((CSGTreeBrush)boxes[1].TopTreeNode).NodeToTreeSpaceMatrix, Is.EqualTo(expectedMatrix), + "the box's brush is not where the box is"); + + // and what its CSG draws once it runs + boxes[1].TopTreeNode.SetDirty(); + Update(); + Assert.That(RenderableWithGeometry(models[1]).sharedMesh.bounds.center, Is.EqualTo(expectedCenter), + "the model's CSG drew the box somewhere else than the box is"); + } + + // Queries read what the CSG built besides the meshes, so a model that kept its meshes is built first + [Test] + public void AQuery_BuildsTheModelsThatKeptTheirMeshes() + { + Save(); + Reopen(); + var center = RenderableWithGeometry(models[0]).sharedMesh.bounds.center; // model 0 sits at the origin + var found = new List(); + Assert.That(ChiselSceneQuery.FindFirstWorldIntersection(found, center + new Vector3(0, 10, 0), center - new Vector3(0, 10, 0)), + Is.True, "the ray missed the box"); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[0].Node), Is.False); + } + + // What the selection ids of a model's renderables pick + static HashSet SelectionEntities(ChiselModelComponent model) + { + var found = new HashSet(); + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.invalid) + continue; + foreach (var description in renderable.triangleBrushes.selectionIndexDescriptions) + found.Add(description.entityID); + } + return found; + } + + // A decal's surfaces select the decal, not the generator of the brush they lie on, and the scene keeps only + // those selection ids apart from the rest + [Test] + public void ADecalOnAModelThatKeptItsMeshes_IsStillSelected() + { + var decalObject = new GameObject("Decal"); + var material = new Material(Shader.Find("Hidden/InternalErrorShader")); + try + { + // Looking down onto the top face of the box, which is the unit cube at the model's origin + decalObject.transform.SetParent(roots[0].transform, false); + decalObject.transform.localPosition = new Vector3(0.5f, 1.0f, 0.5f); + decalObject.transform.localRotation = Quaternion.Euler(90, 0, 0); + var decal = decalObject.AddComponent(); + decal.Material = material; + decal.Size = new Vector3(0.5f, 0.5f, 0.5f); + Update(); + + var decalEntity = UnityEngine.EntityId.ToULong(decal.GetEntityId()); + Assert.That(SelectionEntities(models[0]), Does.Contain(decalEntity), "the decal drew no surfaces"); + var before = SelectionEntities(models[0]); + + Save(); + Reopen(); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[0].Node), Is.True); + Assert.That(SelectionEntities(models[0]), Is.EquivalentTo(before), "other things are selected now"); + } + finally + { + UnityEngine.Object.DestroyImmediate(decalObject); + UnityEngine.Object.DestroyImmediate(material); + Update(); + } + } + + [Test] + public void OnlyTheRenderersOfSurfacesInTheBakedLighting_GoIntoABake() + { + foreach (var root in roots) + UnityEditor.GameObjectUtility.SetStaticEditorFlags(root, UnityEditor.StaticEditorFlags.ContributeGI); + ChiselNodeHierarchyManager.Rebuild(); + Update(); + AssertOnlyTheRenderersInTheBakeContributeGI("after a build"); + + Save(); + MarkSavedMeshes(); + Reopen(whileClosed: () => + { + foreach (var model in models) + { + foreach (var renderable in model.generated.renderables) + { + if (renderable != null && !renderable.invalid) + UnityEditor.GameObjectUtility.SetStaticEditorFlags(renderable.container, UnityEditor.StaticEditorFlags.ContributeGI); + } + } + }); + foreach (var model in models) + Assert.That(IsMarked(model), Is.True, $"{model.name} was built again"); + AssertOnlyTheRenderersInTheBakeContributeGI("after the model kept its meshes"); + } + + [Test] + public void AnEmptyRenderer_IsNotInTheBake_EvenWhenItsSurfacesWouldBe() + { + foreach (var root in roots) + UnityEditor.GameObjectUtility.SetStaticEditorFlags(root, UnityEditor.StaticEditorFlags.ContributeGI); + ChiselNodeHierarchyManager.Rebuild(); + Update(); + + var model = models[0]; + var renderable = RenderableWithGeometry(model); + Assume.That(renderable.HasGeometry, Is.True, "the fixture has to start with a slot that does have geometry"); + Assume.That(renderable.query & SurfaceDestinationFlags.ExcludedFromGlobalIllumination, + Is.EqualTo(SurfaceDestinationFlags.None), "and whose surfaces would otherwise be in the bake"); + Assert.That(UnityEditor.GameObjectUtility.GetStaticEditorFlags(renderable.container) & UnityEditor.StaticEditorFlags.ContributeGI, + Is.EqualTo(UnityEditor.StaticEditorFlags.ContributeGI), "it should be in the bake while it has geometry"); + + // Empty it, exactly as a slot that generated nothing comes out, and let the containers be written again + var had = renderable.sharedMesh; + renderable.sharedMesh = new Mesh { name = "empty" }; + try + { + Assume.That(renderable.HasGeometry, Is.False, "the slot is empty now"); + model.generated.UpdateContainersWhenModelStateChanged(model); + // UpdateContainers is driven by the model's state, which an emptied mesh does not change; toggling a + // flag makes it run, and is what any real edit would do + UnityEditor.GameObjectUtility.SetStaticEditorFlags(model.gameObject, + UnityEditor.StaticEditorFlags.ContributeGI | UnityEditor.StaticEditorFlags.BatchingStatic); + model.generated.UpdateContainersWhenModelStateChanged(model); + + Assert.That(UnityEditor.GameObjectUtility.GetStaticEditorFlags(renderable.container) & UnityEditor.StaticEditorFlags.ContributeGI, + Is.EqualTo(default(UnityEditor.StaticEditorFlags)), + "an empty renderer must not be in the bake: Unity would trace rays against an empty mesh"); + } + finally + { + var empty = renderable.sharedMesh; + renderable.sharedMesh = had; + if (empty) UnityEngine.Object.DestroyImmediate(empty); + } + } + + void AssertOnlyTheRenderersInTheBakeContributeGI(string when) + { + foreach (var model in models) + { + Assert.That(RenderableWithGeometry(model).query & SurfaceDestinationFlags.ExcludedFromGlobalIllumination, + Is.EqualTo(SurfaceDestinationFlags.None), $"a box's surfaces take part in the baked lighting ({when})"); + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.invalid) + continue; + var inTheBake = renderable.HasGeometry && + (renderable.query & SurfaceDestinationFlags.ExcludedFromGlobalIllumination) == SurfaceDestinationFlags.None; + var staticFlags = UnityEditor.GameObjectUtility.GetStaticEditorFlags(renderable.container); + var contributesGI = (staticFlags & UnityEditor.StaticEditorFlags.ContributeGI) == UnityEditor.StaticEditorFlags.ContributeGI; + Assert.That(contributesGI, Is.EqualTo(inTheBake), $"{renderable.container.name} of {model.name} {when}"); + } + } + } + + [Test] + public void TheRenderersThatOnlyCastShadows_NeitherOccludeNorAreOccluded() + { + const UnityEditor.StaticEditorFlags kOcclusion = UnityEditor.StaticEditorFlags.OccluderStatic | + UnityEditor.StaticEditorFlags.OccludeeStatic; + foreach (var root in roots) + UnityEditor.GameObjectUtility.SetStaticEditorFlags(root, kOcclusion); + ChiselNodeHierarchyManager.Rebuild(); + Update(); + + foreach (var model in models) + { + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.invalid) + continue; + var isDrawn = (renderable.query & SurfaceDestinationFlags.Renderable) == SurfaceDestinationFlags.Renderable; + var staticFlags = UnityEditor.GameObjectUtility.GetStaticEditorFlags(renderable.container); + Assert.That(staticFlags & kOcclusion, Is.EqualTo(isDrawn ? kOcclusion : 0), + $"{renderable.container.name} of {model.name}"); + } + } + } + + [Test] + public void AModelMarkedContributeGI_PutsItsRenderersInTheBake_WithoutBuildingAgain() + { + Save(); + MarkSavedMeshes(); + foreach (var root in roots) + UnityEditor.GameObjectUtility.SetStaticEditorFlags(root, UnityEditor.StaticEditorFlags.ContributeGI); + Update(); + + foreach (var model in models) + { + Assert.That(IsMarked(model), Is.True, $"{model.name} was built again; the flags should not need it"); + var renderable = RenderableWithGeometry(model); + var staticFlags = UnityEditor.GameObjectUtility.GetStaticEditorFlags(renderable.container); + Assert.That(staticFlags & UnityEditor.StaticEditorFlags.ContributeGI, + Is.EqualTo(UnityEditor.StaticEditorFlags.ContributeGI), renderable.container.name); + } + } + + // ... and taking it away again reaches them too + [Test] + public void AModelNoLongerContributingGI_TakesItsRenderersOutOfTheBake() + { + foreach (var root in roots) + UnityEditor.GameObjectUtility.SetStaticEditorFlags(root, UnityEditor.StaticEditorFlags.ContributeGI); + Update(); + foreach (var root in roots) + UnityEditor.GameObjectUtility.SetStaticEditorFlags(root, 0); + Update(); + + foreach (var model in models) + { + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.invalid || !renderable.container) + continue; + var staticFlags = UnityEditor.GameObjectUtility.GetStaticEditorFlags(renderable.container); + // Against the enum, not 0: NUnit compares an enum with an int as unequal + Assert.That(staticFlags & UnityEditor.StaticEditorFlags.ContributeGI, + Is.EqualTo(default(UnityEditor.StaticEditorFlags)), renderable.container.name); + } + } + } + + // The model's layer had the same gap, and is the case nobody had noticed + [Test] + public void AModelOnANewLayer_MovesItsGeneratedObjects_WithoutBuildingAgain() + { + Save(); + MarkSavedMeshes(); + const int kLayer = 5; // UI, which every project has + foreach (var root in roots) + root.layer = kLayer; + Update(); + + foreach (var model in models) + { + Assert.That(IsMarked(model), Is.True, $"{model.name} was built again; a layer should not need it"); + Assert.That(RenderableWithGeometry(model).container.layer, Is.EqualTo(kLayer)); + } + } + + // The hash taken from the components survives closing and opening the scene + [Test] + public void TheHashTakenFromTheComponents_IsTheSameAfterAReopen() + { + Save(); + var stored = new List(); + foreach (var model in models) + { + Assert.That(model.generated.componentInputHash.isValid, Is.True, $"{model.name} saved no component hash"); + stored.Add(model.generated.componentInputHash); + } + + Reopen(); + for (int i = 0; i < models.Count; i++) + { + Assert.That(ComponentHash(models[i]), Is.EqualTo(stored[i]), $"{models[i].name}'s components hash differently now"); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[i].Node), Is.True, $"{models[i].name} was built"); + } + } + + // ... and the same as the tree's hash about a model that did change + [Test] + public void TheHashTakenFromTheComponents_AlsoNoticesAModelThatChangedWhileClosed() + { + Save(); + var unchanged = models[1].generated.componentInputHash; + var changed = models[0].generated.componentInputHash; + + Reopen(whileClosed: () => boxes[0].transform.localPosition += new Vector3(0, 1, 0)); + + Assert.That(ComponentHash(models[0]), Is.Not.EqualTo(changed), "the moved brush didn't reach the component hash"); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[0].Node), Is.False, "the changed model kept its meshes"); + Assert.That(ComponentHash(models[1]), Is.EqualTo(unchanged), "the model that didn't change hashes differently"); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[1].Node), Is.True, "the model that didn't change was built"); + } + + static Hash128 ComponentHash(ChiselModelComponent model) + { + return ChiselModelManager.GetComponentInputHash(model); + } + + [Test] + public void AModelWithoutATreeHash_StillKeepsItsMeshes() + { + Save(); + models[0].generated.inputHash = default; + MarkSavedMeshes(); + + Reopen(); + + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(models[0].Node), Is.True, "the tree hash still decides"); + Assert.That(IsMarked(models[0]), Is.True, "the model was built even though its input didn't change"); + } + + [Test] + public void SavingAgain_KeepsTheHashOfAModelThatKeptItsMeshes() + { + Save(); + Reopen(); + Assume.That(models[0].generated.componentInputHash.isValid, Is.True, "the first save stored a hash"); + + Save(); // what the next session's save does, to a model that has just kept its meshes + foreach (var model in models) + Assert.That(model.generated.componentInputHash.isValid, Is.True, $"{model.name} lost its hash on the second save"); + + MarkSavedMeshes(); + Reopen(); + Assert.That(IsMarked(models[0]), Is.True, "built again after a second save, although nothing changed"); + } + + [Test] + public void AModelThatKeepsItsMeshes_BuildsNoNodesAtAll() + { + Save(); + MarkSavedMeshes(); + Reopen(); + Assume.That(IsMarked(models[0]), Is.True, "the model kept its saved meshes"); + + Assert.That(boxes[0].TopTreeNode.Valid, Is.False, + "the generator's CSG node was built for a model that kept its meshes: stages 1 and 2 ran to " + + "answer a question the stored hash had already answered"); + } + + [Test] + public void AModelThatSkippedItsNodes_ComesBackWholeWhenItChanges() + { + Save(); + Reopen(); + Assume.That(boxes[0].TopTreeNode.Valid, Is.False, "nothing was skipped, so there is nothing to recover"); + var expected = RenderableWithGeometry(models[1]).sharedMesh.vertexCount; + Assume.That(expected, Is.GreaterThan(0), "the fixture has to start with geometry to lose"); + + boxes[0].transform.localPosition += new Vector3(0, 1, 0); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(boxes[0]); + Update(); + + Assert.That(boxes[0].TopTreeNode.Valid, Is.True, "the edit never reached the generator, which has no node"); + Assert.That(RenderableWithGeometry(models[0]).sharedMesh.vertexCount, Is.EqualTo(expected), + "the model came back with the wrong amount of geometry: its CSG ran on a tree whose children " + + "were never built"); + } + + [Test] + public void AModelAskedToBuild_KeepsItsMeshesUntilItsNodesExist() + { + Save(); + MarkSavedMeshes(); + Reopen(); + Assume.That(boxes[0].TopTreeNode.Valid, Is.False, "nothing was skipped, so there is no in-between"); + var expected = RenderableWithGeometry(models[0]).sharedMesh.vertexCount; + + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(boxes[0]); + ChiselModelManager.Instance.UpdateModels(); // no hierarchy update: nothing has built the children + + Assert.That(RenderableWithGeometry(models[0]).sharedMesh.vertexCount, Is.EqualTo(expected), + "the CSG ran on a tree whose children were never built and emptied the model"); + + Update(); // and now the ordinary path gets to it + Assert.That(boxes[0].TopTreeNode.Valid, Is.True, "the model never got its nodes at all"); + Assert.That(IsMarked(models[0]), Is.False, "the edit was dropped: the model still has its saved meshes"); + } + + // Rebuilding is asking for everything to be built + [Test] + public void ARebuild_BuildsEveryModel() + { + Save(); + MarkSavedMeshes(); + ChiselNodeHierarchyManager.Rebuild(); + foreach (var model in models) + Assert.That(IsMarked(model), Is.False, model.name); + } + } +} diff --git a/Components/Tests/ChiselSavedOutputTests.cs.meta b/Components/Tests/ChiselSavedOutputTests.cs.meta new file mode 100644 index 00000000..f09a70d7 --- /dev/null +++ b/Components/Tests/ChiselSavedOutputTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 988921567cdc4e99ba1b773aa3e26e3c \ No newline at end of file diff --git a/Components/Tests/ChiselSceneLoadTests.cs b/Components/Tests/ChiselSceneLoadTests.cs new file mode 100644 index 00000000..52683451 --- /dev/null +++ b/Components/Tests/ChiselSceneLoadTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselSceneLoadTests + { + const int kModelCount = 4; + + readonly List roots = new(); + readonly List models = new(); + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + + for (int i = 0; i < kModelCount; i++) + { + var root = new GameObject("Model " + i); + roots.Add(root); + models.Add(root.AddComponent()); + var boxObject = new GameObject("Box"); + boxObject.transform.SetParent(root.transform, false); + boxObject.AddComponent().OnValidate(); + } + Update(); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + models.Clear(); + Update(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + // What opening the scene again leaves: every node registers anew, and the renderables come back without the + // meshes that were never saved + void ReopenScene() + { + foreach (var root in roots) + root.SetActive(false); + Update(); + foreach (var model in models) + { + foreach (var renderable in model.generated.renderables) + DropUnsavedMeshes(renderable); + foreach (var renderable in model.generated.debugVisualizationRenderables) + DropUnsavedMeshes(renderable); + } + foreach (var root in roots) + root.SetActive(true); + Update(); + } + + static void DropUnsavedMeshes(ChiselRenderObjects renderable) + { + if (renderable == null || renderable.invalid) + return; + Object.DestroyImmediate(renderable.partialMesh); + Object.DestroyImmediate(renderable.selectionMesh); + renderable.partialMesh = null; + renderable.selectionMesh = null; + } + + static int IndexCount(Mesh mesh) + { + var count = 0; + for (int subMesh = 0; subMesh < mesh.subMeshCount; subMesh++) + count += (int)mesh.GetIndexCount(subMesh); + return count; + } + + [Test] + public void ReopeningAScene_LooksUpTheBrushesVisibilityAtMostOnce() + { + var before = ChiselUnityVisibilityManager.FullUpdateCount; + ReopenScene(); + var fullUpdates = ChiselUnityVisibilityManager.FullUpdateCount - before; + Assert.That(fullUpdates, Is.LessThanOrEqualTo(1), + $"reopening {kModelCount} models looked up the visibility of every brush {fullUpdates} times"); + } + + // The scene view draws a model through its partial meshes, which only keep the triangles of visible brushes, + // so they must come back whole + [Test] + public void ReopeningAScene_GivesEveryRenderableItsPartialMeshBack() + { + ReopenScene(); + foreach (var model in models) + { + var drawn = 0; + foreach (var renderable in model.generated.renderables) + { + if (renderable == null || renderable.invalid || renderable.sharedMesh.vertexCount == 0) + continue; + Assert.IsNotNull(renderable.partialMesh, renderable.container.name); + Assert.AreEqual(IndexCount(renderable.sharedMesh), IndexCount(renderable.DrawnMesh), renderable.container.name); + drawn++; + } + Assert.That(drawn, Is.GreaterThan(0), $"{model.name} draws nothing"); + } + } + } +} diff --git a/Components/Tests/ChiselSceneLoadTests.cs.meta b/Components/Tests/ChiselSceneLoadTests.cs.meta new file mode 100644 index 00000000..40c5d1a9 --- /dev/null +++ b/Components/Tests/ChiselSceneLoadTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1c465e38cf3940e79d0e9557eb3f80ef \ No newline at end of file diff --git a/Components/Tests/ChiselShadowOnlyTests.cs b/Components/Tests/ChiselShadowOnlyTests.cs new file mode 100644 index 00000000..e0bf3a74 --- /dev/null +++ b/Components/Tests/ChiselShadowOnlyTests.cs @@ -0,0 +1,175 @@ +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselShadowOnlyTests + { + const int kShadowOnlyDebugHelper = 2; // ChiselGeneratedObjects.kGeneratedVisualizationRendererNames + + readonly List roots = new(); + int debugVisualizations; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + // the helper is a debug visualization: made while a draw mode shows it + debugVisualizations = CompactHierarchyManager.DebugVisualizations; + CompactHierarchyManager.DebugVisualizations = debugVisualizations | (1 << kShadowOnlyDebugHelper); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + CompactHierarchyManager.DebugVisualizations = debugVisualizations; + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + // A box with its faces on the given materials, in turn + ChiselModelComponent ModelWithABoxOf(params Material[] materials) + { + var root = new GameObject("Shadow-only Model"); + roots.Add(root); + var model = root.AddComponent(); + var boxObject = new GameObject("Box"); + boxObject.transform.SetParent(root.transform, false); + var box = boxObject.AddComponent(); + box.OnValidate(); + + var surfaces = box.surfaceArray.surfaces; + Assert.That(surfaces, Is.Not.Empty); + for (int i = 0; i < surfaces.Length; i++) + surfaces[i] = ChiselSurface.Create(materials[i % materials.Length]); + box.OnValidate(); + Update(); + return model; + } + + // By name: NUnit builds the test cases before Chisel's project settings are loaded, so the materials themselves + // would come out null + static readonly string[] kShadowOnlyMaterials = { "ShadowOnly", "ForceShadowOnly" }; + + static Material ShadowOnlyMaterial(string name) + { + var material = (name == "ShadowOnly") ? ChiselProjectSettings.ShadowOnlySurfacesMaterial + : ChiselProjectSettings.ForceShadowOnlySurfacesMaterial; + Assert.IsNotNull(material, name); + return material; + } + + // Every sub-mesh is drawn with ForceShadowOnly: by the MeshRenderer, and by the editor, which draws the generated + // meshes itself from renderMaterials (ChiselRenderObjects.RenderMesh) + static void AssertCastWithForceShadowOnly(ChiselRenderObjects renderable) + { + var forceShadowOnly = ShadowOnlyMaterial("ForceShadowOnly"); + var subMeshCount = renderable.sharedMesh.subMeshCount; + Assert.That(renderable.sharedMesh.vertexCount, Is.GreaterThan(0), "the shadow-only renderer got no surfaces"); + Assert.IsTrue(renderable.meshRenderer.enabled); + Assert.AreEqual(ShadowCastingMode.On, renderable.meshRenderer.shadowCastingMode, "URP casts nothing from ShadowsOnly"); + Assert.AreEqual(subMeshCount, renderable.meshRenderer.sharedMaterials.Length); + Assert.That(renderable.meshRenderer.sharedMaterials, Is.All.EqualTo(forceShadowOnly)); + Assert.AreEqual(subMeshCount, renderable.renderMaterials.Length); + Assert.That(renderable.renderMaterials, Is.All.EqualTo(forceShadowOnly)); + Assert.IsTrue(renderable.IsRendered(), "the editor doesn't draw the shadow-only renderer"); + } + + static bool IsShadowOnly(ChiselRenderObjects renderable) => + (renderable.query & (SurfaceDestinationFlags.Renderable | SurfaceDestinationFlags.ShadowCasting)) == SurfaceDestinationFlags.ShadowCasting && + (renderable.query & SurfaceDestinationFlags.ExcludedFromGlobalIllumination) == 0; + + // The renderer of the surfaces that only cast shadows (one for all their materials) + static ChiselRenderObjects ShadowOnlyRenderable(ChiselModelComponent model) + { + foreach (var renderable in model.generated.renderables) + { + if (renderable != null && renderable.Valid && IsShadowOnly(renderable)) + return renderable; + } + Assert.Fail("the model has no shadow-only renderer"); + return null; + } + + [Test] + public void AShadowOnlySurface_IsCastWithForceShadowOnly([ValueSource(nameof(kShadowOnlyMaterials))] string materialName) + { + var model = ModelWithABoxOf(ShadowOnlyMaterial(materialName)); + AssertCastWithForceShadowOnly(ShadowOnlyRenderable(model)); + } + + // A shadow-only mesh is drawn with one material, so its surfaces' materials don't split it: one sub-mesh + [Test] + public void ShadowOnlySurfacesOnSeveralMaterials_AreAllCastWithForceShadowOnly() + { + var model = ModelWithABoxOf(ShadowOnlyMaterial("ShadowOnly"), ShadowOnlyMaterial("ForceShadowOnly")); + var renderable = ShadowOnlyRenderable(model); + Assert.AreEqual(1, renderable.sharedMesh.subMeshCount); + AssertCastWithForceShadowOnly(renderable); + } + + [Test] + public void AShadowOnlySurface_IsNotDrawnVisibly([ValueSource(nameof(kShadowOnlyMaterials))] string materialName) + { + var model = ModelWithABoxOf(ShadowOnlyMaterial(materialName)); + for (int i = 0; i < model.generated.renderables.Length; i++) + { + var renderable = model.generated.renderables[i]; + if (renderable == null || !renderable.Valid || IsShadowOnly(renderable)) + continue; + Assert.IsFalse(renderable.meshRenderer.enabled && renderable.sharedMesh.vertexCount > 0, renderable.container.name); + } + } + + [Test] + public void TheShadowOnlyDebugHelper_ShowsTheSurfacesWithTheShadowOnlyMaterial([ValueSource(nameof(kShadowOnlyMaterials))] string materialName) + { + var model = ModelWithABoxOf(ShadowOnlyMaterial(materialName)); + var helper = System.Array.Find(model.generated.debugVisualizationRenderables, r => r != null && r.debugMode == kShadowOnlyDebugHelper); + Assert.That(helper, Is.Not.Null, "the ShadowOnly debug helper was not made"); + Assert.That(helper.sharedMesh.vertexCount, Is.GreaterThan(0), "the ShadowOnly debug helper shows nothing"); + Assert.That(helper.renderMaterials, Is.Not.Empty); + foreach (var helperMaterial in helper.renderMaterials) + Assert.AreSame(ChiselProjectSettings.ShadowOnlySurfacesMaterial, helperMaterial); + } + + [Test] + public void AShadowOnlySurface_Collides([ValueSource(nameof(kShadowOnlyMaterials))] string materialName) + { + var model = ModelWithABoxOf(ShadowOnlyMaterial(materialName)); + var vertexCount = 0; + foreach (var collider in model.generated.colliders) + vertexCount += collider.sharedMesh.vertexCount; + Assert.That(vertexCount, Is.GreaterThan(0), "the shadow-only surfaces got no collider"); + } + + [Test] + public void AModelWithoutShadows_DoesNotCastItsShadowOnlySurfaces() + { + var model = ModelWithABoxOf(ShadowOnlyMaterial("ShadowOnly")); + model.RenderSettings.castShadows = false; + model.SyncModelSettingsStore(); + Update(); + Assert.IsFalse(ShadowOnlyRenderable(model).meshRenderer.enabled); + } + } +} diff --git a/Components/Tests/ChiselShadowOnlyTests.cs.meta b/Components/Tests/ChiselShadowOnlyTests.cs.meta new file mode 100644 index 00000000..4f7f1bf0 --- /dev/null +++ b/Components/Tests/ChiselShadowOnlyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d3adc92ca23a4497be391625f66a1e5a diff --git a/Components/Tests/ChiselTrimDecalTests.cs b/Components/Tests/ChiselTrimDecalTests.cs new file mode 100644 index 00000000..c2948c26 --- /dev/null +++ b/Components/Tests/ChiselTrimDecalTests.cs @@ -0,0 +1,509 @@ +using System.Collections.Generic; +using Chisel.Core; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.TestTools.Utils; + +namespace Chisel.Components.Tests +{ + // Trim decals (ChiselTrimDecals): on the edge between two surfaces, the image's lower half on the first, its upper + // half on the second, each laid flat on its surface + [TestFixture] + public class ChiselTrimDecalTests + { + readonly List created = new(); + Material decalMaterial; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + decalMaterial = new Material(Shader.Find("Standard")) { name = "TrimTestMaterial" }; + created.Add(decalMaterial); + } + + [TearDown] + public void TearDown() + { + foreach (var item in created) + { + if (item) + Object.DestroyImmediate(item); + } + created.Clear(); + Update(); + ChiselDecalComponentTests.DeleteSheets(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselModelComponent Model() + { + var root = new GameObject("trim test"); + created.Add(root); + return root.AddComponent(); + } + + static ChiselBoxComponent Box(ChiselModelComponent model, float3 min, float3 max) + { + var go = new GameObject("Box"); + go.transform.SetParent(model.transform, false); + var box = go.AddComponent(); + if (math.any(min > box.Max)) { box.Max = max; box.Min = min; } + else { box.Min = min; box.Max = max; } + return box; + } + + // The surface (of the generator: its polygon's descriptionIndex) of the box whose outward normal is this one + static int SurfaceFacing(ChiselBoxComponent box, float3 normal) + { + Update(); + var brush = (CSGTreeBrush)box.TopTreeNode; + ref var mesh = ref BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh).Value; + for (int s = 0; s < mesh.polygons.Length; s++) + { + if (math.dot(math.normalize(mesh.localPlanes[s].xyz), normal) > 0.999f) + return mesh.polygons[s].descriptionIndex; + } + Assert.Fail($"the box has no surface facing {normal}"); + return -1; + } + + ChiselDecalComponent Trim(ChiselModelComponent model, ChiselBoxComponent a, int surfaceA, ChiselBoxComponent b, int surfaceB, + float length = 1, float height = 1, ChiselTrimFill fill = ChiselTrimFill.Repeat) + { + var go = new GameObject("Trim"); + go.transform.SetParent(model.transform, false); + var decal = go.AddComponent(); + decal.Material = decalMaterial; + decal.Shape = ChiselDecalShape.Trim; + decal.TrimFill = fill; + decal.Size = new Vector3(length, height, 1); + decal.Transparent = false; + decal.SetTargets(new[] + { + new ChiselDecalSurfaceTarget { brush = a, surfaceIndex = surfaceA }, + new ChiselDecalSurfaceTarget { brush = b, surfaceIndex = surfaceB } + }); + Update(); + return decal; + } + + struct DecalTriangle + { + public float3 a, b, c; + public float2 uvA, uvB, uvC; + public float3 normal; + public double area; + } + + // The triangles drawn with the trim's material + List DecalTriangles(ChiselModelComponent model) + { + var found = new List(); + foreach (var r in model.generated.renderables) + { + if (r == null || !r.Valid || r.sharedMesh == null || r.sharedMesh.vertexCount == 0) + continue; + // every renderer that draws (transparent decals cast no shadows) + if ((r.query & SurfaceDestinationFlags.Renderable) == 0) + continue; + var vertices = r.sharedMesh.vertices; + var uvs = r.sharedMesh.uv; + for (int s = 0; s < r.sharedMesh.subMeshCount; s++) + { + if (r.renderMaterials == null || s >= r.renderMaterials.Length || r.renderMaterials[s] != decalMaterial) + continue; + var triangles = r.sharedMesh.GetTriangles(s); + for (int t = 0; t < triangles.Length; t += 3) + { + var cross = Vector3.Cross(vertices[triangles[t + 1]] - vertices[triangles[t]], vertices[triangles[t + 2]] - vertices[triangles[t]]); + found.Add(new DecalTriangle + { + a = vertices[triangles[t]], b = vertices[triangles[t + 1]], c = vertices[triangles[t + 2]], + uvA = uvs[triangles[t]], uvB = uvs[triangles[t + 1]], uvC = uvs[triangles[t + 2]], + normal = cross.normalized, + area = cross.magnitude * 0.5 + }); + } + } + } + return found; + } + + static double AreaFacing(List triangles, float3 normal) + { + var area = 0.0; + foreach (var t in triangles) + { + if (math.dot(t.normal, normal) > 0.999f) + area += t.area; + } + return area; + } + + const double kTolerance = 1e-4; + + // A 2 m box: the trim on the edge between its top and its +x side, 1 m high, so half a metre on each + [Test] + public void ATrimOnABoxsEdge_LiesHalfOnEachSurface() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0))); + var triangles = DecalTriangles(model); + Assert.That(AreaFacing(triangles, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kTolerance), "2 m along the edge, half a metre into the top"); + Assert.That(AreaFacing(triangles, new float3(1, 0, 0)), Is.EqualTo(1.0).Within(kTolerance), "and half a metre down the side"); + } + + // The image's v is 0.5 on the edge, 0 half its height into the first surface, 1 half its height into the second + [Test] + public void ATrimsImage_RunsOnAcrossTheEdge() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0))); + foreach (var t in DecalTriangles(model)) + { + foreach (var (p, uv) in new[] { (t.a, t.uvA), (t.b, t.uvB), (t.c, t.uvC) }) + { + // the distance from the edge (y = 1, x = 1), across the surface the point is on + var onTop = t.normal.y > 0.5f; + var d = onTop ? 1 - p.x : 1 - p.y; + var expected = onTop ? 0.5f - d : 0.5f + d; + Assert.That(uv.y, Is.EqualTo(expected).Within(1e-4f), $"v at {p} on the {(onTop ? "top" : "side")}"); + } + } + } + + // Mirrored: v is 0.5 on the edge and 0 half its height into either surface + [Test] + public void AMirroredTrim_TakesTheLowerHalfOnBothSurfaces() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + var decal = Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0))); + decal.TrimMirror = true; + Update(); + var triangles = DecalTriangles(model); + Assert.That(AreaFacing(triangles, new float3(1, 0, 0)), Is.EqualTo(1.0).Within(kTolerance), "still half a metre down the side"); + foreach (var t in triangles) + { + foreach (var (p, uv) in new[] { (t.a, t.uvA), (t.b, t.uvB), (t.c, t.uvC) }) + { + var d = t.normal.y > 0.5f ? 1 - p.x : 1 - p.y; + Assert.That(uv.y, Is.EqualTo(0.5f - d).Within(1e-4f), $"v at {p}"); + } + } + } + + // Picking an edge: the nearest edge of the clicked surface, and the surface across it + [Test] + public void TheNearestEdge_OfABoxsTop_LeadsToItsSide() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + var top = SurfaceFacing(box, new float3(0, 1, 0)); + var side = SurfaceFacing(box, new float3(1, 0, 0)); + var brush = (CSGTreeBrush)box.TopTreeNode; + var topPolygon = PolygonOf(brush, top); + Assert.That(ChiselTrimDecals.NearestEdge(brush, topPolygon, new float3(0.9f, 1, 0.2f), out var from, out var to, out var across), Is.True); + Assert.That(math.min(from.z, to.z), Is.EqualTo(-1).Within(1e-5f)); + Assert.That(math.max(from.z, to.z), Is.EqualTo(1).Within(1e-5f)); + Assert.That(from.x, Is.EqualTo(1).Within(1e-5f)); + Assert.That(from.y, Is.EqualTo(1).Within(1e-5f)); + Assert.That(ChiselTrimDecals.SurfaceOf(brush, across), Is.EqualTo(side), "the side across the edge"); + } + + [Test] + public void AFloorsPlane_HoldsTheEdgeOfAWallOnIt() + { + var model = Model(); + var floor = Box(model, new float3(-4, -1, -4), new float3(4, 0, 4)); + Box(model, new float3(-1, 0, -0.25f), new float3(1, 2, 0.25f)); + var top = SurfaceFacing(floor, new float3(0, 1, 0)); + var brush = (CSGTreeBrush)floor.TopTreeNode; + var topPolygon = PolygonOf(brush, top); + Assert.That(ChiselTrimDecals.PlaneHolds(brush, topPolygon, new float3(-1, 0, 0.25f), new float3(1, 0, 0.25f)), Is.True); + Assert.That(ChiselTrimDecals.PlaneHolds(brush, topPolygon, new float3(-1, 0, 0.25f), new float3(1, 2, 0.25f)), Is.False); + } + + static int PolygonOf(CSGTreeBrush brush, int surface) + { + ref var mesh = ref BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh).Value; + for (int p = 0; p < mesh.polygons.Length; p++) + { + if (mesh.polygons[p].descriptionIndex == surface) + return p; + } + return -1; + } + + // Placing by rule: every edge where a surface of the first material meets one of the second + [Test] + public void TheEdgesWhereTwoMaterialsMeet_AreFound() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + var top = SurfaceFacing(box, new float3(0, 1, 0)); + var topMaterial = new Material(decalMaterial) { name = "Top" }; + var sideMaterial = new Material(decalMaterial) { name = "Side" }; + created.Add(topMaterial); + created.Add(sideMaterial); + var surfaces = box.SurfaceDefinition.surfaces; + for (int i = 0; i < surfaces.Length; i++) + surfaces[i].SetMaterial(i == top ? topMaterial : sideMaterial); + box.OnValidate(); + Update(); + + var topTarget = new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = top }; + Assert.That(ChiselTrimDecals.MaterialOf(topTarget), Is.EqualTo(UnityEngine.EntityId.ToULong(topMaterial.GetEntityId()))); + var edges = new List(); + ChiselTrimDecals.EdgesBetween(model, UnityEngine.EntityId.ToULong(topMaterial.GetEntityId()), UnityEngine.EntityId.ToULong(sideMaterial.GetEntityId()), edges); + Assert.That(edges.Count, Is.EqualTo(4), "the top's four edges"); + foreach (var edge in edges) + { + Assert.That(edge.a.surfaceIndex, Is.EqualTo(top), "the first material's surface first"); + Assert.That(edge.b.surfaceIndex, Is.Not.EqualTo(top)); + Assert.That(edge.middle.y, Is.EqualTo(1).Within(1e-5f), "on the top's edge"); + } + ChiselTrimDecals.EdgesBetween(model, UnityEngine.EntityId.ToULong(sideMaterial.GetEntityId()), UnityEngine.EntityId.ToULong(sideMaterial.GetEntityId()), edges); + Assert.That(edges.Count, Is.EqualTo(8), "the four vertical edges and the bottom's four (it has the side material too), each once"); + } + + // An image in a trim sheet: a piece for every image along the edge, each inside its rect of the texture, the last + // one cut short + [Test] + public void ATrimSheetImage_IsAPiecePerImage_InsideItsRect() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + var decal = Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0)), length: 0.8f); + decal.Material = decalMaterial = ChiselDecalComponentTests.SheetMaterial(decalMaterial); + decal.ImageTile = 1; + Update(); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(6), "2 m in 0.8 m images: three pieces of two halves"); + var triangles = DecalTriangles(model); + Assert.That(AreaFacing(triangles, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kTolerance), "all of the edge still"); + foreach (var t in triangles) + { + foreach (var (p, uv) in new[] { (t.a, t.uvA), (t.b, t.uvB), (t.c, t.uvC) }) + { + Assert.That(uv.x, Is.InRange(0.5f - 1e-4f, 1 + 1e-4f), $"u at {p}"); + Assert.That(uv.y, Is.InRange(-1e-4f, 0.25f + 1e-4f), $"v at {p}"); + } + } + var halves = new List(); + Assert.That(ChiselTrimDecals.TryDescribe(decal, 1, 1, SurfaceDestinationFlags.Default, out _, halves, out _, out _), Is.True); + Assert.That(halves.Count, Is.EqualTo(6)); + for (int i = 0; i < halves.Count; i++) + { + var last = i >= 4; + Assert.That(halves[i].decal.settings.size.x, Is.EqualTo(last ? 0.4f : 0.8f).Within(1e-5f), $"piece {i / 2}'s length"); + Assert.That(math.abs(halves[i].decal.settings.uvScale.x), Is.EqualTo(last ? 0.25f : 0.5f).Within(1e-5f), $"piece {i / 2} shows {(last ? "half its" : "all of its")} image"); + } + } + + // Round corners: a chain of edges round the top of a box, mitred where they meet on the top, the image running on + ChiselDecalComponent TrimRoundTheTop(ChiselModelComponent model, ChiselBoxComponent box) + { + var top = SurfaceFacing(box, new float3(0, 1, 0)); + var decal = Trim(model, box, top, box, SurfaceFacing(box, new float3(1, 0, 0))); + decal.Transparent = true; + decal.SetTargets(new[] + { + new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = top }, new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = SurfaceFacing(box, new float3( 1, 0, 0)) }, + new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = top }, new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = SurfaceFacing(box, new float3( 0, 0, 1)) }, + new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = top }, new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = SurfaceFacing(box, new float3(-1, 0, 0)) }, + new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = top }, new ChiselDecalSurfaceTarget { brush = box, surfaceIndex = SurfaceFacing(box, new float3( 0, 0,-1)) }, + }); + Update(); + return decal; + } + + // A 2 m box's top, framed half a metre deep: 4 m2 less the 1 m2 middle, each corner once (overlapping square ends + // would draw 4 m2); each side half a metre down + [Test] + public void ATrimRoundCorners_IsMitredWhereItsEdgesMeet() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + TrimRoundTheTop(model, box); + var triangles = DecalTriangles(model); + Assert.That(AreaFacing(triangles, new float3(0, 1, 0)), Is.EqualTo(3.0).Within(kTolerance), "a mitred frame on the top"); + foreach (var side in new[] { new float3(1, 0, 0), new float3(0, 0, 1), new float3(-1, 0, 0), new float3(0, 0, -1) }) + Assert.That(AreaFacing(triangles, side), Is.EqualTo(1.0).Within(kTolerance), $"the side facing {side}"); + } + + [Test] + public void ATrimRoundCorners_RunsOnFromEdgeToEdge() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + TrimRoundTheTop(model, box); + Assert.That(URange(DecalTriangles(model)), Is.EqualTo(8.0f).Within(1e-3f), "8 m of edges in 1 m images, one after another"); + } + + // A wall on a floor: the trim runs as far as both surfaces reach, the wall's width + [Test] + public void ATrimBetweenTwoBrushes_RunsWhereBothReach() + { + var model = Model(); + var floor = Box(model, new float3(-4, -1, -4), new float3(4, 0, 4)); + var wall = Box(model, new float3(-1, 0, -0.25f), new float3(1, 2, 0.25f)); + Trim(model, floor, SurfaceFacing(floor, new float3(0, 1, 0)), wall, SurfaceFacing(wall, new float3(0, 0, 1))); + var triangles = DecalTriangles(model); + Assert.That(AreaFacing(triangles, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kTolerance), "the wall's 2 m along the floor, half a metre into it"); + Assert.That(AreaFacing(triangles, new float3(0, 0, 1)), Is.EqualTo(1.0).Within(kTolerance), "and half a metre up the wall"); + } + + // Its transform stays on its edge (y = 1, x = 1 here), nearest to where it was put + [Test] + public void ATrimsTransform_StaysOnItsEdge() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + var decal = Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0))); + decal.transform.position = new Vector3(5, 3, 0.25f); + Update(); + Assert.That((Vector3)decal.transform.position, Is.EqualTo(new Vector3(1, 1, 0.25f)).Using(Vector3EqualityComparer.Instance)); + decal.transform.position = new Vector3(0, 0, 9); + Update(); + Assert.That((Vector3)decal.transform.position, Is.EqualTo(new Vector3(1, 1, 1)).Using(Vector3EqualityComparer.Instance), "the edge's end"); + } + + // A resized brush gets its shape during the update, and the trim follows it in that same update + [Test] + public void AResizedBrush_HasItsTrimInTheSameUpdate() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0))); + box.Max = new float3(1, 1, 3); + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + Assert.That(AreaFacing(DecalTriangles(model), new float3(0, 1, 0)), Is.EqualTo(2.0).Within(kTolerance), "4 m along the edge, half a metre into the top"); + } + + // Single: one image at its length, round the decal's position, kept on the edge and its length near the end + [Test] + public void ASingleImage_LiesRoundTheDecal() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + var decal = Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0))); + decal.TrimFill = ChiselTrimFill.Single; + decal.Size = new Vector3(0.5f, 1, 1); + decal.transform.position = new Vector3(1, 1, 0.25f); + Update(); + Assert.That(AreaFacing(DecalTriangles(model), new float3(0, 1, 0)), Is.EqualTo(0.25).Within(kTolerance), "half a metre of the edge, half a metre into the top"); + Assert.That(ZRange(DecalTriangles(model)), Is.EqualTo(new Vector2(0, 0.5f)).Using(Vector2EqualityComparer.Instance)); + + decal.transform.position = new Vector3(1, 1, 0.9f); + Update(); + Assert.That(ZRange(DecalTriangles(model)), Is.EqualTo(new Vector2(0.5f, 1)).Using(Vector2EqualityComparer.Instance), "as long, ending at the edge's end"); + } + + static Vector2 ZRange(List triangles) + { + var min = float.PositiveInfinity; + var max = float.NegativeInfinity; + foreach (var t in triangles) + { + foreach (var p in new[] { t.a, t.b, t.c }) + { + min = math.min(min, p.z); + max = math.max(max, p.z); + } + } + return new Vector2(min, max); + } + + // Nothing tells the decal its surfaces moved: it follows them + [Test] + public void AMovedWall_TakesItsTrimAlong() + { + var model = Model(); + var floor = Box(model, new float3(-4, -1, -4), new float3(4, 0, 4)); + var wall = Box(model, new float3(-1, 0, -0.25f), new float3(1, 2, 0.25f)); + Trim(model, floor, SurfaceFacing(floor, new float3(0, 1, 0)), wall, SurfaceFacing(wall, new float3(0, 0, 1))); + + wall.transform.localPosition = new Vector3(0.5f, 0, 0); + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(wall); + Update(); + var minX = float.PositiveInfinity; + var maxX = float.NegativeInfinity; + foreach (var t in DecalTriangles(model)) + { + foreach (var p in new[] { t.a, t.b, t.c }) + { + minX = math.min(minX, p.x); + maxX = math.max(maxX, p.x); + } + } + Assert.That(minX, Is.EqualTo(-0.5f).Within(1e-4f)); + Assert.That(maxX, Is.EqualTo(1.5f).Within(1e-4f)); + } + + [Test] + public void ATrimBetweenParallelSurfaces_DrawsNothing() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(0, -1, 0))); + Assert.That(DecalTriangles(model), Is.Empty); + } + + // Repeat counts the edge's images at their length; Fit rounds that to whole images + [Test] + public void TheImage_RepeatsAlongTheEdge() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + var decal = Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0)), length: 0.8f); + Assert.That(URange(DecalTriangles(model)), Is.EqualTo(2.5f).Within(1e-4f), "2 m in 0.8 m images"); + decal.TrimFill = ChiselTrimFill.Fit; + Update(); + Assert.That(URange(DecalTriangles(model)), Is.EqualTo(2.0f).Within(1e-4f), "2.5 images round to 2"); + decal.TrimFill = ChiselTrimFill.Stretch; + Update(); + Assert.That(URange(DecalTriangles(model)), Is.EqualTo(1.0f).Within(1e-4f), "one image over the edge"); + } + + static float URange(List triangles) + { + var min = float.PositiveInfinity; + var max = float.NegativeInfinity; + foreach (var t in triangles) + { + foreach (var uv in new[] { t.uvA, t.uvB, t.uvC }) + { + min = math.min(min, uv.x); + max = math.max(max, uv.x); + } + } + return max - min; + } + + // Its second half is drawn with another entity id (one decal per id); picking it picks the decal + [Test] + public void TheSecondHalf_BelongsToTheDecal() + { + var model = Model(); + var box = Box(model, new float3(-1, -1, -1), new float3(1, 1, 1)); + var decal = Trim(model, box, SurfaceFacing(box, new float3(0, 1, 0)), box, SurfaceFacing(box, new float3(1, 0, 0))); + var id = UnityEngine.EntityId.ToULong(decal.GetEntityId()); + Assert.That(ChiselTrimDecals.DecalEntityID(id | ChiselTrimDecals.kSecondHalf), Is.EqualTo(id)); + Assert.That(model.Node.GetDecalCount(), Is.EqualTo(2), "two halves"); + } + } +} diff --git a/Components/Tests/ChiselTrimDecalTests.cs.meta b/Components/Tests/ChiselTrimDecalTests.cs.meta new file mode 100644 index 00000000..fed2c9b8 --- /dev/null +++ b/Components/Tests/ChiselTrimDecalTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: efd440b348f6c114ba7d40d4c9ab8032 \ No newline at end of file diff --git a/Components/Tests/ChiselUnsavedMeshTests.cs b/Components/Tests/ChiselUnsavedMeshTests.cs new file mode 100644 index 00000000..0dd0df41 --- /dev/null +++ b/Components/Tests/ChiselUnsavedMeshTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class ChiselUnsavedMeshTests + { + const int kModelCount = 2; + + readonly List roots = new(); + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + { + if (root) + Object.DestroyImmediate(root); + } + roots.Clear(); + Update(); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + List BuildModelsAndTheirUnsavedMeshes() + { + var models = new List(); + for (int i = 0; i < kModelCount; i++) + { + var root = new GameObject("Unsaved Mesh Model " + i); + roots.Add(root); + models.Add(root.AddComponent()); + var boxObject = new GameObject("Box"); + boxObject.transform.SetParent(root.transform, false); + boxObject.AddComponent().OnValidate(); + } + Update(); + + var unsaved = new List(); + foreach (var model in models) + { + foreach (var renderables in new[] { model.generated.renderables, model.generated.debugVisualizationRenderables }) + { + foreach (var renderable in renderables) + { + if (renderable == null || renderable.invalid) + continue; + if (renderable.partialMesh) unsaved.Add(renderable.partialMesh); + if (renderable.selectionMesh) unsaved.Add(renderable.selectionMesh); + } + } + } + Assert.That(unsaved.Count, Is.GreaterThan(0), "the models allocated no unsaved meshes: nothing is measured"); + return unsaved; + } + + [Test] + public void AModelsUnsavedMeshes_AreNotSavedButCanBeUnloaded() + { + foreach (var mesh in BuildModelsAndTheirUnsavedMeshes()) + { + Assert.That(mesh.hideFlags & HideFlags.DontSaveInEditor, Is.EqualTo(HideFlags.DontSaveInEditor), mesh.name + " would be saved with its scene"); + Assert.That(mesh.hideFlags & HideFlags.DontSaveInBuild, Is.EqualTo(HideFlags.DontSaveInBuild), mesh.name + " would be saved in a build"); + Assert.That(mesh.hideFlags & HideFlags.DontUnloadUnusedAsset, Is.EqualTo(HideFlags.None), mesh.name + " can never be unloaded"); + } + } + } +} diff --git a/Components/Tests/ChiselUnsavedMeshTests.cs.meta b/Components/Tests/ChiselUnsavedMeshTests.cs.meta new file mode 100644 index 00000000..64dba582 --- /dev/null +++ b/Components/Tests/ChiselUnsavedMeshTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 72f3d0e5283343b0a5d6cdff4afea769 diff --git a/Components/Tests/SceneLeftClean.cs b/Components/Tests/SceneLeftClean.cs new file mode 100644 index 00000000..b688893f --- /dev/null +++ b/Components/Tests/SceneLeftClean.cs @@ -0,0 +1,29 @@ +using System.Reflection; + +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; + +namespace Chisel.Components.Tests +{ + static class SceneLeftClean + { + static readonly MethodInfo s_ClearSceneDirtiness = typeof(EditorSceneManager).GetMethod("ClearSceneDirtiness", + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, null, new[] { typeof(Scene) }, null); + + /// The active scene and whether it is dirty already, for : first thing in a test. + public static (Scene scene, bool wasDirty) Remember() + { + var scene = SceneManager.GetActiveScene(); + return (scene, scene.isDirty); + } + + /// The scene saw marked clean again if the test dirtied it; the last thing a test does + public static void Restore((Scene scene, bool wasDirty) remembered) + { + if (remembered.wasDirty || !remembered.scene.IsValid() || !remembered.scene.isLoaded || !remembered.scene.isDirty || + s_ClearSceneDirtiness == null) + return; + s_ClearSceneDirtiness.Invoke(null, new object[] { remembered.scene }); + } + } +} diff --git a/Components/Tests/SceneLeftClean.cs.meta b/Components/Tests/SceneLeftClean.cs.meta new file mode 100644 index 00000000..ad9bbfe2 --- /dev/null +++ b/Components/Tests/SceneLeftClean.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e90940a4dfb4f1b8254b0a82aa9d24e \ No newline at end of file diff --git a/Components/Tests/StagedInputHashTests.cs b/Components/Tests/StagedInputHashTests.cs new file mode 100644 index 00000000..3051311f --- /dev/null +++ b/Components/Tests/StagedInputHashTests.cs @@ -0,0 +1,255 @@ +using System; +using Chisel.Core; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Components.Tests +{ + [TestFixture] + public class StagedInputHashTests + { + GameObject root; + ChiselModelComponent model; + ChiselBoxComponent box; + Material material; + + [SetUp] + public void SetUp() + { + if (UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + + root = new GameObject("Model"); + model = root.AddComponent(); + box = AddBox("Box", Vector3.zero); + material = new Material(Shader.Find("Hidden/InternalErrorShader")); + Update(); + } + + [TearDown] + public void TearDown() + { + if (root) + UnityEngine.Object.DestroyImmediate(root); + if (material) + UnityEngine.Object.DestroyImmediate(material); + root = null; + model = null; + box = null; + material = null; + Update(); + } + + ChiselBoxComponent AddBox(string name, Vector3 localPosition) + { + var boxObject = new GameObject(name); + boxObject.transform.SetParent(root.transform, false); + boxObject.transform.localPosition = localPosition; + var added = boxObject.AddComponent(); + added.OnValidate(); + return added; + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + // What the model's meshes are built from, hashed from the tree that was built + Hash128 Built() { return ChiselModelManager.GetInputHash(model); } + + // The same question asked of the components instead + Hash128 FromComponents() { return ChiselStagedInputHashes.GetCSGInputHash(model, ChiselModelManager.SurfaceParameterIdentityMethod); } + + void PushToTree() + { + if (!root) + return; + foreach (var generator in root.GetComponentsInChildren(true)) + { + ChiselNodeHierarchyManager.UpdateTreeNodeTransformation(generator); + if (!generator.isActiveAndEnabled) + continue; + generator.ClearHashes(); + generator.UpdateGeneratorNodes(); + } + } + + /// Makes the change and requires both hashes to agree whether it changed the input (and, with , that it did) + void AssertBothAgree(string what, Action change, bool mustChange = true) + { + var builtBefore = Built(); + var componentsBefore = FromComponents(); + Assert.That(builtBefore.isValid, Is.True, "the model has nothing built to hash"); + Assert.That(componentsBefore.isValid, Is.True, "the components hashed to nothing"); + + change(); + PushToTree(); + Update(); + + var builtChanged = Built() != builtBefore; + var componentsChanged = FromComponents() != componentsBefore; + if (builtChanged != componentsChanged) + { + Assert.Fail(builtChanged + ? $"{what} changed what is built, but the hash taken from the components stayed the same: it misses this input" + : $"{what} changed the hash taken from the components, but not what is built: it hashes something that isn't input"); + } + if (mustChange) + Assert.That(builtChanged, Is.True, $"{what} is a change of input, but nothing noticed"); + } + + [Test] + public void AMovedBrush_IsAChange() + { + AssertBothAgree("moving a brush", () => box.transform.localPosition = new Vector3(0.25f, 0, 0)); + } + + // Baking lightmaps lets a chunk split for its lightmap (PrepareSubSections.LightmapSplit) + [Test] + public void BakingLightmaps_IsAChange() + { + AssertBothAgree("baking the model's lightmaps", () => + { + UnityEditor.GameObjectUtility.SetStaticEditorFlags(model.gameObject, UnityEditor.StaticEditorFlags.ContributeGI); + model.RenderSettings.receiveGI = ReceiveGI.Lightmaps; + model.SyncModelSettingsStore(); + }); + } + + [Test] + public void ARotatedBrush_IsAChange() + { + AssertBothAgree("rotating a brush", () => box.transform.localRotation = Quaternion.Euler(0, 30, 0)); + } + + [Test] + public void AResizedBrush_IsAChange() + { + AssertBothAgree("resizing a brush", () => + { + box.definition.settings.Size += new Unity.Mathematics.float3(0.5f, 0, 0); + box.OnValidate(); + }); + } + + [Test] + public void ADifferentOperation_IsAChange() + { + AssertBothAgree("changing a brush's operation", () => box.Operation = CSGOperationType.Subtractive); + } + + [Test] + public void ADifferentPivot_IsAChange() + { + AssertBothAgree("moving a brush's pivot", () => box.PivotOffset = new Vector3(0, 0.5f, 0)); + } + + [Test] + public void ADifferentMaterial_IsAChange() + { + AssertBothAgree("giving a surface another material", () => box.SurfaceDefinition.surfaces[0].SetMaterial(material)); + } + + [Test] + public void ADifferentSurfaceUV_IsAChange() + { + AssertBothAgree("moving a surface's UVs", () => + { + var uv0 = box.GetSurfaceUV0(0); + uv0.U.w += 0.5f; + box.SetSurfaceUV0(0, uv0); + }); + } + + [Test] + public void AnAddedBrush_IsAChange() + { + AssertBothAgree("adding a brush", () => AddBox("Second", new Vector3(2, 0, 0))); + } + + [Test] + public void ARemovedBrush_IsAChange() + { + var second = AddBox("Second", new Vector3(2, 0, 0)); + Update(); + AssertBothAgree("removing a brush", () => UnityEngine.Object.DestroyImmediate(second.gameObject)); + } + + [Test] + public void ADeactivatedBrush_IsAChange() + { + AssertBothAgree("deactivating a brush", () => box.gameObject.SetActive(false)); + } + + [Test] + public void AReorderedBrush_IsAChange() + { + var second = AddBox("Second", new Vector3(2, 0, 0)); + Update(); + AssertBothAgree("reordering two brushes", () => second.transform.SetSiblingIndex(0)); + } + + [Test] + public void AnAddedDecal_IsAChange() + { + AssertBothAgree("adding a decal", () => + { + var decalObject = new GameObject("Decal"); + decalObject.transform.SetParent(root.transform, false); + decalObject.transform.localPosition = new Vector3(0.5f, 1.0f, 0.5f); + decalObject.transform.localRotation = Quaternion.Euler(90, 0, 0); + var decal = decalObject.AddComponent(); + decal.Material = material; + decal.Size = new Vector3(0.5f, 0.5f, 0.5f); + }); + } + + [Test] + public void ADifferentModelSetting_IsAChange() + { + AssertBothAgree("changing the model's smoothing angle", () => + { + model.renderSettings.normalSmoothingAngle = 30.0f; + model.SyncModelSettingsStore(); + }); + } + + // What a brush is made of is an index into the contents list, and a project may only have the one it starts + // with, so this only requires the two hashes to agree + [Test] + public void DifferentContents_AreNoticedByBoth() + { + AssertBothAgree("changing what a brush is made of", () => box.Contents = 0, mustChange: false); + } + + [Test] + public void ARenamedObject_IsNotAChange() + { + AssertBothAgree("renaming a brush", () => box.gameObject.name = "Renamed", mustChange: false); + Assert.That(Built(), Is.EqualTo(Built()), "the built hash isn't stable"); + } + + [Test] + public void AMovedModel_IsNotAChange() + { + var before = FromComponents(); + AssertBothAgree("moving the model", () => root.transform.position = new Vector3(10, 20, 30), mustChange: false); + Assert.That(FromComponents(), Is.EqualTo(before), "moving a model changed what it is built from"); + } + + // The same components hash the same way twice, or nothing above means anything + [Test] + public void TheSameComponents_HashTheSame() + { + var first = FromComponents(); + Update(); + Assert.That(FromComponents(), Is.EqualTo(first)); + Assert.That(first.isValid, Is.True); + } + } +} diff --git a/Components/Tests/StagedInputHashTests.cs.meta b/Components/Tests/StagedInputHashTests.cs.meta new file mode 100644 index 00000000..4a0e2c26 --- /dev/null +++ b/Components/Tests/StagedInputHashTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4bf535432962ff240882d6a6a6bf88cb \ No newline at end of file diff --git a/Components/Tests/com.chisel.unity.components.tests.asmdef b/Components/Tests/com.chisel.unity.components.tests.asmdef new file mode 100644 index 00000000..460d3321 --- /dev/null +++ b/Components/Tests/com.chisel.unity.components.tests.asmdef @@ -0,0 +1,30 @@ +{ + "name": "com.chisel.unity.components.tests", + "rootNamespace": "Chisel.Components.Tests", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "com.chisel.unity.components", + "com.chisel.core", + "Unity.Collections", + "Unity.Entities", + "Unity.Mathematics", + "AssetMetadataRuntime", + "AssetMetadataEditor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Components/Tests/com.chisel.unity.components.tests.asmdef.meta b/Components/Tests/com.chisel.unity.components.tests.asmdef.meta new file mode 100644 index 00000000..6759307b --- /dev/null +++ b/Components/Tests/com.chisel.unity.components.tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fb11c7fa22d99ee499ba29df0ea059f4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Core/Tests.meta b/Core/Tests.meta new file mode 100644 index 00000000..6407e973 --- /dev/null +++ b/Core/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d098f8b6c5c8d4b449c3855842562a5d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Core/Tests/AlignedFaceDetectionTests.cs b/Core/Tests/AlignedFaceDetectionTests.cs new file mode 100644 index 00000000..421bad65 --- /dev/null +++ b/Core/Tests/AlignedFaceDetectionTests.cs @@ -0,0 +1,131 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class AlignedFaceDetectionTests + { + // FaceLiesOnPlane takes a BlobArray, so the vertices have to live in a blob + static BlobAssetReference Blob(params float3[] vertices) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var array = builder.Allocate(ref root.vertices, vertices.Length); + for (int i = 0; i < vertices.Length; i++) + array[i] = vertices[i]; + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + struct VertexBlob { public BlobArray vertices; } + + static bool FaceLiesOnPlane(float4 facePlane, float4 otherPlane, params float3[] vertices) + { + var blob = Blob(vertices); + var result = PrepareBrushPairIntersectionsJob.FaceLiesOnPlane(ref blob.Value.vertices, facePlane, otherPlane); + blob.Dispose(); + return result; + } + + static bool FaceLiesOnPlaneWithin(float epsilon, float4 facePlane, float4 otherPlane, params float3[] vertices) + { + var blob = Blob(vertices); + var result = PrepareBrushPairIntersectionsJob.FaceLiesOnPlane(ref blob.Value.vertices, facePlane, otherPlane, epsilon); + blob.Dispose(); + return result; + } + + // a unit square on z = 0, far enough from the origin that tilt matters + static readonly float3[] kFaceOnZ0 = + { + new float3(100, 100, 0), + new float3(101, 100, 0), + new float3(101, 101, 0), + new float3(100, 101, 0), + }; + + static readonly float4 kPlaneZ0 = new float4(0, 0, 1, 0); + + [Test] + public void AFaceLyingExactlyOnTheOtherPlane_Qualifies() + { + Assert.That(FaceLiesOnPlane(kPlaneZ0, kPlaneZ0, kFaceOnZ0), Is.True); + } + + [Test] + public void AnOppositeFacingCoincidentPlane_Qualifies() + { + // the caller negates the plane first, so the normals agree: a flipped and negated plane still measures zero distance + Assert.That(FaceLiesOnPlane(kPlaneZ0, -(-kPlaneZ0), kFaceOnZ0), Is.True); + } + + [Test] + public void ATiltedButCoincidentPlane_Qualifies_EvenThoughItsOffsetAtTheOriginDisagrees() + { + var tilt = math.normalize(new float3(1e-4f, 0, 1)); + var tiltedPlane = new float4(tilt, -math.dot(tilt, kFaceOnZ0[0])); + + // the offset comparison the original test used would reject this outright + var offsetDisagreement = math.abs(kPlaneZ0.w - tiltedPlane.w); + Assert.That(offsetDisagreement, Is.GreaterThan(CSGConstants.kPlaneDAlignEpsilon), + "the fixture must actually exercise the tilt case"); + // while the normals are nowhere near far enough apart to be rejected for that + Assert.That(math.dot(kPlaneZ0.xyz, tiltedPlane.xyz), + Is.GreaterThan(CSGConstants.kNormalDotAlignEpsilon)); + + Assert.That(FaceLiesOnPlane(kPlaneZ0, tiltedPlane, kFaceOnZ0), Is.True); + } + + [Test] + public void AGenuinelyOffsetPlane_DoesNotQualify() + { + var farPlane = new float4(0, 0, 1, -1.0f); // a whole unit away + Assert.That(FaceLiesOnPlane(kPlaneZ0, farPlane, kFaceOnZ0), Is.False); + } + + // Missing a match is safer than a wrong one: a wrong match moves geometry + [Test] + public void APlaneJustBeyondTheTolerance_DoesNotQualify() + { + var justTooFar = new float4(0, 0, 1, -0.02f); + Assert.That(FaceLiesOnPlane(kPlaneZ0, justTooFar, kFaceOnZ0), Is.False); + } + + // A face 1 mm from another is a real step that both brushes draw: only faces less than τ apart are aligned + [Test] + public void WithCanonicalAlignment_OnlyFacesLessThanTauApartQualify() + { + var oneMillimetre = new float4(0, 0, 1, -0.001f); + var tenthOfAMillimetre = new float4(0, 0, 1, -0.0001f); + Assert.That(FaceLiesOnPlane(kPlaneZ0, oneMillimetre, kFaceOnZ0), Is.True, "10 mm: aligned"); + Assert.That(FaceLiesOnPlaneWithin(CSGConstants.kFatPlaneWidthEpsilon, kPlaneZ0, oneMillimetre, kFaceOnZ0), Is.False, + "τ: a 1 mm step"); + Assert.That(FaceLiesOnPlaneWithin(CSGConstants.kFatPlaneWidthEpsilon, kPlaneZ0, tenthOfAMillimetre, kFaceOnZ0), Is.True, + "τ: 0.1 mm is the same surface"); + } + + [Test] + public void FewerThanThreeVerticesOnTheFace_DoesNotQualify() + { + // two vertices define a line, not a face + Assert.That(FaceLiesOnPlane(kPlaneZ0, kPlaneZ0, + new float3(100, 100, 0), + new float3(101, 100, 0), + new float3(100, 100, 5)), Is.False); + } + + [Test] + public void VerticesNotOnTheFaceAreIgnored_NotCountedAgainstIt() + { + // only the vertices lying on facePlane are the face; the rest of the brush is irrelevant + Assert.That(FaceLiesOnPlane(kPlaneZ0, kPlaneZ0, + new float3(100, 100, 0), + new float3(101, 100, 0), + new float3(101, 101, 0), + new float3(100, 100, 7), // elsewhere on the brush + new float3(101, 101, 9)), Is.True); + } + } +} diff --git a/Core/Tests/AlignedFaceDetectionTests.cs.meta b/Core/Tests/AlignedFaceDetectionTests.cs.meta new file mode 100644 index 00000000..007151fd --- /dev/null +++ b/Core/Tests/AlignedFaceDetectionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc52899dd45551b4b804a32c0244f3ed \ No newline at end of file diff --git a/Core/Tests/AlignedFaceLoopDeliveryTests.cs b/Core/Tests/AlignedFaceLoopDeliveryTests.cs new file mode 100644 index 00000000..7b133173 --- /dev/null +++ b/Core/Tests/AlignedFaceLoopDeliveryTests.cs @@ -0,0 +1,241 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class AlignedFaceLoopDeliveryTests + { + struct VertexBlob { public BlobArray vertices; } + + static BlobAssetReference Planes(params float4[] planes) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var array = builder.Allocate(ref root.treeSpacePlanes, planes.Length); + for (int i = 0; i < planes.Length; i++) + array[i] = planes[i]; + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + static BlobAssetReference Vertices(params float3[] vertices) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var array = builder.Allocate(ref root.vertices, vertices.Length); + for (int i = 0; i < vertices.Length; i++) + array[i] = vertices[i]; + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + // An axis-aligned box as 6 outward tree-space planes: dot(plane, p) <= 0 means inside. + static float4[] BoxPlanes(float3 min, float3 max) + { + return new[] + { + new float4(-1, 0, 0, min.x), + new float4( 1, 0, 0, -max.x), + new float4( 0, -1, 0, min.y), + new float4( 0, 1, 0, -max.y), + new float4( 0, 0, -1, min.z), + new float4( 0, 0, 1, -max.z), + }; + } + + // Brush0 is a unit quad on z = 0 (plane 0 is the face under test), Brush1 a box. Returns how many indices were added for plane 0. + static int AddedIndicesForFace(float3[] faceVertices, float4[] brush1Planes, + CategoryIndex category, int preExistingOnPlane0 = 0, + int facePlaneCount1 = -1) + { + if (facePlaneCount1 < 0) facePlaneCount1 = brush1Planes.Length; + var facePlane = new float4(0, 0, 1, 0); + var brush0 = Planes(facePlane, new float4(0, 0, -1, 0)); + var brush1 = Planes(brush1Planes); + var vertexBlob = Vertices(faceVertices); + + var surfaceInfos = new NativeArray(2, Allocator.Temp); + surfaceInfos[0] = new SurfaceInfo { basePlaneIndex = 0, interiorCategory = (byte)category }; + surfaceInfos[1] = new SurfaceInfo { basePlaneIndex = 1, interiorCategory = (byte)CategoryIndex.Inside }; + + var intersectingPlaneIndices = new NativeArray(new[] { 0, 1 }, Allocator.Temp); + var foundIndices = new NativeArray(64, Allocator.Temp); + + var hashedVertices = new HashedVertices(64, Allocator.Temp); + var snapVertices = new HashedVertices(64, Allocator.Temp); + + int foundIndicesLength = 0; + // pre-seed the plane with vertices, to prove a face that already has a real loop is skipped + for (int i = 0; i < preExistingOnPlane0; i++) + { + var index = hashedVertices.AddNoResize(new float3(-50 - i, -50, 0)); + foundIndices[foundIndicesLength++] = new CreateIntersectionLoopsJob.PlaneVertexIndexPair + { planeIndex = 0, vertexIndex = index }; + } + var before = foundIndicesLength; + + var job = new CreateIntersectionLoopsJob(); + job.AddFullSurfaceLoopsForAlignedFaces( + ref vertexBlob.Value.vertices, + ref brush0.Value, ref brush1.Value, + surfaceInfos, 2, + intersectingPlaneIndices, 2, + facePlaneCount1, + foundIndices, ref foundIndicesLength, + ref hashedVertices, ref snapVertices); + + int addedOnPlane0 = 0; + for (int i = before; i < foundIndicesLength; i++) + if (foundIndices[i].planeIndex == 0) + addedOnPlane0++; + + snapVertices.Dispose(); + hashedVertices.Dispose(); + foundIndices.Dispose(); + intersectingPlaneIndices.Dispose(); + surfaceInfos.Dispose(); + vertexBlob.Dispose(); + brush1.Dispose(); + brush0.Dispose(); + return addedOnPlane0; + } + + // a unit quad on z = 0 + static readonly float3[] kQuadOnZ0 = + { + new float3(0, 0, 0), new float3(1, 0, 0), + new float3(1, 1, 0), new float3(0, 1, 0), + }; + + // A coplanar face wholly inside the other brush gets a loop, so its ReverseAligned category reaches PerformCSGJob + [Test] + public void AReverseAlignedFaceInsideTheOtherBrush_GetsItsOwnVerticesAsALoop() + { + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, 1)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.ReverseAligned), + Is.EqualTo(4), "all four corners of the face should become the loop"); + } + + [Test] + public void AnAlignedFaceInsideTheOtherBrush_AlsoGetsALoop() + { + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, 1)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.Aligned), + Is.EqualTo(4)); + } + + [Test] + public void AGapToTheCoplanarCounterpart_DoesNotVetoCoverage() + { + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, 1)); + covering[5] = new float4(0, 0, 1, -0.0005f); // the coplanar face, a hair outside + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.ReverseAligned), + Is.EqualTo(4)); + } + + // A face only partly covered is left alone: dropping it whole would open a hole + [Test] + public void APartlyCoveredFace_IsLeftAlone() + { + var partial = BoxPlanes(new float3(-1, -1, -1), new float3(0.5f, 2, 1)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, partial, CategoryIndex.ReverseAligned), + Is.EqualTo(0)); + } + + [Test] + public void AFaceCompletelyOutsideTheOtherBrush_IsLeftAlone() + { + var elsewhere = BoxPlanes(new float3(10, 10, 10), new float3(12, 12, 12)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, elsewhere, CategoryIndex.ReverseAligned), + Is.EqualTo(0)); + } + + // Only a coplanar pair is short of vertices; anything else already has a proper loop and must + // not be touched. + [Test] + public void AFaceThatIsNotAligned_IsLeftAlone() + { + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, 1)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.Inside), Is.EqualTo(0)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.Outside), Is.EqualTo(0)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.SelfAligned), Is.EqualTo(0)); + } + + // A face that already collected the 3 vertices GenerateLoop needs has a real crossing loop; + // adding the whole face on top of it would corrupt that loop. + [Test] + public void AFaceThatAlreadyHasALoop_IsLeftAlone() + { + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, 1)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.ReverseAligned, + preExistingOnPlane0: 3), + Is.EqualTo(0)); + } + + [Test] + public void EdgePlanesBeyondTheFaceCount_DoNotVetoContainment() + { + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, 1)); + // six face planes, then edge planes slicing through the middle of the box; every one of + // them would report the face as outside if it were treated as a bound + var withEdgePlanes = new float4[] + { + covering[0], covering[1], covering[2], covering[3], covering[4], covering[5], + new float4(0, 0, 1, -0.5f), + new float4(1, 0, 0, -0.5f), + new float4(math.normalize(new float3(1, 1, 0)), -0.25f), + }; + + // sanity: those extra planes really would reject it + Assert.That(AddedIndicesForFace(kQuadOnZ0, withEdgePlanes, CategoryIndex.ReverseAligned, + facePlaneCount1: withEdgePlanes.Length), + Is.EqualTo(0), "the fixture must actually exercise the edge-plane case"); + + // with the face count respected, the face is contained as it should be + Assert.That(AddedIndicesForFace(kQuadOnZ0, withEdgePlanes, CategoryIndex.ReverseAligned, + facePlaneCount1: 6), + Is.EqualTo(4)); + } + + [Test] + public void TheCounterpartIsTheNearestParallelFace_NotTheFirstOneFound() + { + const float gap = 0.0008f; + Assert.That(gap, Is.GreaterThan(CSGConstants.kFatPlaneWidthEpsilon), + "the fixture must exceed the containment tolerance, or excluding the wrong face would not matter"); + + // box from z = -1 up to z = -gap: the face on z = 0 sits `gap` above its top + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, -gap)); + Assert.That(math.abs(covering[4].z), Is.EqualTo(1f), "the far face must come before the near one"); + Assert.That(covering[5].z, Is.EqualTo(1f)); + + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.ReverseAligned), + Is.EqualTo(4), "excluding the near coincident face leaves only real bounds in the test"); + } + + // Fewer planes than a tetrahedron cannot bound a volume, so containment is meaningless. + [Test] + public void TooFewFacePlanesToBoundAVolume_IsLeftAlone() + { + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, 1)); + Assert.That(AddedIndicesForFace(kQuadOnZ0, covering, CategoryIndex.ReverseAligned, + facePlaneCount1: 3), + Is.EqualTo(0)); + } + + // Two vertices are an edge, not a face + [Test] + public void AFaceWithFewerThanThreeVerticesOnIt_IsLeftAlone() + { + var covering = BoxPlanes(new float3(-1, -1, -1), new float3(2, 2, 1)); + var twoOnPlane = new[] + { + new float3(0, 0, 0), new float3(1, 0, 0), + new float3(0, 0, 0.5f), // off the face + }; + Assert.That(AddedIndicesForFace(twoOnPlane, covering, CategoryIndex.ReverseAligned), + Is.EqualTo(0)); + } + } +} diff --git a/Core/Tests/AlignedFaceLoopDeliveryTests.cs.meta b/Core/Tests/AlignedFaceLoopDeliveryTests.cs.meta new file mode 100644 index 00000000..2388a93d --- /dev/null +++ b/Core/Tests/AlignedFaceLoopDeliveryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4597d72e3bbb70049a8ac4a7786c00d5 \ No newline at end of file diff --git a/Core/Tests/BooleanEdgesUtilityTests.cs b/Core/Tests/BooleanEdgesUtilityTests.cs new file mode 100644 index 00000000..043ca6f4 --- /dev/null +++ b/Core/Tests/BooleanEdgesUtilityTests.cs @@ -0,0 +1,138 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class BooleanEdgesUtilityTests + { + // Inward planes of the axis-aligned box x in [0,1], z in [0,1] (y unconstrained). + // Convention (see IsOutsidePlanes): a point is inside when dot(plane,(p,1)) <= eps for ALL planes. + static NativeList BoxPlanesXZ() + { + var p = new NativeList(4, Allocator.Temp); + p.Add(new float4(-1, 0, 0, 0)); // x >= 0 -> dot = -x + p.Add(new float4( 1, 0, 0, -1)); // x <= 1 -> dot = x - 1 + p.Add(new float4( 0, 0, -1, 0)); // z >= 0 -> dot = -z + p.Add(new float4( 0, 0, 1, -1)); // z <= 1 -> dot = z - 1 + return p; + } + + static HashedVertices Verts(params float3[] vs) + { + var hv = new HashedVertices(math.max(1, vs.Length), Allocator.Temp); + for (int i = 0; i < vs.Length; i++) hv.AddNoResize(vs[i]); + return hv; + } + + static NativeArray EdgeArray(params (int a, int b)[] es) + { + var arr = new NativeArray(es.Length, Allocator.Temp); + for (int i = 0; i < es.Length; i++) + arr[i] = new Edge { index1 = (ushort)es[i].a, index2 = (ushort)es[i].b }; + return arr; + } + + static LoopSegment WholeSegment(int edgeCount, int planeCount) + => new LoopSegment { edgeOffset = 0, edgeLength = edgeCount, planesOffset = 0, planesLength = planeCount }; + + // Vertices: 0,1 = inside (used as the segment edge); 2,3 = inside (off-segment); 4,5 = outside; + // 6 = inside the fat-plane band (x = 1 - eps/2, i.e. just inside the x<=1 face by < epsilon). + static HashedVertices Scene() => Verts( + new float3(0.3f, 0, 0.5f), // 0 inside + new float3(0.7f, 0, 0.5f), // 1 inside + new float3(0.4f, 0, 0.4f), // 2 inside + new float3(0.6f, 0, 0.6f), // 3 inside + new float3(2.0f, 0, 0.5f), // 4 outside (x>1) + new float3(2.5f, 0, 0.5f), // 5 outside + new float3(0.9997f, 0, 0.5f)); // 6 in the fat band of the x<=1 face (dot = -0.0003) + + static void DisposeAll(HashedVertices v, NativeArray e, NativeList p) + { v.Dispose(); e.Dispose(); p.Dispose(); } + + [Test] + public void Aligned_WhenEdgeMatchesSegmentEdge_SameDirection() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + var cat = BooleanEdgesUtility.CategorizeEdge(new Edge { index1 = 0, index2 = 1 }, in p, in e, WholeSegment(1, 4), in v); + Assert.That(cat, Is.EqualTo(EdgeCategory.Aligned)); + DisposeAll(v, e, p); + } + + [Test] + public void ReverseAligned_WhenEdgeMatchesSegmentEdge_OppositeDirection() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + var cat = BooleanEdgesUtility.CategorizeEdge(new Edge { index1 = 1, index2 = 0 }, in p, in e, WholeSegment(1, 4), in v); + Assert.That(cat, Is.EqualTo(EdgeCategory.ReverseAligned)); + DisposeAll(v, e, p); + } + + [Test] + public void Inside_WhenMidpointInsidePlanes_AndNotAligned() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + // edge (2,3): midpoint (0.5,0,0.5) is inside the box; not in the segment edge list. + var cat = BooleanEdgesUtility.CategorizeEdge(new Edge { index1 = 2, index2 = 3 }, in p, in e, WholeSegment(1, 4), in v); + Assert.That(cat, Is.EqualTo(EdgeCategory.Inside)); + DisposeAll(v, e, p); + } + + [Test] + public void Outside_WhenMidpointOutsidePlanes() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + // edge (4,5): midpoint (2.25,0,0.5) is outside (x>1). + var cat = BooleanEdgesUtility.CategorizeEdge(new Edge { index1 = 4, index2 = 5 }, in p, in e, WholeSegment(1, 4), in v); + Assert.That(cat, Is.EqualTo(EdgeCategory.Outside)); + DisposeAll(v, e, p); + } + + [Test] + public void Straddle_True_WhenEndpointsOnOppositeSides() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + // edge (2 inside, 4 outside) crosses the boundary. + var straddle = BooleanEdgesUtility.EdgeStraddlesSegmentPlanes(new Edge { index1 = 2, index2 = 4 }, in p, WholeSegment(1, 4), in v); + Assert.That(straddle, Is.True); + DisposeAll(v, e, p); + } + + [Test] + public void Straddle_False_WhenBothInside_OrBothOutside() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + Assert.That(BooleanEdgesUtility.EdgeStraddlesSegmentPlanes(new Edge { index1 = 2, index2 = 3 }, in p, WholeSegment(1, 4), in v), Is.False); + Assert.That(BooleanEdgesUtility.EdgeStraddlesSegmentPlanes(new Edge { index1 = 4, index2 = 5 }, in p, WholeSegment(1, 4), in v), Is.False); + DisposeAll(v, e, p); + } + + [Test] + public void StrictCrossing_True_WhenOneStrictlyInside_OtherStrictlyOutside() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + Assert.That(BooleanEdgesUtility.EdgeStrictlyCrossesSegmentPlanes(new Edge { index1 = 2, index2 = 4 }, in p, WholeSegment(1, 4), in v), Is.True); + DisposeAll(v, e, p); + } + + [Test] + public void StrictCrossing_False_BothInside_OrBothOutside() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + Assert.That(BooleanEdgesUtility.EdgeStrictlyCrossesSegmentPlanes(new Edge { index1 = 2, index2 = 3 }, in p, WholeSegment(1, 4), in v), Is.False); + Assert.That(BooleanEdgesUtility.EdgeStrictlyCrossesSegmentPlanes(new Edge { index1 = 4, index2 = 5 }, in p, WholeSegment(1, 4), in v), Is.False); + DisposeAll(v, e, p); + } + + [Test] + public void StrictCrossing_False_ForBandTouch_EvenThoughStraddleIsTrue() + { + var v = Scene(); var e = EdgeArray((0, 1)); var p = BoxPlanesXZ(); + var edge = new Edge { index1 = 6, index2 = 4 }; + Assert.That(BooleanEdgesUtility.EdgeStraddlesSegmentPlanes(edge, in p, WholeSegment(1, 4), in v), Is.True); + Assert.That(BooleanEdgesUtility.EdgeStrictlyCrossesSegmentPlanes(edge, in p, WholeSegment(1, 4), in v), Is.False); + DisposeAll(v, e, p); + } + } +} diff --git a/Core/Tests/BooleanEdgesUtilityTests.cs.meta b/Core/Tests/BooleanEdgesUtilityTests.cs.meta new file mode 100644 index 00000000..f6a3ebec --- /dev/null +++ b/Core/Tests/BooleanEdgesUtilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd6cd425f1880ab4f9d962f0cdc19cf5 \ No newline at end of file diff --git a/Core/Tests/BoundsExtensionsTests.cs b/Core/Tests/BoundsExtensionsTests.cs new file mode 100644 index 00000000..5b56c2af --- /dev/null +++ b/Core/Tests/BoundsExtensionsTests.cs @@ -0,0 +1,145 @@ +using Chisel; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + // The setters in BoundsExtensions change the bounds they are called on, not a copy + [TestFixture] + public class BoundsExtensionsTests + { + [Test] + public void IsValid_TrueForNonDegenerateBounds() + { + Assert.That(BoundsExtensions.IsValid(new float3(-1, -1, -1), new float3(1, 1, 1)), Is.True); + } + + [Test] + public void IsValid_FalseForZeroSizeBounds() + { + Assert.That(BoundsExtensions.IsValid(float3.zero, float3.zero), Is.False); + } + + [Test] + public void IsValid_FalseWhenOneAxisIsTooThin() + { + // y axis has zero extent -> degenerate + Assert.That(BoundsExtensions.IsValid(new float3(-1, 0, -1), new float3(1, 0, 1)), Is.False); + } + + [Test] + public void IsValid_FalseForNonFiniteValues() + { + Assert.That(BoundsExtensions.IsValid(new float3(float.NaN, 0, 0), new float3(1, 1, 1)), Is.False); + Assert.That(BoundsExtensions.IsValid(new float3(0, 0, 0), new float3(float.PositiveInfinity, 1, 1)), Is.False); + } + + static void AreEqual(float3 expected, float3 actual, string message) + { + Assert.That(math.distance(expected, actual), Is.LessThan(1e-5f), $"{message}: expected {expected}, got {actual}"); + } + + [Test] + public void MinMaxAABB_Setters_ChangeTheBounds() + { + var bounds = new MinMaxAABB { Min = new float3(0), Max = new float3(1) }; + bounds.SetMin(new float3(-2, -1, -2)); + bounds.SetMax(new float3(2, 0, 2)); + AreEqual(new float3(-2, -1, -2), bounds.Min, "min"); + AreEqual(new float3(2, 0, 2), bounds.Max, "max"); + + // The size (4, 1, 4) stays + bounds.SetCenter(new float3(-10, 5, 0)); + AreEqual(new float3(-12, 4.5f, -2), bounds.Min, "moved min"); + AreEqual(new float3(-8, 5.5f, 2), bounds.Max, "moved max"); + + // Around the center, which is negative + bounds.SetExtents(new float3(1, 2, 3)); + AreEqual(new float3(-11, 3, -3), bounds.Min, "resized min"); + AreEqual(new float3(-9, 7, 3), bounds.Max, "resized max"); + + bounds.SetMinMax(new float3(1, 2, 3), new float3(4, 5, 6)); + AreEqual(new float3(1, 2, 3), bounds.Min, "set min"); + AreEqual(new float3(4, 5, 6), bounds.Max, "set max"); + } + + [Test] + public void AABB_Setters_ChangeTheBounds() + { + var bounds = new AABB { Center = float3.zero, Extents = new float3(1) }; + bounds.SetMin(new float3(-3, -1, -5)); + AreEqual(new float3(-3, -1, -5), bounds.Min, "min"); + AreEqual(new float3(1, 1, 1), bounds.Max, "the max stays"); + + bounds.SetMax(new float3(5, 3, 1)); + AreEqual(new float3(-3, -1, -5), bounds.Min, "the min stays"); + AreEqual(new float3(5, 3, 1), bounds.Max, "max"); + + bounds.SetCenter(new float3(-10, 0, 0)); + AreEqual(new float3(-10, 0, 0), bounds.Center, "center"); + bounds.SetExtents(new float3(2, 2, 2)); + AreEqual(new float3(-12, -2, -2), bounds.Min, "resized"); + + bounds.SetMinMax(new float3(-1, -2, -3), new float3(1, 2, 3)); + AreEqual(float3.zero, bounds.Center, "set center"); + AreEqual(new float3(1, 2, 3), bounds.Extents, "set extents"); + + bounds.Encapsulate(new float3(5, 0, 0)); + AreEqual(new float3(-1, -2, -3), bounds.Min, "encapsulated min"); + AreEqual(new float3(5, 2, 3), bounds.Max, "encapsulated max"); + bounds.Encapsulate(new AABB { Center = new float3(0, 0, -10), Extents = new float3(1) }); + AreEqual(new float3(-1, -2, -11), bounds.Min, "encapsulated box"); + } + + [Test] + public void Bounds_Setters_ChangeTheBounds() + { + var bounds = new Bounds(Vector3.zero, Vector3.one * 2); + bounds.SetMin(new float3(-3, -1, -5)); + AreEqual(new float3(-3, -1, -5), bounds.min, "min"); + AreEqual(new float3(1, 1, 1), bounds.max, "the max stays"); + + bounds.SetMax(new float3(5, 3, 1)); + AreEqual(new float3(5, 3, 1), bounds.max, "max"); + + bounds.SetCenter(new float3(0, 10, 0)); + AreEqual(new float3(0, 10, 0), bounds.center, "center"); + bounds.SetExtents(new float3(1, 1, 1)); + AreEqual(new float3(-1, 9, -1), bounds.min, "resized"); + } + + [Test] + public void BoxSettings_MinMaxSizeAndCenter_ChangeTheBox() + { + var box = ChiselBox.DefaultSettings; + box.Min = new float3(-2, -1, -2); + box.Max = new float3(2, 0, 2); + AreEqual(new float3(-2, -1, -2), box.bounds.Min, "min"); + AreEqual(new float3(2, 0, 2), box.bounds.Max, "max"); + + box.Size = new float3(2, 4, 6); + AreEqual(new float3(0, -0.5f, 0), box.Center, "resized around the center"); + AreEqual(new float3(2, 4, 6), box.Size, "size"); + + box.Center = new float3(10, 0, 0); + AreEqual(new float3(9, -2, -3), box.bounds.Min, "moved"); + AreEqual(new float3(2, 4, 6), box.Size, "the size stays"); + } + + [Test] + public void LinearStairsSettings_SizeAndCenter_ChangeTheBounds() + { + var stairs = new ChiselLinearStairs(); + stairs.Reset(); + var center = stairs.Center; + stairs.BoundsSize = new float3(3, 2, 5); + AreEqual(new float3(3, 2, 5), stairs.BoundsSize, "size"); + AreEqual(center, stairs.Center, "resized around the center"); + + stairs.Center = new float3(-4, 1, 7); + AreEqual(new float3(-4, 1, 7), stairs.Center, "center"); + AreEqual(new float3(3, 2, 5), stairs.BoundsSize, "the size stays"); + } + } +} diff --git a/Core/Tests/BoundsExtensionsTests.cs.meta b/Core/Tests/BoundsExtensionsTests.cs.meta new file mode 100644 index 00000000..fdcefa1d --- /dev/null +++ b/Core/Tests/BoundsExtensionsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 452a0e19ff8d75542aadaeb3949e00f9 \ No newline at end of file diff --git a/Core/Tests/BrushFromPlanesPrecisionTests.cs b/Core/Tests/BrushFromPlanesPrecisionTests.cs new file mode 100644 index 00000000..35040db7 --- /dev/null +++ b/Core/Tests/BrushFromPlanesPrecisionTests.cs @@ -0,0 +1,140 @@ +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class BrushFromPlanesPrecisionTests + { + // the bounds a map importer cuts its brushes within + static readonly Bounds kImporterBounds = new Bounds(Vector3.zero, new Vector3(8192, 8192, 8192)); + + // well below the CSG's 0.6 mm tolerance, well above float precision ~120 m from the origin + const double kWorldTolerance = 1e-4; + + // Two tetrahedra about 120 m from the origin that share the vertical face on side 2 + const int kSharedSide = 2; + + static readonly float4[] kPlanesA = + { + new float4(0.0f, 1.0f, 0.0f, -1.1437499523162842f), + new float4(-0.5765119194984436f, -0.6831666231155396f, -0.4482380151748657f, 68.49883270263672f), + new float4(0.7071067690849304f, 0.0f, -0.7071067690849304f, 62.32792663574219f), + new float4(0.6139405965805054f, 0.0f, 0.7893522381782532f, -108.4188461303711f), + }; + + // exact, computed from the plane points + static readonly double3[] kVerticesA = + { + new double3(27.52625, 1.14375, 115.67125), + new double3(26.99298225579761, 1.14375, 116.35712491215742), + new double3(27.67875, 1.14375, 115.82375), + new double3(27.67875, 0.915, 115.82375), + }; + + static readonly float4[] kPlanesB = + { + new float4(0.0f, 1.0f, 0.0f, -1.1437499523162842f), + new float4(-0.4483332931995392f, -0.6831745505332947f, -0.5764285326004028f, 79.79852294921875f), + new float4(-0.7071067690849304f, 0.0f, 0.7071067690849304f, -62.32792663574219f), + new float4(0.7893522381782532f, 0.0f, 0.6139405965805054f, -92.95718383789062f), + }; + + static readonly double3[] kVerticesB = + { + new double3(27.52625, 1.14375, 115.67125), + new double3(28.2125, 1.14375, 115.1375), + new double3(27.67875, 1.14375, 115.82375), + new double3(27.67875, 0.915, 115.82375), + }; + + static BrushMesh CreateFromPlanes(float4[] planes, out ChiselSurfaceArray surfaceArray) + { + surfaceArray = new ChiselSurfaceArray(); + surfaceArray.EnsureSize(planes.Length); + for (int i = 0; i < surfaceArray.surfaces.Length; i++) + surfaceArray.surfaces[i] = new ChiselSurface(); + BrushMeshFactory.CreateFromPlanes(planes, kImporterBounds, ref surfaceArray, out var brushMesh); + return brushMesh; + } + + // the brush as an importer finishes it, its vertices moved back into world space + static double3[] ImportedWorldVertices(float4[] planes, out BrushMesh brushMesh) + { + brushMesh = CreateFromPlanes(planes, out var surfaceArray); + double3 center = (float3)brushMesh.CenterAndSnapPlanes(ref surfaceArray); + var vertices = new double3[brushMesh.vertices.Length]; + for (int v = 0; v < vertices.Length; v++) + vertices[v] = (double3)brushMesh.vertices[v] + center; + return vertices; + } + + [Test] + public void EveryFace_KeepsThePlaneItWasCutWith() + { + var brushMesh = CreateFromPlanes(kPlanesA, out _); + + Assert.That(brushMesh.polygons.Length, Is.EqualTo(kPlanesA.Length)); + for (int p = 0; p < brushMesh.polygons.Length; p++) + { + var expected = kPlanesA[brushMesh.polygons[p].descriptionIndex]; + var actual = brushMesh.planes[p]; + Assert.That(math.cmax(math.abs(actual.xyz - expected.xyz)), Is.LessThan(1e-6f), $"normal of face {p}"); + Assert.That(math.abs(actual.w - expected.w), Is.LessThan(1e-5f), $"offset of face {p}"); + } + } + + [Test] + public void ImportedVertices_AreWhereTheMapsPlanesMeet() + { + AssertMatchesExactVertices(kPlanesA, kVerticesA); + AssertMatchesExactVertices(kPlanesB, kVerticesB); + } + + [Test] + public void TwoBrushesSharingAFacePlane_BothEndUpOnIt() + { + // the shared plane exactly, seen from brush A + var shared = (double4)kPlanesA[kSharedSide]; + + var verticesA = ImportedWorldVertices(kPlanesA, out var brushA); + var verticesB = ImportedWorldVertices(kPlanesB, out var brushB); + + AssertFaceLiesOnPlane(brushA, verticesA, kSharedSide, shared); + AssertFaceLiesOnPlane(brushB, verticesB, kSharedSide, -shared); + } + + static void AssertMatchesExactVertices(float4[] planes, double3[] expected) + { + var vertices = ImportedWorldVertices(planes, out _); + Assert.That(vertices.Length, Is.EqualTo(expected.Length)); + foreach (var vertex in vertices) + { + var nearest = double.MaxValue; + foreach (var exact in expected) + nearest = math.min(nearest, math.distance(vertex, exact)); + Assert.That(nearest, Is.LessThan(kWorldTolerance), $"vertex {vertex}"); + } + } + + static void AssertFaceLiesOnPlane(BrushMesh brushMesh, double3[] worldVertices, int side, double4 plane) + { + int faceCount = 0; + for (int p = 0; p < brushMesh.polygons.Length; p++) + { + ref var polygon = ref brushMesh.polygons[p]; + if (polygon.descriptionIndex != side) + continue; + faceCount++; + for (int e = polygon.firstEdge; e < polygon.firstEdge + polygon.edgeCount; e++) + { + var vertex = worldVertices[brushMesh.halfEdges[e].vertexIndex]; + var distance = math.dot(plane.xyz, vertex) + plane.w; + Assert.That(math.abs(distance), Is.LessThan(kWorldTolerance), $"vertex {vertex} of the face on side {side}"); + } + } + Assert.That(faceCount, Is.EqualTo(1), $"faces on side {side}"); + } + } +} diff --git a/Core/Tests/BrushFromPlanesPrecisionTests.cs.meta b/Core/Tests/BrushFromPlanesPrecisionTests.cs.meta new file mode 100644 index 00000000..5265d1c5 --- /dev/null +++ b/Core/Tests/BrushFromPlanesPrecisionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f0695f6ed82bdf34dae6a6e970971f55 \ No newline at end of file diff --git a/Core/Tests/BrushFromPlanesTouchingPlaneTests.cs b/Core/Tests/BrushFromPlanesTouchingPlaneTests.cs new file mode 100644 index 00000000..abe6d888 --- /dev/null +++ b/Core/Tests/BrushFromPlanesTouchingPlaneTests.cs @@ -0,0 +1,131 @@ +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class BrushFromPlanesTouchingPlaneTests + { + static readonly Bounds kBounds = new Bounds(Vector3.zero, new Vector3(16, 16, 16)); + static readonly float3 kEdgeNormal = math.normalize(new float3(1, 1, 0)); + static readonly float3 kCornerNormal = math.normalize(new float3(1, 1, 1)); + + // A unit box around the origin: -x, +x, -y, +y, -z, +z + static readonly float4[] kBox = + { + new float4(-1, 0, 0, -1), + new float4( 1, 0, 0, -1), + new float4( 0, -1, 0, -1), + new float4( 0, 1, 0, -1), + new float4( 0, 0, -1, -1), + new float4( 0, 0, 1, -1) + }; + + // Touches the box along its (+x, +y) edge, with the box behind it + static float4 EdgePlane => new float4(kEdgeNormal, -math.dot(kEdgeNormal, new float3(1, 1, 0))); + // Touches the box at its (+x, +y, +z) corner, with the box behind it + static float4 CornerPlane => new float4(kCornerNormal, -math.dot(kCornerNormal, new float3(1, 1, 1))); + + static BrushMesh Build(float4[] planes) + { + var surfaceArray = new ChiselSurfaceArray(); + surfaceArray.EnsureSize(planes.Length); + for (int i = 0; i < surfaceArray.surfaces.Length; i++) + surfaceArray.surfaces[i] = new ChiselSurface(); + BrushMeshFactory.CreateFromPlanes(planes, kBounds, ref surfaceArray, out var brushMesh); + return brushMesh; + } + + static float4[] BoxAnd(float4 plane) + { + var planes = new float4[kBox.Length + 1]; + kBox.CopyTo(planes, 0); + planes[kBox.Length] = plane; + return planes; + } + + static void AssertIsTheBox(BrushMesh brushMesh) + { + Assert.That(brushMesh.Validate(), Is.True, "a valid brush"); + Assert.That(brushMesh.polygons.Length, Is.EqualTo(6), "faces"); + Assert.That(brushMesh.vertices.Length, Is.EqualTo(8), "vertices"); + foreach (var vertex in brushMesh.vertices) + Assert.That(math.cmax(math.abs(math.abs(vertex) - 1)), Is.LessThan(1e-5f), $"vertex {vertex} is a corner of the box"); + } + + static void AssertIsEmpty(BrushMesh brushMesh) + { + Assert.That(brushMesh.polygons == null || brushMesh.polygons.Length == 0, Is.True, "no faces"); + } + + [Test] + public void PlaneTouchingAnEdge_KeepsTheBox() + { + AssertIsTheBox(Build(BoxAnd(EdgePlane))); + } + + // Cut first, the plane still has the whole bounds to cut, and its face shrinks away later on + [Test] + public void PlaneTouchingAnEdge_KeepsTheBox_WhenItComesFirst() + { + var planes = new float4[kBox.Length + 1]; + planes[0] = EdgePlane; + kBox.CopyTo(planes, 1); + AssertIsTheBox(Build(planes)); + } + + [Test] + public void PlaneTouchingACorner_KeepsTheBox() + { + AssertIsTheBox(Build(BoxAnd(CornerPlane))); + } + + [Test] + public void PlaneOnAFace_KeepsTheBox_AndTheFaceTakesIt() + { + var brushMesh = Build(BoxAnd(kBox[1])); + AssertIsTheBox(brushMesh); + var onPlane = 0; + foreach (var polygon in brushMesh.polygons) + { + if (polygon.descriptionIndex == kBox.Length) + onPlane++; + } + Assert.That(onPlane, Is.EqualTo(1), "the +x face is described by the extra plane"); + } + + [Test] + public void PlaneMissingTheBox_KeepsIt([Values(-1, 1)] int side) + { + // x >= -3 or x <= 3: the box is well inside either + AssertIsTheBox(Build(BoxAnd(new float4(side, 0, 0, -3)))); + } + + [Test] + public void PlaneWithTheBoxOutsideIt_RemovesIt([Values(-1, 1)] int side) + { + // x <= -3 or x >= 3 + AssertIsEmpty(Build(BoxAnd(new float4(side, 0, 0, 3)))); + } + + [Test] + public void PlaneTouchingAnEdge_WithTheBoxOutsideIt_RemovesIt() + { + AssertIsEmpty(Build(BoxAnd(-EdgePlane))); + } + + [Test] + public void PlaneTouchingACorner_WithTheBoxOutsideIt_RemovesIt() + { + AssertIsEmpty(Build(BoxAnd(-CornerPlane))); + } + + // x <= 1 and x >= 1 leave only a flat slab, which is no brush + [Test] + public void PlaneOnAFace_FacingOutwards_RemovesTheBox() + { + AssertIsEmpty(Build(BoxAnd(-kBox[1]))); + } + } +} diff --git a/Core/Tests/BrushFromPlanesTouchingPlaneTests.cs.meta b/Core/Tests/BrushFromPlanesTouchingPlaneTests.cs.meta new file mode 100644 index 00000000..44abf47d --- /dev/null +++ b/Core/Tests/BrushFromPlanesTouchingPlaneTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 796254243594d9343965c92b1a77fefe \ No newline at end of file diff --git a/Core/Tests/BrushMeshRegistrationTests.cs b/Core/Tests/BrushMeshRegistrationTests.cs new file mode 100644 index 00000000..a9b76ae7 --- /dev/null +++ b/Core/Tests/BrushMeshRegistrationTests.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class BrushMeshRegistrationTests + { + static BrushMeshBlob.HalfEdge[] HalfEdgesOf(ref BrushMeshBlob blob) + { + var result = new BrushMeshBlob.HalfEdge[blob.halfEdges.Length]; + for (int e = 0; e < result.Length; e++) + result[e] = blob.halfEdges[e]; + return result; + } + + static ChiselSurfaceArray SurfacesFor(BrushMesh brushMesh) + { + var surfaceArray = new ChiselSurfaceArray(); + surfaceArray.EnsureSize(brushMesh.polygons.Length); + for (int i = 0; i < surfaceArray.surfaces.Length; i++) + surfaceArray.surfaces[i] = new ChiselSurface(); + return surfaceArray; + } + + // The blob has the mesh's own vertices, bit for bit and in its order, and every half edge still points at its own. + static void AssertAsGiven(BlobAssetReference blobRef, float3[] given, BrushMesh.HalfEdge[] givenHalfEdges, string what) + { + Assert.That(blobRef.IsCreated, Is.True, what + ": registered as nothing"); + ref var blob = ref blobRef.Value; + Assert.That(blob.localVertices.Length, Is.EqualTo(given.Length), what + ": the registered mesh has another number of vertices"); + for (int v = 0; v < given.Length; v++) + Assert.That(math.all(math.asint(blob.localVertices[v]) == math.asint(given[v])), Is.True, + $"{what}: vertex {v} registered as {blob.localVertices[v]}, given {given[v]}"); + var halfEdges = HalfEdgesOf(ref blob); + for (int e = 0; e < halfEdges.Length; e++) + Assert.That(halfEdges[e].vertexIndex, Is.EqualTo(givenHalfEdges[e].vertexIndex), $"{what}: half edge {e} points at another vertex"); + } + + static void AssertRegisteredAsGiven(BrushMesh brushMesh, string what) + { + var given = brushMesh.vertices.ToArray(); + var givenHalfEdges = brushMesh.halfEdges.ToArray(); + var instance = BrushMeshInstance.Create(brushMesh, SurfacesFor(brushMesh)); + try + { + AssertAsGiven(BrushMeshManager.GetBrushMeshBlob(instance), given, givenHalfEdges, what + ", registered on its own"); + } + finally + { + instance.Destroy(); + } + + var brush = CSGTreeBrush.Create(); + try + { + BrushMeshManager.ConvertBrushMeshesToBrushMeshInstances(new List { brush }, new List { brushMesh }, + new List { SurfacesFor(brushMesh) }); + AssertAsGiven(BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh), given, givenHalfEdges, what + ", registered with a scene's brushes"); + } + finally + { + var registered = brush.BrushMesh; + brush.Destroy(); + if (registered.Valid) + registered.Destroy(); + } + } + + static BrushMesh BoxWithGivenPlanes(out float4[] given) + { + BrushMeshFactory.CreateBox(new Vector3(0, 0, 0), new Vector3(1, 1, 1), out var box); + given = box.planes.ToArray(); + for (int v = 0; v < box.vertices.Length; v++) + box.vertices[v] += new float3(0.001f, 0, 0); + return box; + } + + static void AssertPlanesAsGiven(BlobAssetReference blobRef, float4[] given, string what) + { + Assert.That(blobRef.IsCreated, Is.True, what + ": registered as nothing"); + ref var blob = ref blobRef.Value; + Assert.That(blob.localPlaneCount, Is.EqualTo(given.Length), what + ": another number of planes"); + for (int p = 0; p < given.Length; p++) + Assert.That(math.all(math.asint(blob.localPlanes[p]) == math.asint(given[p])), Is.True, + $"{what}: plane {p} registered as {blob.localPlanes[p]}, the mesh gave {given[p]}"); + } + + [Test] + public void GivenPlanes_AreThePlanesRegistered_BothWays() + { + var box = BoxWithGivenPlanes(out var given); + // the fit of the shifted outline really is another plane, or this proves nothing + var fitted = new BrushMesh(box); + fitted.CalculatePlanes(); + Assert.That(Enumerable.Range(0, given.Length).Any(p => !math.all(math.asint(fitted.planes[p]) == math.asint(given[p]))), Is.True, + "the shifted outline fits the given planes anyway"); + + var instance = BrushMeshInstance.Create(new BrushMesh(box), SurfacesFor(box)); + try + { + AssertPlanesAsGiven(BrushMeshManager.GetBrushMeshBlob(instance), given, "registered on its own"); + } + finally + { + instance.Destroy(); + } + + var brush = CSGTreeBrush.Create(); + try + { + BrushMeshManager.ConvertBrushMeshesToBrushMeshInstances(new List { brush }, new List { new BrushMesh(box) }, + new List { SurfacesFor(box) }); + AssertPlanesAsGiven(BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh), given, "registered with a scene's brushes"); + } + finally + { + var registered = brush.BrushMesh; + brush.Destroy(); + if (registered.Valid) + registered.Destroy(); + } + } + + // A mesh without planes still gets them: fitted to its outline + [Test] + public void AMeshWithoutPlanes_GetsThemFitted() + { + var box = BoxWithGivenPlanes(out _); + box.planes = null; + var instance = BrushMeshInstance.Create(box, SurfacesFor(box)); + try + { + var blobRef = BrushMeshManager.GetBrushMeshBlob(instance); + Assert.That(blobRef.IsCreated, Is.True); + Assert.That(blobRef.Value.localPlaneCount, Is.EqualTo(6)); + } + finally + { + instance.Destroy(); + } + } + + // A ChiselBrushComponent's definition validates its outline without replacing the planes it was given + [Test] + public void BrushDefinitionValidate_KeepsTheGivenPlanes() + { + var box = BoxWithGivenPlanes(out var given); + var definition = new ChiselBrushDefinition { brushOutline = box }; + Assert.That(definition.Validate(), Is.True, "the shifted box is a valid brush"); + for (int p = 0; p < given.Length; p++) + Assert.That(math.all(math.asint(definition.BrushOutline.planes[p]) == math.asint(given[p])), Is.True, + $"plane {p} became {definition.BrushOutline.planes[p]}, it was given {given[p]}"); + } + + static BrushMesh TheBoxWithAnotherPlane(out float4[] given) + { + var box = BoxWithGivenPlanes(out given); + given[0].w = math.asfloat(math.asuint(given[0].w) + 1u); + box.planes = given.ToArray(); + return box; + } + + // Registration shares a mesh only with the same mesh: two brushes whose vertices are the same but whose planes differ + // each get their own planes, however their hashes compare + [Test] + public void MeshesThatDifferOnlyInTheirPlanes_EachKeepTheirOwnPlanes_BothWays() + { + var first = BoxWithGivenPlanes(out var givenFirst); + first.planes = givenFirst.ToArray(); + var second = TheBoxWithAnotherPlane(out var givenSecond); + + var a = BrushMeshInstance.Create(new BrushMesh(first), SurfacesFor(first)); + var b = BrushMeshInstance.Create(new BrushMesh(second), SurfacesFor(second)); + try + { + AssertPlanesAsGiven(BrushMeshManager.GetBrushMeshBlob(a), givenFirst, "the first, registered on its own"); + AssertPlanesAsGiven(BrushMeshManager.GetBrushMeshBlob(b), givenSecond, "the second, registered on its own"); + } + finally + { + a.Destroy(); + b.Destroy(); + } + + var brushes = new List { CSGTreeBrush.Create(), CSGTreeBrush.Create() }; + try + { + BrushMeshManager.ConvertBrushMeshesToBrushMeshInstances(brushes, new List { new BrushMesh(first), new BrushMesh(second) }, + new List { SurfacesFor(first), SurfacesFor(second) }); + AssertPlanesAsGiven(BrushMeshManager.GetBrushMeshBlob(brushes[0].BrushMesh), givenFirst, "the first, registered with a scene's brushes"); + AssertPlanesAsGiven(BrushMeshManager.GetBrushMeshBlob(brushes[1].BrushMesh), givenSecond, "the second, registered with a scene's brushes"); + } + finally + { + foreach (var brush in brushes) + { + var registered = brush.BrushMesh; + brush.Destroy(); + if (registered.Valid) + registered.Destroy(); + } + } + } + + // A mesh set again with other planes is registered with those, not kept as it was + [Test] + public void AMeshSetAgainWithOtherPlanes_IsRegisteredWithThem() + { + var first = BoxWithGivenPlanes(out var givenFirst); + first.planes = givenFirst.ToArray(); + var second = TheBoxWithAnotherPlane(out var givenSecond); + var instance = BrushMeshInstance.Create(new BrushMesh(first), SurfacesFor(first)); + try + { + var before = instance.BrushMeshID; + instance.Set(new BrushMesh(second), SurfacesFor(second)); + AssertPlanesAsGiven(BrushMeshManager.GetBrushMeshBlob(instance), givenSecond, "set again"); + Assert.That(instance.BrushMeshID, Is.Not.EqualTo(before), "the changed mesh kept its registration's id, so nothing sees the change"); + } + finally + { + instance.Destroy(); + } + } + + // The same mesh twice is still one registration + [Test] + public void TheSameMeshTwice_IsOneRegistration() + { + var box = BoxWithGivenPlanes(out var given); + box.planes = given.ToArray(); + var a = BrushMeshInstance.Create(new BrushMesh(box), SurfacesFor(box)); + var b = BrushMeshInstance.Create(new BrushMesh(box), SurfacesFor(box)); + try + { + Assert.That(b.BrushMeshID, Is.EqualTo(a.BrushMeshID)); + } + finally + { + a.Destroy(); + b.Destroy(); + } + } + + [Test] + public void TheContentsHarness_ReplaysAMeshWithTheScenesPlanes() + { + var box = BoxWithGivenPlanes(out var given); + var loops = new List(); + foreach (var polygon in box.polygons) + { + for (int e = 0; e < polygon.edgeCount; e++) + loops.Add(box.halfEdges[polygon.firstEdge + e].vertexIndex); + loops.Add(-1); + } + var node = ContentsSceneNode.Brush(given, name: "box").WithMesh(box.vertices.ToArray(), loops.ToArray()); + using (var harness = ContentsTreeHarness.Build(new ContentsScene().Add(node))) + { + Assert.That(harness.PlaneMismatches, Is.Empty, "the replay registered other planes than the scene's"); + AssertPlanesAsGiven(BrushMeshManager.GetBrushMeshBlob(harness.TreeBrushOf(node).BrushMesh), given, "replayed by the harness"); + } + } + + // A box 8 mm on each side: every corner is within 1.25 cm of three others + [Test] + public void AnEightMillimetreBox_KeepsItsEightCorners() + { + BrushMeshFactory.CreateBox(new Vector3(0, 0, 0), new Vector3(0.008f, 0.008f, 0.008f), out var box); + Assert.That(box, Is.Not.Null); + AssertRegisteredAsGiven(box, "an 8 mm box"); + } + + // A brush outline as a scene saved it (ChiselBrushComponent.definition.brushOutline) + [Test] + public void ABrushOutlineASceneSaved_KeepsItsSixteenVertices() + { + var vertices = new[] + { + new float3(0.018382946f, 0.037841376f, 0.025479233f), new float3(0.011572085f, 0.047798112f, 0.00022935924f), + new float3(0.018362597f, 0.03767041f, -0.02558761f), new float3(0.0323582f, 0.016956896f, -0.038097184f), + new float3(0.046823606f, -0.0043485626f, -0.024765162f), new float3(0.05435298f, -0.015367907f, 0.000044269273f), + new float3(0.04675661f, -0.00405226f, 0.02538101f), new float3(0.032562945f, 0.01695478f, 0.03816659f), + new float3(-0.046486318f, -0.03621872f, 0.014354243f), new float3(-0.041082278f, -0.04393878f, 0.009641586f), + new float3(-0.041345753f, -0.043562386f, -0.009550629f), new float3(-0.038241617f, -0.047996864f, 0.00048045514f), + new float3(-0.051757906f, -0.028687883f, 0.009702293f), new float3(-0.051819477f, -0.028599922f, -0.009569874f), + new float3(-0.046544276f, -0.036135927f, -0.0142104775f), new float3(-0.05440427f, -0.024907364f, 0.000018829056f) + }; + var halfEdges = new (int vertex, int twin)[] + { + (6, 30), (5, 34), (4, 26), (3, 38), (2, 18), (1, 10), (0, 14), (7, 22), (15, 40), (1, 15), (2, 5), (13, 17), + (12, 47), (0, 23), (1, 6), (15, 9), (13, 41), (2, 11), (3, 4), (14, 37), (8, 46), (7, 31), (0, 7), (12, 13), + (10, 43), (4, 39), (5, 2), (11, 33), (9, 45), (6, 35), (7, 0), (8, 21), (11, 44), (5, 27), (6, 1), (9, 29), + (14, 42), (3, 19), (4, 3), (10, 25), (13, 8), (14, 16), (10, 36), (11, 24), (9, 32), (8, 28), (12, 20), (15, 12) + }; + var polygons = new (int firstEdge, int edgeCount)[] { (0, 8), (8, 4), (12, 4), (16, 4), (20, 4), (24, 4), (28, 4), (32, 4), (36, 4), (40, 8) }; + var brushMesh = new BrushMesh + { + vertices = vertices, + halfEdges = halfEdges.Select(h => new BrushMesh.HalfEdge { vertexIndex = h.vertex, twinIndex = h.twin }).ToArray(), + polygons = polygons.Select((p, i) => new BrushMesh.Polygon { firstEdge = p.firstEdge, edgeCount = p.edgeCount, descriptionIndex = i }).ToArray() + }; + // what the scene's component does with its outline before registering it (as BrushMeshFactory.CreateBox does) + brushMesh.UpdateHalfEdgePolygonIndices(); + brushMesh.CalculatePlanes(); + Assert.That(brushMesh.Validate(logErrors: true), Is.True, "the saved mesh itself is valid"); + AssertRegisteredAsGiven(brushMesh, "the saved outline"); + } + } +} diff --git a/Core/Tests/BrushMeshRegistrationTests.cs.meta b/Core/Tests/BrushMeshRegistrationTests.cs.meta new file mode 100644 index 00000000..2f0ced4b --- /dev/null +++ b/Core/Tests/BrushMeshRegistrationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7a8e9bf4ab1cb654c8cf3a1bdb0c8516 \ No newline at end of file diff --git a/Core/Tests/BrushMeshTests.cs b/Core/Tests/BrushMeshTests.cs new file mode 100644 index 00000000..80c20d74 --- /dev/null +++ b/Core/Tests/BrushMeshTests.cs @@ -0,0 +1,169 @@ +using System; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + // End-to-end checks over the managed BrushMesh: building a box through the factory and + // exercising the validation / topology / containment queries the CSG input relies on. + [TestFixture] + public class BrushMeshTests + { + static BrushMesh MakeUnitBox() + { + BrushMeshFactory.CreateBox(new Vector3(2, 2, 2), 0, out var box); // centered, -1..1 + Assert.That(box, Is.Not.Null, "factory failed to create a box"); + return box; + } + + [Test] + public void CreateBox_ProducesValidTopology() + { + var box = MakeUnitBox(); + + Assert.That(box.vertices.Length, Is.EqualTo(8)); + Assert.That(box.polygons.Length, Is.EqualTo(6)); + Assert.That(box.halfEdges.Length, Is.EqualTo(24)); + Assert.That(box.planes.Length, Is.EqualTo(6)); + Assert.That(box.halfEdgePolygonIndices.Length, Is.EqualTo(24)); + } + + [Test] + public void CreateBox_PassesValidation() + { + var box = MakeUnitBox(); + Assert.That(box.Validate(logErrors: true), Is.True); + Assert.That(box.ValidateData(out var message), Is.True); + Assert.That(message, Is.Null); + Assert.That(box.ValidateShape(out _), Is.True); + } + + [Test] + public void CreateBox_ShapeClassification() + { + var box = MakeUnitBox(); + Assert.That(box.IsEmpty(), Is.False); + Assert.That(box.HasVolume(), Is.True); + Assert.That(box.IsConcave(), Is.False); + Assert.That(box.IsInsideOut(), Is.False); + Assert.That(box.IsSelfIntersecting(), Is.False); + } + + [Test] + public void IsInside_DistinguishesInteriorExteriorAndSurface() + { + var box = MakeUnitBox(); + Assert.That(box.IsInside(float3.zero), Is.True); + Assert.That(box.IsInside(new float3(5, 5, 5)), Is.False); + + // A point exactly on a face is not strictly inside, but is inside-or-on. + Assert.That(box.IsInside(new float3(1, 0, 0)), Is.False); + Assert.That(box.IsInsideOrOn(new float3(1, 0, 0)), Is.True); + Assert.That(box.IsInsideOrOn(new float3(1.1f, 0, 0)), Is.False); + } + + [Test] + public void GetPolygonCenter_ReturnsUnitDistanceFaceCenters() + { + var box = MakeUnitBox(); + for (int p = 0; p < box.polygons.Length; p++) + { + var center = box.GetPolygonCenter(p); + Assert.That(math.length(center), Is.EqualTo(1f).Within(1e-4f), + $"polygon {p} center {center} should be one unit from the box centre"); + } + } + + [Test] + public void GetPolygonCenter_OutOfRange_Throws() + { + var box = MakeUnitBox(); + Assert.Throws(() => box.GetPolygonCenter(99)); + Assert.Throws(() => box.GetPolygonCenter(-1)); + } + + [Test] + public void FindVertexIndexOfVertex_FindsExistingMissesAbsent() + { + var box = MakeUnitBox(); + Assert.That(box.FindVertexIndexOfVertex(box.vertices[3]), Is.EqualTo(3)); + Assert.That(box.FindVertexIndexOfVertex(new float3(100, 100, 100)), Is.EqualTo(-1)); + } + + [Test] + public void FindEdgeByVertexIndices_FindsConnectedVertices() + { + var box = MakeUnitBox(); + var headVertex = box.halfEdges[0].vertexIndex; + var tailVertex = box.halfEdges[box.halfEdges[0].twinIndex].vertexIndex; + + Assert.That(box.FindEdgeByVertexIndices(tailVertex, headVertex), Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public void CreateBox_MinMaxOverload_Matches() + { + BrushMeshFactory.CreateBox(new Vector3(-1, -1, -1), new Vector3(1, 1, 1), out var box); + Assert.That(box, Is.Not.Null); + Assert.That(box.Validate(), Is.True); + Assert.That(box.IsInside(float3.zero), Is.True); + } + + [Test] + public void CreateBox_DegenerateBounds_ProducesNull() + { + BrushMeshFactory.CreateBox(Vector3.zero, Vector3.zero, out var box); + Assert.That(box, Is.Null); + } + + [Test] + public void CopyConstructor_ProducesIndependentValidMesh() + { + var box = MakeUnitBox(); + var copy = new BrushMesh(box); + + Assert.That(copy.Validate(), Is.True); + Assert.That(copy.vertices.Length, Is.EqualTo(box.vertices.Length)); + Assert.That(copy.halfEdges.Length, Is.EqualTo(box.halfEdges.Length)); + + // Mutating the copy must not touch the original. + copy.vertices[0] = new float3(42, 42, 42); + Assert.That(box.vertices[0].x, Is.Not.EqualTo(42f)); + } + + [Test] + public void EmptyBrushMesh_FailsValidation() + { + var empty = new BrushMesh(); + Assert.That(empty.IsEmpty(), Is.True); + Assert.That(empty.Validate(), Is.False); + Assert.That(empty.ValidateData(out var message), Is.False); + Assert.That(message, Is.Not.Null); + } + + [Test] + public void ValidateData_DetectsBrokenTwinIndex() + { + var broken = new BrushMesh(MakeUnitBox()); + broken.halfEdges[0] = new BrushMesh.HalfEdge + { + vertexIndex = broken.halfEdges[0].vertexIndex, + twinIndex = 999 // out of range + }; + Assert.That(broken.ValidateData(out _), Is.False); + } + + [Test] + public void ValidateData_DetectsOutOfRangeVertexIndex() + { + var broken = new BrushMesh(MakeUnitBox()); + broken.halfEdges[0] = new BrushMesh.HalfEdge + { + vertexIndex = 999, // out of range + twinIndex = broken.halfEdges[0].twinIndex + }; + Assert.That(broken.ValidateData(out _), Is.False); + } + } +} diff --git a/Core/Tests/BrushMeshTests.cs.meta b/Core/Tests/BrushMeshTests.cs.meta new file mode 100644 index 00000000..801aea39 --- /dev/null +++ b/Core/Tests/BrushMeshTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc2860e542ecf3048a3b178916387ff6 \ No newline at end of file diff --git a/Core/Tests/BurstRegistrationTests.cs b/Core/Tests/BurstRegistrationTests.cs new file mode 100644 index 00000000..0c09172a --- /dev/null +++ b/Core/Tests/BurstRegistrationTests.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Jobs; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public sealed class BurstRegistrationTests + { + static HashSet Registered() + { + return new HashSet(typeof(ChiselTreeLookup).Assembly.GetCustomAttributes() + .Select(attribute => attribute.ConcreteType)); + } + + [Test] + public void EveryBlobCacheOfATree_HasItsDisposeJobRegisteredForBurst() + { + var registered = Registered(); + var caches = new List<(string field, System.Type blob)>(); + foreach (var field in typeof(ChiselTreeLookup.Data).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + var type = field.FieldType; + if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(NativeList<>)) + continue; + var element = type.GetGenericArguments()[0]; + if (!element.IsGenericType || element.GetGenericTypeDefinition() != typeof(BlobAssetReference<>)) + continue; + caches.Add((field.Name, element.GetGenericArguments()[0])); + } + System.Type DisposeJobOf(System.Type blob) => typeof(DisposeListChildrenBlobAssetReferenceJob<>).MakeGenericType(blob); + + // both sides are read: the caches are found, and the registrations of some of them as well + Assert.That(caches, Is.Not.Empty, "no blob cache found in ChiselTreeLookup.Data"); + Assert.That(caches.Any(cache => registered.Contains(DisposeJobOf(cache.blob))), Is.True, + "no registration of DisposeListChildrenBlobAssetReferenceJob read from com.chisel.core"); + + var missing = caches.Where(cache => !registered.Contains(DisposeJobOf(cache.blob))) + .Select(cache => cache.field + " (" + cache.blob.Name + ")").ToList(); + Assert.That(missing, Is.Empty, "blob caches whose dispose job Burst cannot see: " + string.Join(", ", missing)); + } + + [Test] + public void EveryGeneratorOfTheCore_HasItsJobsRegisteredForBurst() + { + var registered = Registered(); + var brushJobs = new[] { typeof(CreateBrushesJob<>) }; + var branchJobs = new[] { typeof(BranchPrepareAndCountBrushesJob<>), typeof(BranchAllocateBrushesJob<>), + typeof(BranchCreateBrushesJob<>), typeof(GeneratorBranchJobPool<>.InitializeArraysJob) }; + var generators = new List(); + var missing = new List(); + foreach (var type in typeof(ChiselTreeLookup).Assembly.GetTypes()) + { + if (!type.IsValueType || type.IsGenericTypeDefinition) + continue; + var jobs = typeof(IBranchGenerator).IsAssignableFrom(type) ? branchJobs + : typeof(IBrushGenerator).IsAssignableFrom(type) ? brushJobs + : null; + if (jobs == null) + continue; + generators.Add(type); + foreach (var job in jobs) + { + if (!registered.Contains(job.MakeGenericType(type))) + missing.Add(type.Name + ": " + job.Name.Split('`')[0]); + } + } + + // both sides are read: the generators are found, and the registrations of some of them as well + Assert.That(generators, Is.Not.Empty, "no generator found in com.chisel.core"); + Assert.That(missing.Count, Is.LessThan(generators.Sum(type => typeof(IBranchGenerator).IsAssignableFrom(type) ? branchJobs.Length : brushJobs.Length)), + "no registration of a generator's job read from com.chisel.core"); + + Assert.That(missing, Is.Empty, "generator jobs Burst cannot see: " + string.Join(", ", missing)); + } + + [Test] public void ALinearStairs_IsGeneratedThroughItsJobs() { Generate(new ChiselLinearStairsDefinition()); } + [Test] public void APathedStairs_IsGeneratedThroughItsJobs() { Generate(new ChiselPathedStairsDefinition()); } + [Test] public void ARevolvedShape_IsGeneratedThroughItsJobs() { Generate(new ChiselRevolvedShapeDefinition()); } + [Test] public void ASpiralStairs_IsGeneratedThroughItsJobs() { Generate(new ChiselSpiralStairsDefinition()); } + [Test] public void ATorus_IsGeneratedThroughItsJobs() { Generate(new ChiselTorusDefinition()); } + [Test] public void AnExtrudedShape_IsGeneratedThroughItsJobs(){ Generate(new ChiselExtrudedShapeDefinition()); } + + static void Generate(SerializedBranchGenerator definition) + where Generator : unmanaged, IBranchGenerator + { + definition.Reset(); + Assert.That(definition.Validate(), Is.True, "the default definition is not valid"); + var pool = new GeneratorBranchJobPool(); + var tree = CSGTree.Create(default(UnityEngine.EntityId)); + try + { + var branch = tree.CreateBranch(); + pool.ScheduleUpdate(branch, definition.GetBranchGenerator(), + BrushMeshManager.BuildInternalSurfaceArrayBlob(definition.RequiredSurfaceCount, Allocator.TempJob)); + GeneratorJobPoolManager.ScheduleJobs(runInParallel: false).Complete(); + Assert.That(branch.Count, Is.GreaterThan(0), typeof(Generator).Name + " made no brushes"); + } + finally + { + GeneratorJobPoolManager.Clear(); + pool.Dispose(); + if (tree.Valid) + tree.Destroy(); + } + } + } +} diff --git a/Core/Tests/BurstRegistrationTests.cs.meta b/Core/Tests/BurstRegistrationTests.cs.meta new file mode 100644 index 00000000..3f30a13f --- /dev/null +++ b/Core/Tests/BurstRegistrationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3700b271b1d53a14ca8dc98eb80586ab \ No newline at end of file diff --git a/Core/Tests/CSGConstantsTests.cs b/Core/Tests/CSGConstantsTests.cs new file mode 100644 index 00000000..fab4d514 --- /dev/null +++ b/Core/Tests/CSGConstantsTests.cs @@ -0,0 +1,42 @@ +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + // The tolerances the CSG depends on, and how the squared and linear ones relate + [TestFixture] + public class CSGConstantsTests + { + [Test] + public void Epsilons_ArePositive() + { + Assert.That(CSGConstants.kBoundsDistanceEpsilon, Is.GreaterThan(0f)); + Assert.That(CSGConstants.kFatPlaneWidthEpsilon, Is.GreaterThan(0f)); + Assert.That(CSGConstants.kEdgeIntersectionEpsilon, Is.GreaterThan(0f)); + Assert.That(CSGConstants.kVertexEqualEpsilon, Is.GreaterThan(0f)); + Assert.That(CSGConstants.kPlaneDAlignEpsilon, Is.GreaterThan(0f)); + Assert.That(CSGConstants.kDivideMinimumEpsilon, Is.GreaterThan(0.0)); + } + + [Test] + public void VertexEqualEpsilon_HasExpectedValue() + { + Assert.That(CSGConstants.kVertexEqualEpsilon, Is.EqualTo(0.0125f).Within(1e-6f)); + } + + [Test] + public void SquaredEpsilons_MatchTheirLinearVariants() + { + Assert.That(CSGConstants.kSqrVertexEqualEpsilon, + Is.EqualTo(CSGConstants.kVertexEqualEpsilon * CSGConstants.kVertexEqualEpsilon).Within(1e-9f)); + Assert.That(CSGConstants.kSqrEdgeDistanceEpsilon, + Is.EqualTo(CSGConstants.kEdgeIntersectionEpsilon * CSGConstants.kEdgeIntersectionEpsilon).Within(1e-9f)); + } + + [Test] + public void NormalDotAlignEpsilon_IsCloseToButBelowOne() + { + Assert.That(CSGConstants.kNormalDotAlignEpsilon, Is.LessThan(1f)); + Assert.That(CSGConstants.kNormalDotAlignEpsilon, Is.GreaterThan(0.99f)); + } + } +} diff --git a/Core/Tests/CSGConstantsTests.cs.meta b/Core/Tests/CSGConstantsTests.cs.meta new file mode 100644 index 00000000..e56fa8b1 --- /dev/null +++ b/Core/Tests/CSGConstantsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c7f055c7ba4a52d4a91b9f7bc1c38fd5 \ No newline at end of file diff --git a/Core/Tests/CSGMathTests.cs b/Core/Tests/CSGMathTests.cs new file mode 100644 index 00000000..79546ace --- /dev/null +++ b/Core/Tests/CSGMathTests.cs @@ -0,0 +1,181 @@ +using System; +using NUnit.Framework; +using Unity.Mathematics; +using static Chisel.Core.Tests.ChiselTestUtility; + +namespace Chisel.Core.Tests +{ + // Pins the behavior of the centralized CSG math (CSGMath): these assert the exact geometric results + // of the operations the library provides, on well-separated cases that don't exercise rounding. + [TestFixture] + public class CSGMathTests + { + [Test] + public void SignedDistance_IsDotPlanePoint() + { + var plane = new float4(1, 0, 0, -1); // x = 1 + Assert.That(CSGMath.SignedDistance(plane, new float3(3, 0, 0)), Is.EqualTo(2f).Within(1e-5f)); + Assert.That(CSGMath.SignedDistance(plane, new float3(1, 5, 9)), Is.EqualTo(0f).Within(1e-5f)); + Assert.That(CSGMath.SignedDistance(plane, new float3(0, 0, 0)), Is.EqualTo(-1f).Within(1e-5f)); + // float4 overload agrees + Assert.That(CSGMath.SignedDistance(plane, new float4(3, 0, 0, 1)), Is.EqualTo(2f).Within(1e-5f)); + } + + [Test] + public void SqrDistance_And_VerticesEqual() + { + Assert.That(CSGMath.SqrDistance(new float3(0, 0, 0), new float3(3, 4, 0)), Is.EqualTo(25f).Within(1e-4f)); + // within the weld tolerance (kVertexEqualEpsilon ~0.0125): nearly coincident -> equal + Assert.That(CSGMath.VerticesEqual(new float3(0, 0, 0), new float3(0.001f, 0, 0)), Is.True); + // clearly separated -> not equal + Assert.That(CSGMath.VerticesEqual(new float3(0, 0, 0), new float3(1, 0, 0)), Is.False); + } + + [Test] + public void EdgePlaneCrossing_HitsThePlane() + { + // segment (0,0,0)[d=+1] -> (0,0,2)[d=-1] crosses at the midpoint (0,0,1). + var p = CSGMath.EdgePlaneCrossing(new float3(0, 0, 0), new float3(0, 0, 2), 1f, -1f); + AssertApproximately(new float3(0, 0, 1), p, 1e-4f); + // direction-independent: same segment given in reverse with swapped distances -> same point. + var q = CSGMath.EdgePlaneCrossing(new float3(0, 0, 2), new float3(0, 0, 0), -1f, 1f); + AssertApproximately(new float3(0, 0, 1), q, 1e-4f); + } + + [Test] + public void EdgePlaneCrossing_OffCenter() + { + // d0=3, d1=-1 -> delta=3/4, crossing at v0 - (v0-v1)*0.75 = (0,0,0)-(0,0,-4)*0.75 = (0,0,3) + var p = CSGMath.EdgePlaneCrossing(new float3(0, 0, 0), new float3(0, 0, 4), 3f, -1f); + AssertApproximately(new float3(0, 0, 3), p, 1e-4f); + } + + [Test] + public void PlaneIntersection_ThreeAxisPlanes() + { + var x1 = new double4(1, 0, 0, -1); // x = 1 + var y2 = new double4(0, 1, 0, -2); // y = 2 + var z3 = new double4(0, 0, 1, -3); // z = 3 + var p = CSGMath.PlaneIntersection(x1, y2, z3); + AssertApproximately(new double3(1, 2, 3), p, 1e-3); + } + + [Test] + public void Orient2D_SignsAreConsistent() + { + var a = new double2(0, 0); + var b = new double2(1, 0); + var c = new double2(0, 1); + var ccw = CSGMath.Orient2D(a, b, c); + var cw = CSGMath.Orient2D(a, c, b); // reversed winding + Assert.That(ccw, Is.Not.EqualTo(0.0)); + Assert.That(math.sign(ccw), Is.EqualTo(-math.sign(cw))); // opposite signs + // collinear -> zero + Assert.That(CSGMath.Orient2D(new double2(0, 0), new double2(1, 1), new double2(2, 2)), Is.EqualTo(0.0).Within(1e-12)); + } + + // error-free transformations + + static System.Numerics.BigInteger Exact(double value) + { + long bits = BitConverter.DoubleToInt64Bits(value); + int rawExp = (int)((bits >> 52) & 0x7FF); + long man = bits & 0xFFFFFFFFFFFFFL; + if (rawExp == 0) rawExp = 1; // subnormal: no implicit leading bit + else man |= 1L << 52; // normal: restore the implicit leading bit + var scaled = new System.Numerics.BigInteger(man) << rawExp; // == value * 2^1075 + return bits < 0 ? -scaled : scaled; + } + + [Test] + public void Exact_RoundTripsDoublesThatDecimalWouldRound() + { + // the helper above, and why decimal can't be used for this + Assert.That(Exact(1.0) + Exact(1.0), Is.EqualTo(Exact(2.0))); + Assert.That(Exact(-1e8) + Exact(1e8), Is.EqualTo(System.Numerics.BigInteger.Zero)); + Assert.That(Exact(0.0), Is.EqualTo(System.Numerics.BigInteger.Zero)); + + // The double sum loses information, which err carries: s + err restores it + double a = -1e8, b = 7.000001; + CSGMath.TwoSum(a, b, out var s, out var err); + Assert.That(Exact(s), Is.Not.EqualTo(Exact(a) + Exact(b)), + "the double sum alone cannot represent a + b here"); + Assert.That(Exact(s) + Exact(err), Is.EqualTo(Exact(a) + Exact(b)), + "but s + err does, exactly - which is the whole point of the transformation"); + + // decimal can't show that: at this magnitude it rounds to 1e-7, coarser than err (~1.6e-9) + Assert.That((decimal)s + (decimal)err, Is.Not.EqualTo((decimal)a + (decimal)b), + "this is the old assertion; it fails even though TwoSum is exact"); + } + + [Test] + public void TwoSum_IsExact() + { + // a + b == s + err exactly, even when b is far below a's ulp. + foreach (var (a, b) in new[] { (1e16, 1.0), (1.0, 1e-16), (3.0, 1e0), (-1e8, 7.000001) }) + { + CSGMath.TwoSum(a, b, out var s, out var err); + Assert.That(Exact(s) + Exact(err), Is.EqualTo(Exact(a) + Exact(b)), + $"TwoSum({a},{b}) not exact"); + } + } + + [Test] + public void TwoProduct_IsExact() + { + // a * b == p + err exactly. Both sides are scaled by 2^2150 so the product's scale matches. + foreach (var (a, b) in new[] { (100000001.0, 100000001.0), (1.0000000001, 1.0000000001), (12345.6789, -98765.4321) }) + { + CSGMath.TwoProduct(a, b, out var p, out var err); + Assert.That((Exact(p) + Exact(err)) << 1075, Is.EqualTo(Exact(a) * Exact(b)), + $"TwoProduct({a},{b}) not exact"); + } + } + + [Test] + public void Orient2D_CompensatedBeatsNaive_OnCatastrophicCancellation() + { + var p = new double2(0, 0); + var q = new double2(1e8, 1e8 + 1); + var r = new double2(2e8 + 1, 2e8 + 3); + + double a = q.y - p.y, b = r.x - q.x, c = q.x - p.x, d = r.y - q.y; + double naive = a * b - c * d; + Assert.That(naive, Is.EqualTo(0.0), "expected naive double to lose the cancellation"); + + // compensated orient2d recovers the true sign (+). + Assert.That(CSGMath.Orient2D(p, q, r), Is.GreaterThan(0.0)); + } + + [Test] + public void Orient2D_MatchesExactReference_OnRandomishCancellation() + { + // For a battery of near-collinear triples, the compensated orient2d sign must equal the exact + // (decimal) sign of a*b - c*d computed from the same double differences. + var cases = new[] + { + (new double2(0, 0), new double2(1e8, 1e8 + 1), new double2(2e8 + 1, 2e8 + 3)), + (new double2(0.5, 0.5), new double2(1e7 + 0.5, 1e7 + 0.5), new double2(2e7 + 0.5, 2e7 + 0.5)), + (new double2(-1e6, 3), new double2(1e6 + 1, 5), new double2(3e6 + 2, 7)), + }; + foreach (var (p, q, r) in cases) + { + decimal a = (decimal)(q.y - p.y), b = (decimal)(r.x - q.x), c = (decimal)(q.x - p.x), d = (decimal)(r.y - q.y); + int refSign = Math.Sign(a * b - c * d); + Assert.That(Math.Sign(CSGMath.Orient2D(p, q, r)), Is.EqualTo(refSign)); + } + } + + [Test] + public void NewellTerm_AccumulatesUnitSquareNormal() + { + // CCW unit square in the XY plane -> normal should point +Z. + var v = new[] { new float3(0, 0, 0), new float3(1, 0, 0), new float3(1, 1, 0), new float3(0, 1, 0) }; + var n = float3.zero; + for (int i = 0; i < v.Length; i++) + n += CSGMath.NewellTerm(v[i], v[(i + 1) % v.Length]); + n = math.normalize(n); + AssertApproximately(new float3(0, 0, 1), n, 1e-4f); + } + } +} diff --git a/Core/Tests/CSGMathTests.cs.meta b/Core/Tests/CSGMathTests.cs.meta new file mode 100644 index 00000000..07cbc551 --- /dev/null +++ b/Core/Tests/CSGMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c116ad386737d384c82d0bf389798a0f \ No newline at end of file diff --git a/Core/Tests/CanonicalVerticesTests.cs b/Core/Tests/CanonicalVerticesTests.cs new file mode 100644 index 00000000..40674796 --- /dev/null +++ b/Core/Tests/CanonicalVerticesTests.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class CanonicalVerticesTests + { + // The pipeline inputs canonical vertices read, for brushes given as tree-space face planes with outward normals + // (n·x + w <= 0 inside). Every brush touches every other one. + sealed class Scene : IDisposable + { + public NativeList> treeSpacePlanes; + public NativeList> touched; + + public Scene(params float4[][] brushes) + { + treeSpacePlanes = new NativeList>(brushes.Length, Allocator.Persistent); + touched = new NativeList>(brushes.Length, Allocator.Persistent); + for (int b = 0; b < brushes.Length; b++) + { + // Normalized as CreateBrushTreeSpacePlanesJob does it + treeSpacePlanes.Add(Blob(brushes[b].Select(p => p / math.length(p.xyz)).ToArray())); + touched.Add(Touching(b, brushes.Length)); + } + } + + public CanonicalVertices Canonical(CanonicalVertexStage stage = CanonicalVertexStage.Positions) + { + return CanonicalVertices.Create(stage, treeSpacePlanes, touched); + } + + public void Dispose() + { + foreach (var blob in treeSpacePlanes) blob.Dispose(); + foreach (var blob in touched) blob.Dispose(); + treeSpacePlanes.Dispose(); + touched.Dispose(); + } + + static BlobAssetReference Blob(float4[] planes) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var array = builder.Allocate(ref root.treeSpacePlanes, planes.Length); + for (int i = 0; i < planes.Length; i++) + array[i] = planes[i]; + root.faceCount = planes.Length; + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + static BlobAssetReference Touching(int self, int count) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var list = builder.Allocate(ref root.brushIntersections, count - 1); + int n = 0; + for (int i = 0; i < count; i++) + { + if (i != self) + list[n++] = new BrushIntersection { nodeIndexOrder = new IndexOrder { nodeOrder = i }, type = IntersectionType.Intersection }; + } + builder.Allocate(ref root.intersectionBits, 0); + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + } + + // An axis-aligned box as six outward planes. + static float4[] Box(float3 min, float3 max) + { + return new[] + { + new float4(-1, 0, 0, min.x), new float4(1, 0, 0, -max.x), + new float4(0, -1, 0, min.y), new float4(0, 1, 0, -max.y), + new float4(0, 0, -1, min.z), new float4(0, 0, 1, -max.z), + }; + } + + // Rotates planes: a plane (n, w) through a rotation R becomes (R n, w). + static float4[] Rotate(float4[] planes, quaternion rotation) + { + return planes.Select(p => new float4(math.mul(rotation, p.xyz), p.w)).ToArray(); + } + + static readonly quaternion kTurn = quaternion.Euler(0.3f, 0.7f, -0.2f); + + static bool SameBits(float a, float b) => BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b); + static bool SameBits(float3 a, float3 b) => SameBits(a.x, b.x) && SameBits(a.y, b.y) && SameBits(a.z, b.z); + static bool SameBits(float4 a, float4 b) => SameBits(a.xyz, b.xyz) && SameBits(a.w, b.w); + + // Two turned boxes side by side. The second's shared face is nudged 0.2 um, so the brushes' own planes + // differ, as they do after transforms. + static float4[][] TwoTurnedBoxes() + { + var a = Rotate(Box(new float3(0, 0, 0), new float3(1, 1, 1)), kTurn); + var b = Rotate(Box(new float3(1, 0, 0), new float3(2, 1, 1)), kTurn); + b[0].w += 2e-7f; + return new[] { a, b }; + } + + static float3 SharedCorner => math.mul(kTurn, new float3(1, 0, 0)); + + [Test] + public void ASharedCorner_HasTheSameBitsFromEitherBrush() + { + using var scene = new Scene(TwoTurnedBoxes()); + var canonical = scene.Canonical(); + + var fromA = canonical.Canonicalize(SharedCorner + new float3(1e-6f, -2e-6f, 0), 0); + var fromB = canonical.Canonicalize(SharedCorner + new float3(-2e-6f, 1e-6f, 1e-6f), 1); + + Assert.That(SameBits(fromA, fromB), Is.True, $"{fromA} from the first brush, {fromB} from the second"); + Assert.That(math.distance(fromA, SharedCorner), Is.LessThan(1e-5f)); + } + + [Test] + public void CanonicalizingTwice_ChangesNothing() + { + using var scene = new Scene(TwoTurnedBoxes()); + var canonical = scene.Canonical(); + + var once = canonical.Canonicalize(SharedCorner + new float3(3e-6f, 0, -1e-6f), 0); + var twice = canonical.Canonicalize(once, 1); + + Assert.That(SameBits(once, twice), Is.True); + } + + // The key is sorted by plane, so the order the brushes come in doesn't matter + [Test] + public void BrushOrder_DoesNotChangeTheResult() + { + var brushes = TwoTurnedBoxes(); + using var forward = new Scene(brushes[0], brushes[1]); + using var backward = new Scene(brushes[1], brushes[0]); + + var approximate = SharedCorner + new float3(1e-6f, 1e-6f, 0); + var fromForward = forward.Canonical().Canonicalize(approximate, 0); + var fromBackward = backward.Canonical().Canonicalize(approximate, 1); + + Assert.That(SameBits(fromForward, fromBackward), Is.True, $"{fromForward} against {fromBackward}"); + } + + // The trim case in miniature: a neighbour whose bottom face sits a little above this brush's, so their + // corners are a sliver apart. Thicker than τ it is a real feature and both corners stay. + [Test] + public void ASliverThickerThanTau_KeepsBothCorners() + { + const float kSliver = 0.002f; + using var scene = new Scene(Box(new float3(0, 0, 0), new float3(1, 1, 1)), + Box(new float3(1, kSliver, 0), new float3(2, 1, 1))); + var canonical = scene.Canonical(); + + var corner = canonical.Canonicalize(new float3(1, 0, 0), 0); + var raised = canonical.Canonicalize(new float3(1, kSliver, 0), 1); + + Assert.That(corner.y, Is.EqualTo(0).Within(1e-6f)); + Assert.That(raised.y, Is.EqualTo(kSliver).Within(1e-6f)); + Assert.That(math.distance(corner, raised), Is.GreaterThan(CanonicalVertices.kSameVertex), + "two different vertices, however close"); + } + + // Thinner than τ both bottom faces pass through the corner, and both brushes pick the same one of them. + [Test] + public void ASliverThinnerThanTau_BecomesOneCornerOnBothBrushes() + { + const float kSliver = 0.0003f; + using var scene = new Scene(Box(new float3(0, 0, 0), new float3(1, 1, 1)), + Box(new float3(1, kSliver, 0), new float3(2, 1, 1))); + var canonical = scene.Canonical(); + + var corner = canonical.Canonicalize(new float3(1, 0, 0), 0); + var raised = canonical.Canonicalize(new float3(1, kSliver, 0), 1); + + Assert.That(SameBits(corner, raised), Is.True, $"{corner} against {raised}"); + } + + [Test] + public void ABrushAddedOnTheSamePlane_DoesNotMoveCornersItDoesNotContain() + { + var left = Box(new float3(0, 0, 0), new float3(1, 1, 1)); + var middle = Box(new float3(1, 0, 0), new float3(2, 1, 1)); + // Added by the edit: it touches the middle box only, and its top is 0.3 mm higher, less than τ + var right = Box(new float3(2, 0, 0), new float3(3, 1.0003f, 1)); + using var before = new Scene(left, middle); + using var after = new Scene(left, middle, right); + + // The corner the left and middle boxes share, as the middle box finds it before and after the edit + var approximate = new float3(1, 1, 0) + new float3(1e-6f, -1e-6f, 0); + var unedited = before.Canonical().Canonicalize(approximate, 1); + var edited = after.Canonical().Canonicalize(approximate, 1); + Assert.That(SameBits(unedited, edited), Is.True, $"{unedited} before the edit, {edited} after it"); + Assert.That(unedited, Is.EqualTo(new float3(1, 1, 0))); + + // Where the new brush does meet the middle box, the two agree + var fromMiddle = after.Canonical().Canonicalize(new float3(2, 1, 0), 1); + var fromRight = after.Canonical().Canonicalize(new float3(2, 1.0003f, 0), 2); + Assert.That(SameBits(fromMiddle, fromRight), Is.True, $"{fromMiddle} from the middle box, {fromRight} from the new one"); + } + + // A plane that only touches the brush along an edge joins its corners' key, but each corner stays where its three faces put it + [Test] + public void APlaneThroughAnEdge_JoinsTheKeyButDoesNotMoveTheCorner() + { + var box = Box(new float3(0, 0, 0), new float3(1, 1, 1)); + // x + y <= 2: it touches the box only along the edge x = 1, y = 1 + var withDiagonal = box.Append(new float4(math.normalize(new float3(1, 1, 0)), -math.sqrt(2f))).ToArray(); + using var scene = new Scene(withDiagonal); + var canonical = scene.Canonical(); + + var key = new PlaneKey(); + canonical.Gather(new float3(1, 1, 0), 0, ref key); + Assert.That(key.Length, Is.EqualTo(4), "x = 1, y = 1, z = 0 and the diagonal"); + + var corner = canonical.Canonicalize(new float3(1, 1, 0) + new float3(2e-6f, -1e-6f, 1e-6f), 0); + Assert.That(corner.x, Is.EqualTo(1f)); + Assert.That(corner.y, Is.EqualTo(1f)); + Assert.That(corner.z, Is.EqualTo(0f)); + } + + // A plane is infinite. A brush far away with a face on the same plane does not contain the corner, so it + // does not take part. + [Test] + public void AFarBrushOnTheSamePlane_DoesNotChangeTheCorner() + { + var near = Box(new float3(0, 0, 0), new float3(1, 1, 1)); + var far = Box(new float3(1, 5, 0), new float3(1.5f, 6, 1)); // its -x face is the plane x = 1 + using var alone = new Scene(near); + using var both = new Scene(near, far); + + var approximate = new float3(1, 0, 0) + new float3(1e-6f, 0, 0); + var key = new PlaneKey(); + both.Canonical().Gather(approximate, 0, ref key); + Assert.That(key.Length, Is.EqualTo(3)); + Assert.That(SameBits(alone.Canonical().Canonicalize(approximate, 0), both.Canonical().Canonicalize(approximate, 0)), Is.True); + } + + // A key holds each plane once, counting a plane and its twin as one, and what it holds doesn't depend on the + // order the planes came in, even when there are more of them than it has room for. + [Test] + public void APlaneKey_DependsOnlyOnItsPlanes() + { + var random = new Unity.Mathematics.Random(12345); + var planes = new List(); + for (int i = 0; i < PlaneKey.kCapacity + 8; i++) + planes.Add(new float4(random.NextFloat3Direction(), random.NextFloat(-2, 2))); + + var forward = new PlaneKey(); + foreach (var plane in planes) + forward.Add(plane); + var backward = new PlaneKey(); + for (int i = planes.Count - 1; i >= 0; i--) + { + backward.Add(-planes[i]); + backward.Add(planes[i]); + } + + Assert.That(forward.Overflowed, Is.True); + Assert.That(forward.Length, Is.EqualTo(PlaneKey.kCapacity)); + Assert.That(backward.Length, Is.EqualTo(PlaneKey.kCapacity)); + for (int i = 0; i < forward.Length; i++) + Assert.That(SameBits(forward[i], backward[i]), Is.True, $"plane {i}: {forward[i]} against {backward[i]}"); + } + + // -0 and +0 are equal but have different bits, and a twin's negation is full of -0. + [Test] + public void APlaneKey_StoresOneKindOfZero() + { + var key = new PlaneKey(); + key.Add(new float4(0, -1, 0, 0)); // turned around, this is (-0, 1, -0, -0) + key.Add(new float4(0, 1, 0, 0)); + Assert.That(key.Length, Is.EqualTo(1)); + Assert.That(SameBits(key[0], new float4(0, 1, 0, 0)), Is.True, $"{key[0]}"); + } + + [Test] + public void Measure_CountsButDoesNotMove() + { + using var scene = new Scene(TwoTurnedBoxes()); + var approximate = SharedCorner + new float3(3e-6f, 0, 0); + + CanonicalVertexStats.Reset(); + var measured = scene.Canonical(CanonicalVertexStage.Measure).Canonicalize(approximate, 0); + + Assert.That(SameBits(measured, approximate), Is.True); + Assert.That(CanonicalVertexStats.Get(CanonicalVertexStats.kComputed), Is.EqualTo(1)); + Assert.That(CanonicalVertexStats.Get(CanonicalVertexStats.kMoved), Is.EqualTo(1)); + } + + [Test] + public void Off_IsInert() + { + var approximate = new float3(1.25f, -3.5f, 7); + Assert.That(SameBits(CanonicalVertices.Disabled.Canonicalize(approximate, 0), approximate), Is.True); + Assert.That(CanonicalVertices.Disabled.Computes, Is.False); + } + + // With canonical positions, the weld merges only the same vertex, however close another one is. + [Test] + public void TheSameVertexWeld_MergesOnlyTheSameVertex() + { + var filter = WeldIncidenceFilter.SameVertexOnly; + var corner = new float3(4, 5, 6); + Assert.That(filter.Allows(corner, corner), Is.True); + Assert.That(filter.Allows(corner, corner + new float3(0.000005f, 0, 0)), Is.True, "0.005 mm: rounding"); + Assert.That(filter.Allows(corner, corner + new float3(0.0001f, 0, 0)), Is.False, "0.1 mm: a different vertex"); + + using var hashed = new HashedVertices(16, Allocator.Temp); + var first = hashed.AddNoResize(corner, in filter); + var second = hashed.AddNoResize(corner + new float3(0, 0.002f, 0), in filter); + Assert.That(second, Is.Not.EqualTo(first), "2 mm apart: two vertices, where the distance weld made one"); + } + } +} diff --git a/Core/Tests/CanonicalVerticesTests.cs.meta b/Core/Tests/CanonicalVerticesTests.cs.meta new file mode 100644 index 00000000..6c827b43 --- /dev/null +++ b/Core/Tests/CanonicalVerticesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12089733e49c6334a83123f854947149 \ No newline at end of file diff --git a/Core/Tests/CategoryRoutingRowTests.cs b/Core/Tests/CategoryRoutingRowTests.cs new file mode 100644 index 00000000..f87b67e5 --- /dev/null +++ b/Core/Tests/CategoryRoutingRowTests.cs @@ -0,0 +1,173 @@ +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class CategoryRoutingRowTests + { + const int kAdditive = 0; + const int kSubtractive = 1; + const int kIntersection = 2; + const int kInvalidOp = 3; + + [Test] + public void Length_EqualsNumberOfCategories() + { + // 6 self-categories: Inside, Aligned, SelfAligned, SelfReverseAligned, ReverseAligned, Outside + Assert.That(CategoryRoutingRow.Length, Is.EqualTo((int)CategoryIndex.LastCategory + 1)); + Assert.That(CategoryRoutingRow.Length, Is.EqualTo(6)); + } + + [Test] + public void OperationStride_IsRowStrideTimesOperationCount() + { + Assert.That(CategoryRoutingRow.RowStride, Is.EqualTo(CategoryRoutingRow.OperationCount)); + Assert.That(CategoryRoutingRow.OperationStride, + Is.EqualTo(CategoryRoutingRow.OperationCount * CategoryRoutingRow.RowStride)); + } + + [Test] + public void OperationTables_HaveExactlyFourOperationBlocks() + { + Assert.That(CategoryRoutingRow.kOperationTables.Length, + Is.EqualTo(CategoryRoutingRow.OperationStride * 4)); + } + + [Test] + public void OperationTables_ValidOperationsOnlyContainValidCategories() + { + // The first three operation blocks (additive/subtractive/intersection) must only + // reference valid category indices (0..LastCategory); never the Invalid (255) sentinel. + var stride = CategoryRoutingRow.OperationStride; + for (int op = kAdditive; op <= kIntersection; op++) + { + for (int i = 0; i < stride; i++) + { + int value = CategoryRoutingRow.kOperationTables[op * stride + i]; + Assert.That(value, Is.InRange(0, (int)CategoryIndex.LastCategory), + $"operation {op}, entry {i} is out of range ({value})"); + } + } + } + + [Test] + public void OperationTables_InvalidOperationBlockIsAllInvalid() + { + var stride = CategoryRoutingRow.OperationStride; + for (int i = 0; i < stride; i++) + { + int value = CategoryRoutingRow.kOperationTables[kInvalidOp * stride + i]; + // Compared with the sentinel itself, not a literal + Assert.That(value, Is.EqualTo((int)CategoryRoutingRow.AllInvalid.inside), + $"invalid-op entry {i} should be the Invalid sentinel"); + } + } + + // Building a routing row from the Identity right-row reproduces a raw table row, + // because Identity = (Inside=0, Aligned=1, ... Outside=5) indexes each column in order. + + [Test] + public void Additive_InsideRow_RoutesEverythingInside() + { + var row = new CategoryRoutingRow(kAdditive, CategoryIndex.Inside, CategoryRoutingRow.Identity); + Assert.That(row.Equals(CategoryRoutingRow.AllInside), Is.True); + Assert.That(row.AreAllValue((int)CategoryIndex.Inside), Is.True); + } + + [Test] + public void Additive_OutsideRow_IsIdentity() + { + // For the additive (union) operation, the "outside" left-node row passes the right + // node through unchanged -> it equals the Identity row. + var row = new CategoryRoutingRow(kAdditive, CategoryIndex.Outside, CategoryRoutingRow.Identity); + Assert.That(row.Equals(CategoryRoutingRow.Identity), Is.True); + } + + [Test] + public void Subtractive_OutsideRow_RoutesEverythingOutside() + { + var row = new CategoryRoutingRow(kSubtractive, CategoryIndex.Outside, CategoryRoutingRow.Identity); + Assert.That(row.Equals(CategoryRoutingRow.AllOutside), Is.True); + Assert.That(row.AreAllValue((int)CategoryIndex.Outside), Is.True); + } + + [Test] + public void Subtractive_InsideRow_InvertsAlignment() + { + // inside row of subtractive table: Outside, ReverseAligned, SelfReverseAligned, SelfAligned, Aligned, Inside + var row = new CategoryRoutingRow(kSubtractive, CategoryIndex.Inside, CategoryRoutingRow.Identity); + Assert.That(row.inside, Is.EqualTo((byte)CategoryIndex.Outside)); + Assert.That(row.aligned, Is.EqualTo((byte)CategoryIndex.ReverseAligned)); + Assert.That(row.selfAligned, Is.EqualTo((byte)CategoryIndex.SelfReverseAligned)); + Assert.That(row.selfReverseAligned, Is.EqualTo((byte)CategoryIndex.SelfAligned)); + Assert.That(row.reverseAligned, Is.EqualTo((byte)CategoryIndex.Aligned)); + Assert.That(row.outside, Is.EqualTo((byte)CategoryIndex.Inside)); + } + + [Test] + public void Intersection_InsideRow_IsIdentity() + { + var row = new CategoryRoutingRow(kIntersection, CategoryIndex.Inside, CategoryRoutingRow.Identity); + Assert.That(row.Equals(CategoryRoutingRow.Identity), Is.True); + } + + [Test] + public void Intersection_OutsideRow_RoutesEverythingOutside() + { + var row = new CategoryRoutingRow(kIntersection, CategoryIndex.Outside, CategoryRoutingRow.Identity); + Assert.That(row.Equals(CategoryRoutingRow.AllOutside), Is.True); + } + + [Test] + public void Identity_Indexer_ReturnsCategoryInOrder() + { + var identity = CategoryRoutingRow.Identity; + Assert.That(identity[0], Is.EqualTo((byte)CategoryIndex.Inside)); + Assert.That(identity[1], Is.EqualTo((byte)CategoryIndex.Aligned)); + Assert.That(identity[2], Is.EqualTo((byte)CategoryIndex.SelfAligned)); + Assert.That(identity[3], Is.EqualTo((byte)CategoryIndex.SelfReverseAligned)); + Assert.That(identity[4], Is.EqualTo((byte)CategoryIndex.ReverseAligned)); + Assert.That(identity[5], Is.EqualTo((byte)CategoryIndex.Outside)); + } + + [Test] + public void AreAllTheSame_TrueForUniformRows_FalseForIdentity() + { + Assert.That(CategoryRoutingRow.AllInside.AreAllTheSame(), Is.True); + Assert.That(CategoryRoutingRow.AllOutside.AreAllTheSame(), Is.True); + Assert.That(CategoryRoutingRow.AllSelfAligned.AreAllTheSame(), Is.True); + Assert.That(CategoryRoutingRow.AllSelfReverseAligned.AreAllTheSame(), Is.True); + Assert.That(CategoryRoutingRow.Identity.AreAllTheSame(), Is.False); + } + + [Test] + public void OperatorPlus_OffsetsEveryColumn() + { + // AllInside is all zeroes; adding 3 produces all SelfReverseAligned (== 3). + var shifted = CategoryRoutingRow.AllInside + (int)CategoryIndex.SelfReverseAligned; + Assert.That(shifted.Equals(CategoryRoutingRow.AllSelfReverseAligned), Is.True); + } + + [Test] + public void OperatorPlus_Zero_IsNoOp() + { + var same = CategoryRoutingRow.Identity + 0; + Assert.That(same.Equals(CategoryRoutingRow.Identity), Is.True); + } + + [Test] + public void Equals_DistinguishesDifferentRows() + { + Assert.That(CategoryRoutingRow.Identity.Equals(CategoryRoutingRow.Identity), Is.True); + Assert.That(CategoryRoutingRow.Identity.Equals(CategoryRoutingRow.AllInside), Is.False); + Assert.That(CategoryRoutingRow.AllInside.Equals(CategoryRoutingRow.AllOutside), Is.False); + } + + [Test] + public void ByteValueConstructor_FillsAllColumns() + { + var row = new CategoryRoutingRow((byte)CategoryIndex.Aligned); + Assert.That(row.AreAllValue((int)CategoryIndex.Aligned), Is.True); + } + } +} diff --git a/Core/Tests/CategoryRoutingRowTests.cs.meta b/Core/Tests/CategoryRoutingRowTests.cs.meta new file mode 100644 index 00000000..b490623e --- /dev/null +++ b/Core/Tests/CategoryRoutingRowTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 670005b68b8bcdc47bc74864915b15d4 \ No newline at end of file diff --git a/Core/Tests/ChiselSurfaceMetadataFlagsTests.cs b/Core/Tests/ChiselSurfaceMetadataFlagsTests.cs new file mode 100644 index 00000000..1e5a58cf --- /dev/null +++ b/Core/Tests/ChiselSurfaceMetadataFlagsTests.cs @@ -0,0 +1,72 @@ +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class ChiselSurfaceMetadataFlagsTests + { + static SurfaceDestinationFlags FlagsOf(string json) + { + var metadata = ScriptableObject.CreateInstance(); + try + { + JsonUtility.FromJsonOverwrite(json, metadata); + return metadata.destinationFlags; + } + finally + { + Object.DestroyImmediate(metadata); + } + } + + [Test] + public void FlagsSavedBeforeTheFlagExisted_StillTakePartInTheBake() + { + var saved = FlagsOf("{\"destinationFlags\":15}"); + Assert.AreEqual(SurfaceDestinationFlags.Default, saved, "what Chisel's own materials store"); + Assert.AreEqual(SurfaceDestinationFlags.None, saved.Normalize() & SurfaceDestinationFlags.ExcludedFromGlobalIllumination); + + var drawnAndCasting = FlagsOf("{\"destinationFlags\":3}"); + Assert.AreEqual(SurfaceDestinationFlags.RenderShadowsCasting, drawnAndCasting); + Assert.AreEqual(SurfaceDestinationFlags.None, + drawnAndCasting.Normalize() & SurfaceDestinationFlags.ExcludedFromGlobalIllumination); + } + + // A shadow-only surface is a solid wall the player never sees, and it blocks light in a bake as it does in the game + [Test] + public void FlagsSavedBeforeTheFlagExisted_ThatAreNotDrawn_AreInTheBakeToo() + { + var shadowOnly = FlagsOf("{\"destinationFlags\":10}"); + Assert.AreEqual(SurfaceDestinationFlags.ShadowCasting | SurfaceDestinationFlags.Collidable, shadowOnly); + Assert.AreEqual(SurfaceDestinationFlags.None, + shadowOnly.Normalize() & SurfaceDestinationFlags.ExcludedFromGlobalIllumination); + } + + // Drawn without shadows, it lets light through in a bake as it does in the game, whatever it was saved as + [Test] + public void FlagsSavedBeforeTheFlagExisted_ThatCastNoShadows_AreKeptOutOfIt() + { + var sky = FlagsOf("{\"destinationFlags\":9}"); + Assert.AreEqual(SurfaceDestinationFlags.Renderable | SurfaceDestinationFlags.Collidable, sky); + Assert.AreEqual(SurfaceDestinationFlags.ExcludedFromGlobalIllumination, + sky.Normalize() & SurfaceDestinationFlags.ExcludedFromGlobalIllumination); + } + + [Test] + public void NewSurfaceParameters_TakePartInTheBake() + { + var metadata = ScriptableObject.CreateInstance(); + try + { + Assert.AreEqual(SurfaceDestinationFlags.Default, metadata.destinationFlags); + Assert.AreEqual(SurfaceDestinationFlags.None, + metadata.destinationFlags & SurfaceDestinationFlags.ExcludedFromGlobalIllumination); + } + finally + { + Object.DestroyImmediate(metadata); + } + } + } +} diff --git a/Core/Tests/ChiselSurfaceMetadataFlagsTests.cs.meta b/Core/Tests/ChiselSurfaceMetadataFlagsTests.cs.meta new file mode 100644 index 00000000..e6d1c0cf --- /dev/null +++ b/Core/Tests/ChiselSurfaceMetadataFlagsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b2d7c5fa2e5063f4da01f03a7fb64403 \ No newline at end of file diff --git a/Core/Tests/ChiselTestUtility.cs b/Core/Tests/ChiselTestUtility.cs new file mode 100644 index 00000000..3773608d --- /dev/null +++ b/Core/Tests/ChiselTestUtility.cs @@ -0,0 +1,39 @@ +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + // Shared helpers for the Chisel.Core tests. + static class ChiselTestUtility + { + public const float kEpsilon = 1e-4f; + + public static void AssertApproximately(Vector3 expected, Vector3 actual, float epsilon = kEpsilon) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(epsilon), $"x mismatch (expected {expected}, got {actual})"); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(epsilon), $"y mismatch (expected {expected}, got {actual})"); + Assert.That(actual.z, Is.EqualTo(expected.z).Within(epsilon), $"z mismatch (expected {expected}, got {actual})"); + } + + public static void AssertApproximately(float3 expected, float3 actual, float epsilon = kEpsilon) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(epsilon), $"x mismatch (expected {expected}, got {actual})"); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(epsilon), $"y mismatch (expected {expected}, got {actual})"); + Assert.That(actual.z, Is.EqualTo(expected.z).Within(epsilon), $"z mismatch (expected {expected}, got {actual})"); + } + + public static void AssertApproximately(double3 expected, double3 actual, double epsilon = kEpsilon) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(epsilon), $"x mismatch (expected {expected}, got {actual})"); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(epsilon), $"y mismatch (expected {expected}, got {actual})"); + Assert.That(actual.z, Is.EqualTo(expected.z).Within(epsilon), $"z mismatch (expected {expected}, got {actual})"); + } + + // Returns true when the point lies on the plane (within epsilon). + public static bool IsOnPlane(Plane plane, Vector3 point, float epsilon = 1e-3f) + { + return Mathf.Abs(plane.GetDistanceToPoint(point)) <= epsilon; + } + } +} diff --git a/Core/Tests/ChiselTestUtility.cs.meta b/Core/Tests/ChiselTestUtility.cs.meta new file mode 100644 index 00000000..dfc7e606 --- /dev/null +++ b/Core/Tests/ChiselTestUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9aa0d0aa0a956d944b66c7adce4063f7 \ No newline at end of file diff --git a/Core/Tests/ChiselTreeLookupTests.cs b/Core/Tests/ChiselTreeLookupTests.cs new file mode 100644 index 00000000..3489d663 --- /dev/null +++ b/Core/Tests/ChiselTreeLookupTests.cs @@ -0,0 +1,78 @@ +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class ChiselTreeLookupTests + { + [Test] + public void DestroyingATree_ReleasesItsCaches() + { + var tree = CSGTree.Create(default(UnityEngine.EntityId)); + try + { + var data = ChiselTreeLookup.Value[tree]; + Assert.That(ChiselTreeLookup.Value.HasTree(tree), Is.True); + Assert.That(data.brushIDValues.IsCreated, Is.True); + + // Destroy invalidates the handle it is called on, so keep a copy to look the tree up with + var destroyed = tree; + Assert.That(tree.Destroy(), Is.True); + + Assert.That(ChiselTreeLookup.Value.HasTree(destroyed), Is.False, "the destroyed tree still has caches"); + Assert.That(data.brushIDValues.IsCreated, Is.False, "the caches were dropped but not disposed"); + } + finally + { + if (tree.Valid) + tree.Destroy(); + } + } + + [Test] + public void DestroyingATree_FreesItsHierarchy() + { + var first = CSGTree.Create(default(UnityEngine.EntityId)); + var firstHierarchy = first.CompactHierarchyID; + Assert.That(first.Destroy(), Is.True); + + var second = CSGTree.Create(default(UnityEngine.EntityId)); + try + { + var secondHierarchy = second.CompactHierarchyID; + Assert.That(secondHierarchy.slotIndex.index, Is.EqualTo(firstHierarchy.slotIndex.index), + "the destroyed tree's hierarchy slot was never freed"); + Assert.That(secondHierarchy.slotIndex.generation, Is.Not.EqualTo(firstHierarchy.slotIndex.generation)); + } + finally + { + if (second.Valid) + second.Destroy(); + } + } + + [Test] + public void DestroyingATree_LeavesOtherTreesAlone() + { + var kept = CSGTree.Create(default(UnityEngine.EntityId)); + var dropped = CSGTree.Create(default(UnityEngine.EntityId)); + try + { + var keptData = ChiselTreeLookup.Value[kept]; + _ = ChiselTreeLookup.Value[dropped]; + + dropped.Destroy(); + + Assert.That(ChiselTreeLookup.Value.HasTree(kept), Is.True); + Assert.That(keptData.brushIDValues.IsCreated, Is.True); + } + finally + { + if (kept.Valid) + kept.Destroy(); + if (dropped.Valid) + dropped.Destroy(); + } + } + } +} diff --git a/Core/Tests/ChiselTreeLookupTests.cs.meta b/Core/Tests/ChiselTreeLookupTests.cs.meta new file mode 100644 index 00000000..89d70941 --- /dev/null +++ b/Core/Tests/ChiselTreeLookupTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 728c334d627a4c359dc57aa8d64faac5 diff --git a/Core/Tests/Contents.meta b/Core/Tests/Contents.meta new file mode 100644 index 00000000..b23a5281 --- /dev/null +++ b/Core/Tests/Contents.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d8c2492f846c8ff41887ae37615cb35d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Core/Tests/Contents/BrushRemovalTests.cs b/Core/Tests/Contents/BrushRemovalTests.cs new file mode 100644 index 00000000..87787071 --- /dev/null +++ b/Core/Tests/Contents/BrushRemovalTests.cs @@ -0,0 +1,116 @@ +using System; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class BrushRemovalTests + { + static ContentsSceneNode Box(string name, float3 min, float3 max) + { + return ContentsSceneNode.Brush(ContentsScene.BoxPlanes(min, max), name: name); + } + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene, string what) + { + var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + return harness; + } + + static void UpdateAndAssert(ContentsTreeHarness harness, string what, params ContentsSceneNode[] removed) + { + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + Assert.That(harness.Delivered, Is.True, what + ": no meshes were delivered (" + harness.LastUpdateReport + ")"); + ContentsComparison.AssertMatchesOracle(harness, what); + Assert.That(RemovedBrushTriangles(harness, removed), Is.Zero, what + ": triangles of removed brushes are still drawn"); + } + + // A destroyed brush is gone from the harness, so its triangles have no node; a detached one is + // still known. + static int RemovedBrushTriangles(ContentsTreeHarness harness, ContentsSceneNode[] removed) + { + var count = 0; + foreach (var triangle in harness.Triangles) + { + var node = harness.NodeOf(triangle.brushID); + if (node == null || Array.IndexOf(removed, node) >= 0) + count++; + } + return count; + } + + static string Removed(bool destroy) => destroy ? "destroyed" : "detached"; + + [Test] + public void RemovingABrush_RebuildsTheBrushesItTouched([Values] bool destroy) + { + var a = Box("a", new float3(0), new float3(2)); + var b = Box("b", new float3(1), new float3(3)); + var scene = new ContentsScene().Add(a).Add(b); + using (var harness = BuildAndUpdate(scene, "a, b")) + { + harness.Remove(b, destroy); + UpdateAndAssert(harness, "b " + Removed(destroy), b); + } + } + + [Test] + public void RemovingABrushThatTouchesNothing_RemovesItsTriangles([Values] bool destroy) + { + var a = Box("a", new float3(0), new float3(2)); + var far = Box("far", new float3(10, 0, 0), new float3(12, 2, 2)); + var scene = new ContentsScene().Add(a).Add(far); + using (var harness = BuildAndUpdate(scene, "a, far")) + { + harness.Remove(far, destroy); + UpdateAndAssert(harness, "far " + Removed(destroy), far); + } + } + + // What a generator does when it is left with no brushes: each one is removed and the branch + // stays. The wall is outside the branch, and nothing flags it. + [Test] + public void RemovingEveryBrushOfAComposite_RebuildsTheBrushesTheyTouched([Values] bool destroy) + { + var wall = Box("wall", new float3(0), new float3(4, 2, 2)); + var s1 = Box("s1", new float3(0.5f, 1, 1), new float3(1.5f, 3, 3)); + var s2 = Box("s2", new float3(2.5f, 1, 1), new float3(3.5f, 3, 3)); + var steps = ContentsSceneNode.Composite(CSGOperationType.Additive, s1, s2); + var scene = new ContentsScene().Add(wall).Add(steps); + using (var harness = BuildAndUpdate(scene, "wall, [s1, s2]")) + { + harness.Remove(s1, destroy); + harness.Remove(s2, destroy); + UpdateAndAssert(harness, "s1 and s2 " + Removed(destroy), s1, s2); + } + } + + // Half of a row of overlapping boxes removed in one update: every box that is left touched two + // of them. + [Test] + public void RemovingManyBrushesAtOnce_RebuildsEveryBrushTheyTouched([Values] bool destroy) + { + const int kCount = 16; + var boxes = new ContentsSceneNode[kCount]; + var scene = new ContentsScene(); + for (int i = 0; i < kCount; i++) + { + boxes[i] = Box("box" + i, new float3(i * 1.5f, 0, 0), new float3((i * 1.5f) + 2, 2, 2)); + scene.Add(boxes[i]); + } + using (var harness = BuildAndUpdate(scene, kCount + " overlapping boxes")) + { + var removed = new ContentsSceneNode[kCount / 2]; + for (int i = 1; i < kCount; i += 2) + { + harness.Remove(boxes[i], destroy); + removed[i / 2] = boxes[i]; + } + UpdateAndAssert(harness, "every other box " + Removed(destroy), removed); + } + } + } +} diff --git a/Core/Tests/Contents/BrushRemovalTests.cs.meta b/Core/Tests/Contents/BrushRemovalTests.cs.meta new file mode 100644 index 00000000..f72bc337 --- /dev/null +++ b/Core/Tests/Contents/BrushRemovalTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5198f6c3b1ea7a54dacf52d0c560cafd \ No newline at end of file diff --git a/Core/Tests/Contents/BrushWithoutMeshTests.cs b/Core/Tests/Contents/BrushWithoutMeshTests.cs new file mode 100644 index 00000000..775d76a1 --- /dev/null +++ b/Core/Tests/Contents/BrushWithoutMeshTests.cs @@ -0,0 +1,36 @@ +using System.Text.RegularExpressions; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class BrushWithoutMeshTests + { + const int kWholeBox = 12; + + [Test] + public void BrushWithoutMesh_IsSkippedWithoutAnException() + { + // What Chisel reports, rightly, about a brush it has no mesh for. Anything else logged fails the test. + LogAssert.Expect(LogType.Error, new Regex(@"has its brushMeshID set to \(0\), which is invalid")); + LogAssert.Expect(LogType.Error, new Regex(@"The brushMeshID is invalid")); + LogAssert.Expect(LogType.Error, new Regex(@"^Invalid ID 0")); + + // x >= 1 and x <= -1 (and the same for y and z): no volume at all + var nothing = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1), new float3(-1)), name: "nothing"); + var box = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0), new float3(2)), name: "box"); + var scene = new ContentsScene().Add(nothing).Add(box); + + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(harness.TriangleCountOf(box), Is.EqualTo(kWholeBox), harness.LastUpdateReport); + Assert.That(harness.TriangleCountOf(nothing), Is.EqualTo(0)); + } + } + } +} diff --git a/Core/Tests/Contents/BrushWithoutMeshTests.cs.meta b/Core/Tests/Contents/BrushWithoutMeshTests.cs.meta new file mode 100644 index 00000000..0617e104 --- /dev/null +++ b/Core/Tests/Contents/BrushWithoutMeshTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 13ab212688804b04ab290f27571886ff \ No newline at end of file diff --git a/Core/Tests/Contents/CanonicalVertexEditTests.cs b/Core/Tests/Contents/CanonicalVertexEditTests.cs new file mode 100644 index 00000000..4845bd2e --- /dev/null +++ b/Core/Tests/Contents/CanonicalVertexEditTests.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class CanonicalVertexEditTests + { + int savedStage; + + [SetUp] + public void SaveStage() => savedStage = CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage; + + [TearDown] + public void RestoreStage() => CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage = savedStage; + + static ContentsSceneNode Box(string name, float3 min, float3 max) + { + return ContentsSceneNode.Brush(ContentsScene.BoxPlanes(min, max), name: name); + } + + static List VerticesOf(ContentsTreeHarness harness, ContentsSceneNode brush) + { + var vertices = new List(); + foreach (var triangle in harness.Triangles) + { + if (harness.NodeOf(triangle.brushID) != brush) + continue; + vertices.Add(triangle.a); + vertices.Add(triangle.b); + vertices.Add(triangle.c); + } + return vertices.Distinct().ToList(); + } + + // Every vertex either brush draws on the plane x = seamX is one the other brush draws too. + static void AssertSeamMatches(ContentsTreeHarness harness, ContentsSceneNode a, ContentsSceneNode b, float seamX, string when) + { + var verticesA = VerticesOf(harness, a); + var verticesB = VerticesOf(harness, b); + var seamA = verticesA.Where(v => math.abs(v.x - seamX) < 0.001f).ToList(); + var seamB = verticesB.Where(v => math.abs(v.x - seamX) < 0.001f).ToList(); + Assert.That(seamA, Is.Not.Empty, $"{when}: {a.name} draws nothing on the seam"); + Assert.That(seamB, Is.Not.Empty, $"{when}: {b.name} draws nothing on the seam"); + foreach (var vertex in seamA) + Assert.That(seamB.Any(v => math.distance(v, vertex) <= CanonicalVertices.kSameVertex), Is.True, + $"{when}: {a.name} has {vertex} on the seam and {b.name} doesn't; {b.name} has {string.Join(" ", seamB)}"); + foreach (var vertex in seamB) + Assert.That(seamA.Any(v => math.distance(v, vertex) <= CanonicalVertices.kSameVertex), Is.True, + $"{when}: {b.name} has {vertex} on the seam and {a.name} doesn't; {a.name} has {string.Join(" ", seamA)}"); + } + + [Test] + public void AddingABrush_KeepsTheSeamWithABrushThatWasNotRebuilt([Values(0, 2, 3, 4)] int stageValue) + { + var stage = (CanonicalVertexStage)stageValue; + CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage = stageValue; + + var left = Box("left", new float3(0, 0, 0), new float3(1, 1, 1)); + var middle = Box("middle", new float3(1, 0, 0), new float3(2, 1, 1)); + using var harness = ContentsTreeHarness.Build(new ContentsScene().Add(left).Add(middle)); + Assert.That(harness.Update(), Is.True, "the first update did not run"); + AssertSeamMatches(harness, left, middle, 1, "before the edit"); + var leftBefore = VerticesOf(harness, left); + + var right = Box("right", new float3(2, 0, 0), new float3(3, 1.0003f, 1)); + harness.Insert(null, 2, right); + Assert.That(harness.Update(), Is.True, "the update after the edit did not run"); + Assert.That(harness.Delivered, Is.True, harness.LastUpdateReport); + + // Only a brush that wasn't rebuilt makes this a test of the rule + CollectionAssert.AreEquivalent(leftBefore, VerticesOf(harness, left), + "the left box changed, so it was rebuilt: " + harness.LastUpdateReport); + AssertSeamMatches(harness, left, middle, 1, "after the edit"); + + if (CompactHierarchyManager.TreeUpdate.kExactCSG) + AssertStepIsClosed(harness, middle, right, 2); + else if (stage >= CanonicalVertexStage.Positions) + AssertSeamMatches(harness, middle, right, 2, "after the edit"); + } + + static void AssertStepIsClosed(ContentsTreeHarness harness, ContentsSceneNode lower, ContentsSceneNode taller, float seamX) + { + Assert.That(ExactCSGCapture.Brushes.TryGetValue(harness.NodeIDOf(taller), out var capture) && capture.planes != null, + Is.True, $"exact CSG: no planes were captured for {taller.name}"); + var tops = capture.planes.Where(p => p.a == 0 && p.c == 0 && p.b > 0).ToList(); + Assert.That(tops.Count, Is.EqualTo(1), $"exact CSG: {taller.name} does not have one plane facing straight up"); + // a*x + b*y + c*z + w = 0 with a = c = 0: every point of the top has y = -w / b + var stepTop = ExactPredicates.RoundToFloat(Int128.FromLong(-tops[0].w), Int128.FromLong(tops[0].b)); + + var seamLower = VerticesOf(harness, lower).Where(v => math.abs(v.x - seamX) < 0.001f).ToList(); + var seamTaller = VerticesOf(harness, taller).Where(v => math.abs(v.x - seamX) < 0.001f).ToList(); + Assert.That(seamLower, Is.Not.Empty, $"exact CSG: {lower.name} draws nothing on the seam"); + foreach (var vertex in seamLower) + Assert.That(seamTaller.Contains(vertex), Is.True, + $"exact CSG: {lower.name} has {vertex} on the seam and {taller.name} doesn't; {taller.name} has {string.Join(" ", seamTaller)}"); + var stepCorners = seamTaller.Where(v => !seamLower.Contains(v)).ToList(); + foreach (var vertex in stepCorners) + Assert.That(vertex.y, Is.EqualTo(stepTop), + $"exact CSG: {taller.name} has {vertex} on the seam, which is neither a corner of {lower.name} nor on the top of the step " + + $"(y = {stepTop:R}); {lower.name} has {string.Join(" ", seamLower)}"); + // a step lower than OutputModelWeld.kCollapseLength is merged away; a higher one is drawn as one strip + var lowerTop = seamLower.Max(v => v.y); + if (stepTop - lowerTop < OutputModelWeld.kCollapseLength) + { + Assert.That(stepCorners, Is.Empty, $"exact CSG: the step above {lower.name}, {stepTop - lowerTop} high, is not merged away; {taller.name} has {string.Join(" ", seamTaller)}"); + return; + } + Assert.That(stepCorners.Count, Is.EqualTo(2), $"exact CSG: the step above {lower.name} is not drawn as one strip; {taller.name} has {string.Join(" ", seamTaller)}"); + Assert.That(stepCorners[0].y, Is.EqualTo(stepCorners[1].y), "exact CSG: the top of the step is not one plane"); + } + } +} diff --git a/Core/Tests/Contents/CanonicalVertexEditTests.cs.meta b/Core/Tests/Contents/CanonicalVertexEditTests.cs.meta new file mode 100644 index 00000000..d0d40c67 --- /dev/null +++ b/Core/Tests/Contents/CanonicalVertexEditTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bbaaa31e5ab64a8d9c0d2253b59b2e75 diff --git a/Core/Tests/Contents/ConcretePadTests.cs b/Core/Tests/Contents/ConcretePadTests.cs new file mode 100644 index 00000000..306aca39 --- /dev/null +++ b/Core/Tests/Contents/ConcretePadTests.cs @@ -0,0 +1,786 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class ConcretePadTests + { + // The pad, all 14 sides + static readonly float4[] kPad = + { + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0f, +1f, +0f, -25.4675f), + new float4(+0.259973473f, +0f, +0.965615759f, +40.3935641f), + new float4(+0.707106781f, +0f, +0.707106781f, +78.9882469f), + new float4(+0.965615759f, +0f, +0.259973473f, +95.4901138f), + new float4(+0.965615759f, +0f, -0.259973473f, +85.3407493f), + new float4(+0.707106781f, +0f, -0.707106781f, +51.3827981f), + new float4(+0.259973473f, +0f, -0.965615759f, +2.69592492f), + new float4(-0.259973473f, +0f, -0.965615759f, -48.0508971f), + new float4(-0.707106781f, +0f, -0.707106781f, -86.6444455f), + new float4(-0.965615759f, +0f, -0.259973473f, -103.147447f), + new float4(-0.965615759f, +0f, +0.259973473f, -92.9980823f), + new float4(-0.707106781f, +0f, +0.707106781f, -59.0389968f), + new float4(-0.259973473f, +0f, +0.965615759f, -10.3532579f), + }; + + // its top face: +Y at -25.467500 + static readonly float4 kTopFace = new float4(+0f, +1f, +0f, -25.4675f); + + static readonly float4[] kNeighbour1 = + { + new float4(+0f, +1f, +0f, -25.08625f), + new float4(+0.965965867f, +0f, -0.258669563f, +85.0399165f), + new float4(-0.96594981f, +0f, +0.258729519f, -85.3963103f), + new float4(+0f, -0f, +1f, +19.52f), + new float4(+0f, -1f, -0f, +24.8575f), + new float4(+0.148435645f, +0f, -0.988922069f, -6.29192715f), + }; + + static readonly float4[] kNeighbour2 = + { + new float4(+0f, +1f, -0f, -25.08625f), + new float4(+0.965973954f, +0f, +0.258639365f, +95.1385409f), + new float4(-0.965968646f, +0f, -0.258659185f, -95.4975015f), + new float4(+0f, +0f, -1f, -19.52f), + new float4(+0f, -1f, +0f, +24.8575f), + new float4(+0.155691004f, -0f, +0.987805807f, +32.9919113f), + }; + + static readonly float4[] kNeighbour3 = + { + new float4(+0f, +1f, -0f, -24.62875f), + new float4(+0.965948052f, -0f, +0.258736085f, +94.696092f), + new float4(-0.966485127f, +0f, -0.256722612f, -95.5080602f), + new float4(+0f, +0f, -1f, -19.52f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0.173167881f, -0f, +0.984892322f, +34.2942212f), + }; + + static readonly float4[] kNeighbour4 = + { + new float4(+0f, +1f, -0f, -24.8575f), + new float4(+0.965935975f, +0f, +0.258781167f, +94.916809f), + new float4(-0.965944189f, +0f, -0.258750504f, -95.496994f), + new float4(+0f, +0f, -1f, -19.52f), + new float4(+0f, -1f, +0f, +24.62875f), + new float4(+0.173666379f, -0f, +0.984804543f, +34.4864163f), + }; + + static readonly float4[] kNeighbour5 = + { + new float4(+0f, +1f, +0f, -24.8575f), + new float4(+0.965935975f, +0f, -0.258781167f, +84.8139923f), + new float4(-0.965944189f, +0f, +0.258750504f, -85.3953743f), + new float4(+0f, -0f, +1f, +19.52f), + new float4(+0f, -1f, -0f, +24.62875f), + new float4(+0.173666379f, +0f, -0.984804543f, -3.9603531f), + }; + + static readonly float4[] kNeighbour6 = + { + new float4(+0f, +1f, +0f, -24.62875f), + new float4(+0.965948052f, +0f, -0.258736085f, +84.5950352f), + new float4(-0.966485127f, +0f, +0.256722612f, -85.4856095f), + new float4(+0f, -0f, +1f, +19.52f), + new float4(+0f, -1f, -0f, +24.4f), + new float4(+0.173167881f, +0f, -0.984892322f, -4.15597502f), + }; + + static readonly float4[] kNeighbour7 = + { + new float4(+0f, -1f, +0f, +23.79f), + new float4(+0f, +1f, -0f, -24.4f), + new float4(+0.498964445f, +0f, -0.866622457f, +31.7884667f), + new float4(-0.866622457f, +0f, +0.498964445f, -74.8365586f), + new float4(+0.707106781f, +0f, +0.707106781f, +76.1845685f), + new float4(-0.707106781f, +0f, -0.707106781f, -78.9882469f), + }; + + static readonly float4[] kNeighbour8 = + { + new float4(-0f, -1f, +0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(+0.866622457f, +0f, -0.498964445f, +74.8365586f), + new float4(-1f, +0f, +0f, -97.6f), + new float4(+0.257662651f, +0f, +0.96623494f, +37.3779934f), + new float4(-0.259973473f, +0f, -0.965615759f, -40.3935641f), + }; + + static readonly float4[] kNeighbour9 = + { + new float4(+0f, -1f, +0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(+1f, +0f, +0f, +97.6f), + new float4(-0.866622457f, +0f, -0.498964445f, -94.328145f), + new float4(-0.257662651f, +0f, +0.96623494f, -12.9177559f), + new float4(+0.259973473f, +0f, -0.965615759f, +10.3532579f), + }; + + static readonly float4[] kNeighbour10 = + { + new float4(+0f, -1f, -0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(+0.866622457f, +0f, +0.498964445f, +94.328145f), + new float4(-0.498964445f, +0f, -0.866622457f, -65.6093929f), + new float4(-0.707106781f, +0f, +0.707106781f, -61.8426752f), + new float4(+0.707106781f, +0f, -0.707106781f, +59.0389968f), + }; + + static readonly float4[] kNeighbour11 = + { + new float4(+0f, -1f, +0f, +23.79f), + new float4(+0f, +1f, -0f, -24.4f), + new float4(+0f, -0f, -1f, -19.52f), + new float4(-0.498964445f, +0f, +0.866622457f, -31.7884667f), + new float4(+0.96623494f, +0f, +0.257662651f, +92.7033178f), + new float4(-0.965615759f, +0f, -0.259973473f, -95.4901138f), + }; + + static readonly float4[] kNeighbour12 = + { + new float4(+0f, -1f, -0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(-0.498964445f, +0f, -0.866622457f, -65.6214074f), + new float4(+0f, -0f, +1f, +19.52f), + new float4(+0.96623494f, +0f, -0.257662651f, +82.6441679f), + new float4(-0.965615759f, +0f, +0.259973473f, -85.3407493f), + }; + + static readonly float4[] kNeighbour13 = + { + new float4(+0f, -1f, +0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(-0.866622457f, -0f, -0.498964445f, -94.3161305f), + new float4(+0.498964445f, -0f, +0.866622457f, +65.6214074f), + new float4(+0.707106781f, +0f, -0.707106781f, +48.5791198f), + new float4(-0.707106781f, +0f, +0.707106781f, -51.3827981f), + }; + + static readonly float4[] kNeighbour14 = + { + new float4(+0f, -1f, +0f, +23.79f), + new float4(-0f, +1f, +0f, -24.4f), + new float4(-1f, +0f, +0f, -97.6f), + new float4(+0.866622457f, -0f, +0.498964445f, +94.3161305f), + new float4(+0.257662651f, +0f, -0.96623494f, -0.343818599f), + new float4(-0.259973473f, +0f, +0.965615759f, -2.69592492f), + }; + + static readonly float4[] kNeighbour15 = + { + new float4(+0f, -1f, +0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(-0.866622457f, +0f, +0.498964445f, -74.8485731f), + new float4(+1f, +0f, +0f, +97.6f), + new float4(-0.257662651f, +0f, -0.96623494f, -50.639568f), + new float4(+0.259973473f, +0f, +0.965615759f, +48.0508971f), + }; + + static readonly float4[] kNeighbour16 = + { + new float4(+0f, -1f, +0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(-0.498964445f, +0f, +0.866622457f, -31.7764522f), + new float4(+0.866622457f, +0f, -0.498964445f, +74.8485731f), + new float4(-0.707106781f, +0f, -0.707106781f, -89.4481239f), + new float4(+0.707106781f, +0f, +0.707106781f, +86.6444455f), + }; + + static readonly float4[] kNeighbour17 = + { + new float4(+0f, -1f, +0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(+0f, +0f, +1f, +19.52f), + new float4(+0.498964445f, +0f, -0.866622457f, +31.7764522f), + new float4(-0.96623494f, +0f, -0.257662651f, -105.964892f), + new float4(+0.965615759f, +0f, +0.259973473f, +103.147447f), + }; + + static readonly float4[] kNeighbour18 = + { + new float4(+0f, -1f, -0f, +23.79f), + new float4(+0f, +1f, +0f, -24.4f), + new float4(+0.498964445f, +0f, +0.866622457f, +65.6093929f), + new float4(+0f, +0f, -1f, -19.52f), + new float4(-0.96623494f, +0f, +0.257662651f, -95.9057424f), + new float4(+0.965615759f, +0f, -0.259973473f, +92.9980823f), + }; + + static readonly float4[] kNeighbour19 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(-0f, +1f, +0f, -26.0775f), + new float4(+0.928476691f, +0f, -0.371390676f, +83.4016374f), + new float4(-0.707106781f, +0f, +0.707106781f, -55.2108975f), + new float4(-0.558376369f, +0f, -0.829587748f, -73.6808479f), + new float4(+0.540757591f, +0f, +0.841178475f, +71.594972f), + }; + + static readonly float4[] kNeighbour20 = + { + new float4(-0f, -1f, +0f, +25.4675f), + new float4(+0f, +1f, +0f, -26.0775f), + new float4(-0.928476691f, +0f, +0.371390676f, -83.4016374f), + new float4(-0.193055343f, +0f, -0.981187869f, -40.9876521f), + new float4(+0.199883224f, +0f, +0.979819727f, +41.0253692f), + new float4(+1f, +0f, -0f, +98.0575f), + }; + + static readonly float4[] kNeighbour21 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(-0f, +1f, +0f, -26.0775f), + new float4(+0.707106781f, +0f, -0.707106781f, +55.2108975f), + new float4(-0.371390676f, +0f, +0.928476691f, -18.0920066f), + new float4(-0.829587748f, +0f, -0.558376369f, -94.8570323f), + new float4(+0.841178475f, +0f, +0.540757591f, +95.0518346f), + }; + + static readonly float4[] kNeighbour22 = + { + new float4(-0f, -1f, +0f, +25.4675f), + new float4(+0f, +1f, +0f, -26.0775f), + new float4(+0.371390676f, +0f, -0.928476691f, +18.0920066f), + new float4(-0.981187869f, +0f, -0.193055343f, -102.52504f), + new float4(+0.979819727f, +0f, +0.199883224f, +101.922811f), + new float4(+0f, +0f, +1f, +19.9775f), + }; + + static readonly float4[] kNeighbour23 = + { + new float4(+0f, -1f, -0f, +24.4f), + new float4(+0f, +1f, +0f, -25.315f), + new float4(+0f, +0f, -1f, -19.52f), + new float4(-0.496138938f, +0f, +0.868243142f, -31.4939695f), + new float4(+0.965788228f, +0f, +0.259332024f, +95.3464591f), + new float4(-0.965615759f, +0f, -0.259973473f, -95.4901138f), + }; + + static readonly float4[] kNeighbour24 = + { + new float4(-0f, -1f, +0f, +24.4f), + new float4(+0f, +1f, -0f, -25.315f), + new float4(-0.496138938f, +0f, -0.868243142f, -65.3901818f), + new float4(+0f, +0f, +1f, +19.52f), + new float4(+0.965788228f, +0f, -0.259332024f, +85.2221369f), + new float4(-0.965615759f, +0f, +0.259973473f, -85.3407493f), + }; + + static readonly float4[] kNeighbour25 = + { + new float4(-0f, -1f, +0f, +24.4f), + new float4(+0f, +1f, -0f, -25.315f), + new float4(-0.868243142f, +0f, -0.496138938f, -94.4062475f), + new float4(+0.496138938f, +0f, +0.868243142f, +65.3901818f), + new float4(+0.707106781f, +0f, -0.707106781f, +51.2345267f), + new float4(-0.707106781f, +0f, +0.707106781f, -51.3827981f), + }; + + static readonly float4[] kNeighbour26 = + { + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0f, +1f, +0f, -25.315f), + new float4(-1f, +0f, +0f, -97.6f), + new float4(+0.868243142f, +0f, +0.496138938f, +94.4062475f), + new float4(+0.259332024f, +0f, -0.965788228f, +2.48198632f), + new float4(-0.259973473f, +0f, +0.965615759f, -2.69592492f), + }; + + static readonly float4[] kNeighbour27 = + { + new float4(+0f, -1f, -0f, +24.4f), + new float4(-0f, +1f, +0f, -25.315f), + new float4(-0.868243142f, +0f, +0.496138938f, -75.0748139f), + new float4(+1f, +0f, +0f, +97.6f), + new float4(-0.259332024f, +0f, -0.965788228f, -48.1396248f), + new float4(+0.259973473f, +0f, +0.965615759f, +48.0508971f), + }; + + static readonly float4[] kNeighbour28 = + { + new float4(+0f, -1f, -0f, +24.4f), + new float4(-0f, +1f, +0f, -25.315f), + new float4(-0.496138938f, +0f, +0.868243142f, -31.456139f), + new float4(+0.868243142f, +0f, -0.496138938f, +75.0748139f), + new float4(-0.707106781f, +0f, -0.707106781f, -86.792717f), + new float4(+0.707106781f, +0f, +0.707106781f, +86.6444455f), + }; + + static readonly float4[] kNeighbour29 = + { + new float4(-0f, -1f, -0f, +24.4f), + new float4(-0f, +1f, +0f, -25.315f), + new float4(+0f, +0f, +1f, +19.52f), + new float4(+0.496138938f, +0f, -0.868243142f, +31.456139f), + new float4(-0.965788228f, +0f, -0.259332024f, -103.299725f), + new float4(+0.965615759f, +0f, +0.259973473f, +103.147447f), + }; + + static readonly float4[] kNeighbour30 = + { + new float4(-0f, -1f, +0f, +24.4f), + new float4(+0f, +1f, +0f, -25.315f), + new float4(+0.496138938f, +0f, +0.868243142f, +65.3523512f), + new float4(+0f, +0f, -1f, -19.52f), + new float4(-0.965788228f, +0f, +0.259332024f, -93.175403f), + new float4(+0.965615759f, +0f, -0.259973473f, +92.9980823f), + }; + + static readonly float4[] kNeighbour31 = + { + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0f, +1f, +0f, -25.315f), + new float4(+0.868243142f, +0f, +0.496138938f, +94.444078f), + new float4(-0.496138938f, +0f, -0.868243142f, -65.3523512f), + new float4(-0.707106781f, +0f, +0.707106781f, -59.1872683f), + new float4(+0.707106781f, +0f, -0.707106781f, +59.0389968f), + }; + + static readonly float4[] kNeighbour32 = + { + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0f, +1f, +0f, -25.315f), + new float4(+1f, +0f, +0f, +97.6f), + new float4(-0.868243142f, +0f, -0.496138938f, -94.444078f), + new float4(-0.259332024f, +0f, +0.965788228f, -10.4352524f), + new float4(+0.259973473f, +0f, -0.965615759f, +10.3532579f), + }; + + static readonly float4[] kNeighbour33 = + { + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0f, +1f, +0f, -25.315f), + new float4(+0.868243142f, +0f, -0.496138938f, +75.0369833f), + new float4(-1f, +0f, +0f, -97.6f), + new float4(+0.259332024f, +0f, +0.965788228f, +40.1863587f), + new float4(-0.259973473f, +0f, -0.965615759f, -40.3935641f), + }; + + static readonly float4[] kNeighbour34 = + { + new float4(+0f, -1f, -0f, +24.4f), + new float4(+0f, +1f, +0f, -25.315f), + new float4(+0.496138938f, +0f, -0.868243142f, +31.4939695f), + new float4(-0.868243142f, +0f, +0.496138938f, -75.0369833f), + new float4(+0.707106781f, +0f, +0.707106781f, +78.8399754f), + new float4(-0.707106781f, +0f, -0.707106781f, -78.9882469f), + }; + + static readonly float4[] kNeighbour35 = + { + new float4(-0.862578431f, +0f, +0.505923365f, -71.4302668f), + new float4(+0f, -1f, -0f, +24.4f), + new float4(-0.383747758f, +0f, -0.923437956f, -56.3047135f), + new float4(+0.606382618f, +0f, +0.795173013f, +73.8854488f), + new float4(+0.707750483f, +0f, -0.706462493f, +51.1607863f), + new float4(+0.965708999f, +0f, -0.259626902f, +85.0594455f), + new float4(+0f, +1f, +0f, -29.89f), + }; + + static readonly float4[] kNeighbour36 = + { + new float4(-0.499992958f, +0f, +0.866029469f, -29.0152437f), + new float4(+0f, -1f, -0f, +24.4f), + new float4(-0.794010974f, +0f, -0.607903424f, -90.1879994f), + new float4(+0.923387019f, +0f, +0.383870308f, +96.7897889f), + new float4(+0.259626902f, +0f, -0.965708999f, +2.36281333f), + new float4(+0.706462493f, +0f, -0.707750483f, +51.0091289f), + new float4(+0f, +1f, +0f, -29.89f), + }; + + static readonly float4[] kNeighbour37 = + { + new float4(+0.499992958f, +0f, +0.866029469f, +68.5824609f), + new float4(-0f, -1f, +0f, +24.4f), + new float4(-0.923437956f, +0f, +0.383747758f, -83.4632816f), + new float4(+0.794080342f, +0f, -0.607812809f, +64.8122288f), + new float4(-0.70614057f, +0f, -0.708071674f, -86.8666214f), + new float4(-0.259788525f, +0f, -0.965665533f, -48.3303993f), + new float4(+0f, +1f, +0f, -29.89f), + }; + + static readonly float4[] kNeighbour38 = + { + new float4(+0.866029469f, +0f, +0.499992958f, +97.162522f), + new float4(-0f, -1f, +0f, +24.4f), + new float4(-0.607834063f, +0f, +0.794064073f, -44.6497879f), + new float4(+0.383819372f, +0f, -0.923408192f, +18.6093169f), + new float4(-0.965547314f, +0f, -0.260227562f, -103.442773f), + new float4(-0.708072553f, +0f, -0.706139689f, -87.0177408f), + new float4(+0f, +1f, +0f, -29.89f), + }; + + static readonly float4[] kNeighbour39 = + { + new float4(+0.866034677f, +0f, -0.499983937f, +77.6434765f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0.383794865f, +0f, +0.923418378f, +54.6569056f), + new float4(-0.607843123f, +0f, -0.794057138f, -75.6508453f), + new float4(-0.708201023f, -4.90285308e-06f, +0.706010843f, -59.4651006f), + new float4(-0.965521128f, +9.03905221e-07f, +0.260324704f, -93.2791399f), + new float4(+0f, +1f, +0f, -29.89f), + }; + + static readonly float4[] kNeighbour40 = + { + new float4(+0.499992958f, +0f, -0.866029469f, +34.7721241f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0.79413343f, +0f, +0.607743445f, +88.5448228f), + new float4(-0.923459117f, +0f, -0.383696832f, -98.4453885f), + new float4(-0.260227562f, +0f, +0.965547314f, -10.6761419f), + new float4(-0.706139689f, -2.45858525e-06f, +0.708072553f, -59.2228027f), + new float4(+0f, +1f, +0f, -29.89f), + }; + + static readonly float4[] kNeighbour41 = + { + new float4(+0f, +0f, -1f, -16.6415625f), + new float4(+0.991600411f, +0f, +0.129339184f, +98.4789491f), + new float4(-0.991600411f, +0f, +0.129339184f, -95.0814512f), + new float4(+0.257662651f, +0f, +0.96623494f, +39.8829575f), + new float4(-0.257662651f, +0f, +0.96623494f, -10.4127919f), + new float4(+0f, +1f, +0f, -29.89f), + new float4(+0f, -1f, +0f, +24.4f), + }; + + static readonly float4[] kNeighbour42 = + { + new float4(-0.499977333f, +0f, -0.86603849f, -62.8236605f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0.923384901f, +0f, -0.383875402f, +81.803216f), + new float4(-0.794005663f, +0f, +0.607910361f, -66.454832f), + new float4(+0.706430232f, +4.91515752e-06f, +0.707782683f, +78.6370175f), + new float4(+0.259626902f, +0f, +0.965708999f, +40.0640927f), + new float4(+0f, +1f, -0f, -29.89f), + }; + + static readonly float4[] kNeighbour43 = + { + new float4(-0.862481839f, +0f, -0.506088013f, -91.1753923f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0.606400884f, +0f, -0.795159084f, +42.8438846f), + new float4(-0.383709087f, +3.20643759e-06f, +0.923454025f, -20.2498586f), + new float4(+0.965726363f, +1.80251603e-06f, +0.259562308f, +95.1957135f), + new float4(+0.707557557f, -2.45366569e-06f, +0.706655718f, +78.7263043f), + new float4(+0f, +1f, +0f, -29.89f), + }; + + static readonly float4[] kNeighbour44 = + { + new float4(+0f, +0f, +1f, +22.3984375f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(-0.991602214f, +0f, -0.129325362f, -100.130702f), + new float4(+0.991596804f, +0f, -0.129366837f, +93.4285405f), + new float4(-0.257662651f, +0f, -0.96623494f, -48.1346039f), + new float4(+0.257662651f, +0f, -0.96623494f, +2.16114548f), + new float4(+1f, +0f, +0f, +97.2378125f), + new float4(-1f, +0f, +0f, -97.9621875f), + new float4(+0f, +1f, +0f, -25.62f), + }; + + static readonly float4[] kNeighbour45 = + { + new float4(+0f, +0f, +1f, +22.3984375f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(-0.991598969f, +0f, -0.129350244f, -100.130959f), + new float4(+0f, +1f, +0f, -29.89f), + new float4(+1f, +0f, +0f, +97.9621875f), + }; + + static readonly float4[] kNeighbour46 = + { + new float4(+0f, -0f, +1f, +22.3984375f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0.991603295f, +4.49017605e-06f, -0.12931707f, +93.4302132f), + new float4(+0f, +1f, +0f, -29.89f), + new float4(-1f, +0f, +0f, -97.2378125f), + }; + + static readonly float4[] kNeighbour47 = + { + new float4(+1f, +0f, +0f, +100.478437f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(-0.129408338f, +4.04401055e-05f, +0.991591388f, +5.89838401f), + new float4(-0.129366837f, +0f, -0.991596804f, -32.8082398f), + new float4(-0.96623494f, +0f, +0.257662651f, -93.4007784f), + new float4(-0.96623494f, +0f, -0.257662651f, -103.459928f), + new float4(+0f, +0f, -1f, -19.8821875f), + new float4(+0f, +0f, +1f, +19.1578125f), + new float4(+0f, +1f, -0f, -25.62f), + }; + + static readonly float4[] kNeighbour48 = + { + new float4(+1f, +0f, +0f, +100.478437f), + new float4(-0f, -1f, -0f, +24.4f), + new float4(-0.129350244f, +0f, -0.991598969f, -32.8065788f), + new float4(-0f, +1f, +0f, -29.89f), + new float4(+0f, +0f, +1f, +19.8821875f), + }; + + static readonly float4[] kNeighbour49 = + { + new float4(+1f, +0f, +0f, +100.478437f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(-0.129350244f, +0f, +0.991598969f, +5.90544496f), + new float4(+0f, +1f, -0f, -29.89f), + new float4(+0f, +0f, -1f, -19.1578125f), + }; + + static readonly float4[] kNeighbour50 = + { + new float4(+0f, +1f, +0f, -28.975f), + new float4(+0.9701425f, +0f, +0.242535625f, +95.7215351f), + new float4(-0.9701425f, +0f, -0.242535625f, -95.8694819f), + new float4(+0f, -0f, +1f, +17.69f), + new float4(+0f, -1f, +0f, +25.4675f), + new float4(+0f, +0f, -1f, -18.4525f), + }; + + static readonly float4[] kNeighbour51 = + { + new float4(+0f, +1f, +0f, -28.975f), + new float4(+0.9701425f, +0f, -0.242535625f, +86.2529443f), + new float4(-0.9701425f, +0f, +0.242535625f, -86.4008911f), + new float4(+0f, +0f, -1f, -21.35f), + new float4(+0f, -1f, +0f, +25.4675f), + new float4(+0f, -0f, +1f, +20.5875f), + }; + + static readonly float4[] kNeighbour52 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(+0.695081302f, +0f, -0.71893114f, +50.0733608f), + new float4(-0.69513353f, +0f, +0.718880641f, -50.2273469f), + new float4(+0.866081536f, +0f, +0.499902763f, +92.4811717f), + new float4(-0.865768711f, -0f, -0.500444342f, -94.2935524f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour53 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(+0.242535625f, +0f, -0.9701425f, +1.03562712f), + new float4(-0.242535625f, +0f, +0.9701425f, -1.18357385f), + new float4(+1f, +0f, +0f, +95.77f), + new float4(-1f, +0f, +0f, -97.6f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour54 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(-0.242535625f, -0f, -0.9701425f, -46.3073269f), + new float4(+0.242535625f, +0f, +0.9701425f, +46.1593802f), + new float4(-1f, +0f, +0f, -99.43f), + new float4(+1f, +0f, +0f, +97.6f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour55 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(-0.695081302f, -0f, -0.71893114f, -85.5791f), + new float4(+0.69513353f, +0f, +0.718880641f, +85.4353108f), + new float4(-0.866081536f, +0f, +0.499902763f, -76.597003f), + new float4(+0.865768711f, +0f, -0.500444342f, +74.7235794f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour56 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(-0.9701425f, -0f, -0.242535625f, -103.118872f), + new float4(+0.9701425f, +0f, +0.242535625f, +102.970925f), + new float4(+0f, -0f, -1f, -21.35f), + new float4(+0f, +0f, +1f, +19.52f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour57 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(-0.9701425f, +0f, +0.242535625f, -93.6502809f), + new float4(+0.9701425f, +0f, -0.242535625f, +93.5023342f), + new float4(+0f, +0f, +1f, +17.69f), + new float4(+0f, -0f, -1f, -19.52f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour58 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(-0.718885524f, +0f, +0.69512848f, -60.3038613f), + new float4(+0.718926257f, +0f, -0.695086352f, +60.1607413f), + new float4(+0.500444342f, +0f, +0.865768711f, +63.922401f), + new float4(-0.499902763f, -0f, -0.866081536f, -65.7035683f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour59 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(-0.242535625f, +0f, +0.9701425f, -8.43296368f), + new float4(+0.242535625f, +0f, -0.9701425f, +8.28501695f), + new float4(-1f, +0f, +0f, -99.43f), + new float4(+1f, +0f, +0f, +97.6f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour60 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(+0.242535625f, +0f, +0.9701425f, +38.9099903f), + new float4(-0.242535625f, -0f, -0.9701425f, -39.0579371f), + new float4(+1f, +0f, +0f, +95.77f), + new float4(-1f, +0f, +0f, -97.6f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour61 = + { + new float4(+0f, -1f, +0f, +25.4675f), + new float4(+0.695128307f, +0f, +0.718885691f, +78.1562334f), + new float4(-0.695081302f, -0f, -0.71893114f, -78.3004455f), + new float4(+0.865737373f, +0f, -0.500498553f, +72.8914821f), + new float4(-0.866081536f, +0f, +0.499902763f, -74.7644048f), + new float4(+0f, +1f, +0f, -26.0775f), + }; + + static readonly float4[] kNeighbour62 = + { + new float4(+0f, +1f, -0f, -25.01f), + new float4(-0f, -1f, +0f, +24.4f), + new float4(-0.868243142f, +0f, -0.496138938f, -94.3967898f), + new float4(+0.496138938f, +0f, +0.868243142f, +65.3996395f), + new float4(+0.707106781f, +0f, -0.707106781f, +48.5791198f), + new float4(-0.707106781f, +0f, +0.707106781f, -48.7273912f), + }; + + static readonly float4[] kNeighbour63 = + { + new float4(-0f, +1f, +0f, -25.01f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0.868243142f, +0f, -0.496138938f, +75.0842715f), + new float4(-0.496138938f, +0f, +0.868243142f, -31.4466813f), + new float4(-0.707106781f, +0f, -0.707106781f, -89.4481239f), + new float4(+0.707106781f, +0f, +0.707106781f, +89.2998525f), + }; + + static readonly float4[] kNeighbour64 = + { + new float4(+0f, +1f, +0f, -25.01f), + new float4(+0f, -1f, -0f, +24.4f), + new float4(-0.868243142f, +0f, +0.496138938f, -75.0275256f), + new float4(+0.496138938f, +0f, -0.868243142f, +31.5034272f), + new float4(+0.707106781f, +0f, +0.707106781f, +76.1845685f), + new float4(-0.707106781f, +0f, -0.707106781f, -76.3328399f), + }; + + static readonly float4[] kNeighbour65 = + { + new float4(+0f, +1f, +0f, -25.01f), + new float4(+0f, -1f, +0f, +24.4f), + new float4(+0.868243142f, +0f, +0.496138938f, +94.4535357f), + new float4(-0.496138938f, +0f, -0.868243142f, -65.3428936f), + new float4(-0.707106781f, +0f, +0.707106781f, -61.8426752f), + new float4(+0.707106781f, +0f, -0.707106781f, +61.6944037f), + }; + + // the ones that rest on the face + static readonly float4[][] kResting = { kNeighbour19, kNeighbour20, kNeighbour21, kNeighbour22, kNeighbour50, kNeighbour51, kNeighbour52, kNeighbour53, kNeighbour54, kNeighbour55, kNeighbour56, kNeighbour57, kNeighbour58, kNeighbour59, kNeighbour60, kNeighbour61 }; + // everything that shares the pad's volume + internal static readonly float4[][] kNeighbourhood = { kNeighbour1, kNeighbour2, kNeighbour3, kNeighbour4, kNeighbour5, kNeighbour6, kNeighbour7, kNeighbour8, kNeighbour9, kNeighbour10, kNeighbour11, kNeighbour12, kNeighbour13, kNeighbour14, kNeighbour15, kNeighbour16, kNeighbour17, kNeighbour18, kNeighbour19, kNeighbour20, kNeighbour21, kNeighbour22, kNeighbour23, kNeighbour24, kNeighbour25, kNeighbour26, kNeighbour27, kNeighbour28, kNeighbour29, kNeighbour30, kNeighbour31, kNeighbour32, kNeighbour33, kNeighbour34, kNeighbour35, kNeighbour36, kNeighbour37, kNeighbour38, kNeighbour39, kNeighbour40, kNeighbour41, kNeighbour42, kNeighbour43, kNeighbour44, kNeighbour45, kNeighbour46, kNeighbour47, kNeighbour48, kNeighbour49, kNeighbour50, kNeighbour51, kNeighbour52, kNeighbour53, kNeighbour54, kNeighbour55, kNeighbour56, kNeighbour57, kNeighbour58, kNeighbour59, kNeighbour60, kNeighbour61, kNeighbour62, kNeighbour63, kNeighbour64, kNeighbour65 }; + internal static readonly string[] kNeighbourhoodNames = { "detail 3982", "detail 3984", "detail 3986", "detail 3988", "detail 3990", "detail 3992", "detail 4054", "detail 4055", "detail 4056", "detail 4057", "detail 4058", "detail 4059", "detail 4060", "detail 4061", "detail 4062", "detail 4063", "detail 4064", "detail 4065", "detail 4312", "detail 4314", "detail 4315", "detail 4317", "detail 4839", "detail 4840", "detail 4841", "detail 4842", "detail 4843", "detail 4844", "detail 4845", "detail 4846", "detail 4847", "detail 4848", "detail 4849", "detail 4850", "detail 4861", "detail 4862", "detail 4863", "detail 4864", "detail 4866", "detail 4867", "detail 4868", "detail 4869", "detail 4870", "detail 4871", "detail 4872", "detail 4873", "detail 4874", "detail 4875", "detail 4876", "detail 4894", "detail 4897", "detail 4898", "detail 4905", "detail 4906", "detail 4911", "detail 4914", "detail 4921", "detail 4925", "detail 4926", "detail 4930", "detail 4934", "detail 5188", "detail 5191", "detail 5193", "detail 5195" }; + internal static readonly float4[] kPadPlanes = kPad; + internal static readonly float4 kTop = kTopFace; + + /// The area of the pad's top face in a scene's output + internal static double MeasureTopFaceOf(ContentsScene scene) + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + if (!harness.Update()) + return -1; + return TopFaceArea(harness); + } + } + + internal static ContentsScene BuildPadWith(params float4[][] others) { return PadWith(others); } + + // The area of output triangles that lie on the top face's plane and face the same way. + static double TopFaceArea(ContentsTreeHarness harness) + { + double area = 0; + foreach (var triangle in harness.Triangles) + { + if (math.dot(triangle.Normal, kTopFace.xyz) < 0.999f) + continue; + if (math.abs(math.dot(kTopFace.xyz, triangle.Center) + kTopFace.w) > 1e-3f) + continue; + area += 0.5 * math.length(math.cross(triangle.b - triangle.a, triangle.c - triangle.a)); + } + return area; + } + + static ContentsScene PadWith(params float4[][] others) + { + var scene = new ContentsScene().Add(ContentsSceneNode.Brush(kPad, name: "pad 4838")); + for (int i = 0; i < others.Length; i++) + scene.Add(ContentsSceneNode.Brush(others[i], name: kNeighbourhoodNames[i])); + return scene; + } + + static double MeasureTopFace(ContentsScene scene, string what) + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + if (CompactHierarchyManager.TreeUpdate.kExactCSG) + ContentsComparison.AssertMatchesOracle(harness, what); + return TopFaceArea(harness); + } + } + + static void AssertArea(double area, double expected, double tolerance, string message) + { + if (CompactHierarchyManager.TreeUpdate.kExactCSG) + return; + Assert.That(area, Is.EqualTo(expected).Within(tolerance), message); + } + + [Test] + public void ThePadOnItsOwn_ShowsItsWholeTopFace() + { + var area = MeasureTopFace(PadWith(), "the pad alone"); + AssertArea(area, 47.143552, 0.992871, $"the pad's top face is {area:0.00} m2"); + } + + [Test] + public void TheBrushesRestingOnThePad_TakeOnlyWhatTheyCover() + { + var area = MeasureTopFace(PadWith(kResting), "the brushes resting on the pad"); + AssertArea(area, 42.105717, 0.892114, $"the pad's top face is {area:0.00} m2"); + } + + [Test] + public void APadWithOneBrushRestingOnItAndOnePassingThrough_KeepsTheRestOfItsTopFace() + { + var area = MeasureTopFace(PadWith(kNeighbour19, kNeighbour37), "the shrunk pair"); + AssertArea(area, 45.792030, 0.965841, + $"the pad's top face is {area:0.00} m2 of the 45.79 m2 that nothing covers"); + } + + // Every brush that shares the pad's volume + [Test] + public void ThePadInItsNeighbourhood_KeepsTheConcreteNothingCovers() + { + var area = MeasureTopFace(PadWith(kNeighbourhood), "the map's own neighbourhood"); + AssertArea(area, 34.464447, 0.739289, + $"the pad's top face is {area:0.00} m2 of the 34.46 m2 that nothing covers"); + } + } +} diff --git a/Core/Tests/Contents/ConcretePadTests.cs.meta b/Core/Tests/Contents/ConcretePadTests.cs.meta new file mode 100644 index 00000000..2bf00ad8 --- /dev/null +++ b/Core/Tests/Contents/ConcretePadTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 550412a74bdaab0c5b57b8eef8fafcb1 \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsComparison.cs b/Core/Tests/Contents/ContentsComparison.cs new file mode 100644 index 00000000..2f3518b8 --- /dev/null +++ b/Core/Tests/Contents/ContentsComparison.cs @@ -0,0 +1,423 @@ +using System.Collections.Generic; +using System.Text; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + // Compares what a harness captured against what the oracle says its scene should draw, face by + // face, on a grid of sample points. + public static class ContentsComparison + { + const float kProbeOffset = 0.05f; + const float kInset = 0.2f; + const float kSpacing = 0.75f; + const float kCrossingBand = 0.05f; + // A vertex may sit up to the 12.5 mm weld distance off the analytic plane of the face it belongs + // to, so "on this plane" has to be at least that loose or real triangles get rejected. + const float kOnPlane = 0.02f; + const float kInside = 0.001f; + const float kCoplanar = 0.001f; + const int kMaxReported = 8; + + internal struct Face : System.IEquatable + { + public ContentsSceneNode brush; + public int facing; // +1 drawn along the sampled plane's normal, -1 against it + public bool Equals(Face other) => brush == other.brush && facing == other.facing; + public override bool Equals(object obj) => obj is Face other && Equals(other); + public override int GetHashCode() => ((brush?.GetHashCode() ?? 0) * 3) + facing; + public override string ToString() => (brush?.name ?? "?") + (brush != null && brush.contents != ContentsSceneNode.kSolidContents ? "(c" + brush.contents + ")" : "") + (facing > 0 ? "+" : "-"); + } + + // One place where the output and the oracle disagree, kept as data so a caller can come back to it + public sealed class Mismatch + { + public ContentsSceneNode brush; + public int brushIndex; + public int face; + public float3 point; + public string problem; + public float nearest; + + public string Format(string what) + { + return $"{what}: {brush.name ?? ("brush" + brushIndex)} face {face} at " + + $"({point.x:0.###}, {point.y:0.###}, {point.z:0.###}): {problem}" + + $" [nearest other plane {nearest:0.####}]"; + } + } + + // Builds the scene, runs one full update and compares. + public static void BuildAndAssertMatchesOracle(ContentsScene scene, string what) + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + AssertMatchesOracle(harness, what); + } + } + + // As above, sampling with the given quad extent. A scene that sits further from the origin than the + // default extent needs this, or its far faces are never sampled and pass unexamined. + public static void BuildAndAssertMatchesOracle(ContentsScene scene, string what, float extent) + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + AssertMatchesOracle(harness, what, extent); + } + } + + public static void AssertMatchesOracle(ContentsTreeHarness harness, string what, float extent) + { + var failures = FindMismatches(harness, what, out var checkedSamples, out var skipped, + out var facesWithoutSamples, extent, kMaxReported); + Assert.That(checkedSamples, Is.GreaterThan(0), what + ": every sample was filtered out"); + if (failures.Count > 0) + { + var report = new StringBuilder(); + report.AppendLine($"{what}: {failures.Count} mismatch(es) over {checkedSamples} samples, {skipped} skipped"); + report.AppendLine(" scene: " + DescribeScene(harness.Scene)); + report.AppendLine(" update: " + harness.LastUpdateReport); + foreach (var failure in failures) + report.AppendLine(" " + failure); + if (facesWithoutSamples.Count > 0) + report.AppendLine($" ({facesWithoutSamples.Count} face(s) produced no samples and were not judged)"); + Assert.Fail(report.ToString()); + } + } + + // Compares the harness's last output with its scene: after an edit, this judges the incremental update + public static void AssertMatchesOracle(ContentsTreeHarness harness, string what) + { + var failures = FindMismatches(harness, what, out var checkedSamples, out var skipped); + Assert.That(checkedSamples, Is.GreaterThan(0), what + ": every sample was filtered out"); + if (failures.Count > 0) + { + var report = new StringBuilder(); + report.AppendLine($"{what}: {failures.Count} mismatch(es) over {checkedSamples} samples, {skipped} skipped"); + report.AppendLine(" scene: " + DescribeScene(harness.Scene)); + report.AppendLine(" update: " + harness.LastUpdateReport); + foreach (var failure in failures) + report.AppendLine(" " + failure); + Assert.Fail(report.ToString()); + } + } + + // The first few places where the output and the oracle disagree, without failing: a control that has to + // show a disagreement uses this. + public static List FindMismatches(ContentsTreeHarness harness, string what, out int checkedSamples, out int skipped) + { + return FindMismatches(harness, what, out checkedSamples, out skipped, out _, kDefaultExtent, kMaxReported); + } + + // The extent ContentsFaceSampler builds its quad with, when a caller does not say otherwise. + public const float kDefaultExtent = 64f; + + public static List FindMismatches(ContentsTreeHarness harness, string what, + out int checkedSamples, out int skipped, + out List facesWithoutSamples, + float extent, int maxReported) + { + var records = FindMismatchRecords(harness, out checkedSamples, out skipped, out facesWithoutSamples, + extent, maxReported); + var failures = new List(records.Count); + foreach (var record in records) + failures.Add(record.Format(what)); + return failures; + } + + public static List FindMismatchRecords(ContentsTreeHarness harness, + out int checkedSamples, out int skipped, + out List facesWithoutSamples, + float extent, int maxReported, bool budgeted = false) + { + if (CompactHierarchyManager.TreeUpdate.kExactCSG) + { + skipped = 0; + return ExactJudge.FindMismatches(harness, maxReported, out checkedSamples, out facesWithoutSamples, + budgeted ? ExactJudge.SweepBudget : ExactJudge.WorkBudget.Unlimited); + } + + var scene = harness.Scene; + var oracle = new ContentsOracle(scene); + var brushes = oracle.Brushes; + + var failures = new List(); + facesWithoutSamples = new List(); + checkedSamples = 0; + skipped = 0; + + var allBounds = new List<(float3 min, float3 max)>(brushes.Count); + foreach (var other in brushes) + allBounds.Add(ContentsFaceSampler.BrushBounds(oracle.TreePlanesOf(other), extent)); + + for (int b = 0; b < brushes.Count && failures.Count < maxReported; b++) + { + var brush = brushes[b]; + var planes = oracle.TreePlanesOf(brush); + + var otherPlanes = new List(); + var otherBounds = new List<(float3 min, float3 max)>(); + for (int o = 0; o < brushes.Count; o++) + { + if (o == b) + continue; + otherPlanes.Add(oracle.TreePlanesOf(brushes[o])); + otherBounds.Add(allBounds[o]); + } + + for (int p = 0; p < planes.Length && failures.Count < maxReported; p++) + { + // the grid, plus a point in every region the brushes reaching this face cut it into + var samples = ContentsFaceSampler.FaceSamples(planes, p, otherPlanes, otherBounds, extent, kInset, kSpacing, + kCellClearance); + + // A face nothing sampled has not passed - it has not been examined. Recorded so a sweep + // can tell the two apart instead of counting silence as agreement. + if (samples.Count == 0) + facesWithoutSamples.Add($"{brush.name ?? ("brush" + b)} face {p}"); + + foreach (var point in samples) + { + if (ContentsFaceSampler.NearACrossingPlane(otherPlanes, planes[p], point, kCrossingBand)) + { + skipped++; + continue; + } + checkedSamples++; + + var expected = ExpectedFaces(oracle, brushes, planes[p], point); + var found = FoundFaces(harness, planes[p], point); + + var problem = Compare(expected, found); + if (problem != null) + { + var nearest = NearestOtherPlaneDistance(otherPlanes, point); + failures.Add(new Mismatch + { + brush = brush, + brushIndex = b, + face = p, + point = point, + problem = problem, + nearest = nearest + }); + if (failures.Count >= maxReported) + break; + } + } + } + } + + return failures; + } + + public static string MismatchAt(ContentsTreeHarness harness, ContentsOracle oracle, float4 plane, float3 point) + { + return Compare(ExpectedFaces(oracle, oracle.Brushes, plane, point), FoundFaces(harness, plane, point)); + } + + // Whether the oracle has a face at this point on this plane, per facing - all that can be asked of an output that + // carries no brush identity, such as a live scene's meshes. + public static void ExpectedFacings(ContentsOracle oracle, float4 plane, float3 point, out bool along, out bool against) + { + along = against = false; + foreach (var face in ExpectedFaces(oracle, oracle.Brushes, plane, point)) + { + if (face.facing > 0) along = true; + else against = true; + } + } + + // Whether this triangle lies on the plane and covers the point, with the same tolerances the harness comparison + // uses; facing is +1 when it is drawn along the plane's normal. + public static bool Covers(float3 a, float3 b, float3 c, float4 plane, float3 point, out int facing) + { + facing = math.dot(math.cross(b - a, c - a), plane.xyz) > 0 ? 1 : -1; + return ContentsFaceSampler.CoversPoint(a, b, c, plane, point, kOnPlane, kInside); + } + + public static bool OnPlane(float3 point, float4 plane) => math.abs(math.dot(plane.xyz, point) + plane.w) <= kOnPlane; + + public const float kSampleInset = kInset, kSampleSpacing = kSpacing, kSampleCrossingBand = kCrossingBand; + + public const float kCellClearance = kCrossingBand * 2; + + public static string KindOf(string problem) + { + if (problem == null) + return null; + if (problem.StartsWith("the output draws nothing", System.StringComparison.Ordinal)) + return "hole"; + const string kExtra = "the oracle expects nothing here, the output draws "; + if (problem.StartsWith(kExtra, System.StringComparison.Ordinal)) + return "extra: " + problem.Substring(kExtra.Length); + var disallowed = problem.IndexOf(", which the oracle does not allow", System.StringComparison.Ordinal); + if (disallowed > 0) + return "wrong: " + problem.Substring(0, disallowed); + if (problem.EndsWith("on top of each other", System.StringComparison.Ordinal)) + return "overlap"; + return problem; + } + + // Every face the oracle finds at this point on this plane. More than one brush can qualify where + // faces are coplanar; Compare decides which of them may draw. + static List ExpectedFaces(ContentsOracle oracle, IReadOnlyList brushes, + float4 plane, float3 point) + { + var expected = new List(); + foreach (var brush in brushes) + { + var planes = oracle.TreePlanesOf(brush); + for (int p = 0; p < planes.Length; p++) + { + var sameWay = math.dot(planes[p].xyz, plane.xyz) > 0; + var aligned = math.abs(math.dot(planes[p].xyz, plane.xyz)) > 0.9999f && + math.abs(sameWay ? planes[p].w - plane.w : planes[p].w + plane.w) < kCoplanar; + if (!aligned) + continue; + // On that brush's face, not just its plane: a coplanar face elsewhere can't draw here, and in a + // tie it would take the point from the brush that does + if (!OnFace(planes, p, point)) + continue; + + var verdict = oracle.JudgeFace(brush, p, point, kProbeOffset); + if (verdict == ContentsFaceVerdict.None) + continue; + + // A face drawn out of its own brush points along its own plane's normal; express + // that in the sampled plane's frame so both orientations can be compared. + var facing = verdict == ContentsFaceVerdict.FacingOut ? 1 : -1; + if (!sameWay) + facing = -facing; + expected.Add(new Face { brush = brush, facing = facing }); + } + } + return expected; + } + + // Whether the point lies within the face on the given plane: inside or on every other plane of the brush + public static bool OnFace(float4[] planes, int planeIndex, float3 point) + { + for (int q = 0; q < planes.Length; q++) + { + if (q != planeIndex && math.dot(planes[q].xyz, point) + planes[q].w > kCoplanar) + return false; + } + return true; + } + + // The scene's shape in one line: which brush carries which operation, and how the composites + // nest. A mismatch is nearly always about an operation, so this is the first thing to read. + public static string DescribeScene(ContentsScene scene) + { + var text = new StringBuilder(); + foreach (var node in scene.roots) + DescribeNode(text, node); + return text.ToString(); + } + + static void DescribeNode(StringBuilder text, ContentsSceneNode node) + { + if (text.Length > 0) + text.Append(' '); + if (node.IsBrush) + { + text.Append($"{node.name}({node.operation.ToString().Substring(0, 3)},c{node.contents},{node.localPlanes.Length}p)"); + return; + } + text.Append($"[{node.operation.ToString().Substring(0, 3)}:"); + foreach (var child in node.children) + DescribeNode(text, child); + text.Append(']'); + } + + static float NearestOtherPlaneDistance(List otherBrushPlanes, float3 point) + { + var nearest = float.MaxValue; + foreach (var planes in otherBrushPlanes) + { + for (int p = 0; p < planes.Length; p++) + nearest = math.min(nearest, math.abs(math.dot(planes[p].xyz, point) + planes[p].w)); + } + return nearest; + } + + static List FoundFaces(ContentsTreeHarness harness, float4 plane, float3 point) + { + var found = new List(); + foreach (var triangle in harness.Triangles) + { + if (!ContentsFaceSampler.CoversPoint(triangle.a, triangle.b, triangle.c, plane, point, kOnPlane, kInside)) + continue; + var node = harness.NodeOf(triangle.brushID); + if (node == null) + continue; + found.Add(new Face { brush = node, facing = math.dot(triangle.Normal, plane.xyz) > 0 ? 1 : -1 }); + } + return found; + } + + internal static string Compare(List expected, List found) + { + foreach (var facing in new[] { 1, -1 }) + { + var allowed = Allowed(expected, facing); + var drawn = Distinct(found, facing); + if (allowed.Count == 0 && drawn.Count == 0) + continue; + if (allowed.Count == 0) + return "the oracle expects nothing here, the output draws " + Describe(drawn); + if (drawn.Count == 0) + return "the output draws nothing, the oracle expects one of " + Describe(allowed); + foreach (var face in drawn) + { + if (!allowed.Contains(face)) + return $"the output draws {face}, which the oracle does not allow (it expects one of {Describe(allowed)})"; + } + if (drawn.Count > 1) + return $"the output draws {Describe(drawn)} on top of each other"; + } + return null; + } + + static List Allowed(List expected, int facing) + { + var candidates = Distinct(expected, facing); + if (candidates.Count < 2) + return candidates; + var winner = candidates[0].brush.contents; + foreach (var candidate in candidates) + { + if (ContentsOracle.WinsTie(candidate.brush.contents, winner)) + winner = candidate.brush.contents; + } + return candidates.FindAll(candidate => candidate.brush.contents == winner); + } + + static List Distinct(List faces, int facing) + { + var result = new List(); + foreach (var face in faces) + { + if (face.facing == facing && !result.Contains(face)) + result.Add(face); + } + return result; + } + + static string Describe(List faces) + { + var text = new StringBuilder(); + foreach (var face in faces) + { + if (text.Length > 0) + text.Append(", "); + text.Append(face.ToString()); + } + return text.Length == 0 ? "nothing" : text.ToString(); + } + } +} diff --git a/Core/Tests/Contents/ContentsComparison.cs.meta b/Core/Tests/Contents/ContentsComparison.cs.meta new file mode 100644 index 00000000..28992fe2 --- /dev/null +++ b/Core/Tests/Contents/ContentsComparison.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 285ded40bda0f1b4db1e00bc883ebc36 \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsFaceSampler.cs b/Core/Tests/Contents/ContentsFaceSampler.cs new file mode 100644 index 00000000..fd45b15d --- /dev/null +++ b/Core/Tests/Contents/ContentsFaceSampler.cs @@ -0,0 +1,286 @@ +using System.Collections.Generic; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + public static class ContentsFaceSampler + { + // The face of plane planeIndex: a large quad on that plane, clipped by every other plane of + // the same convex brush. Empty when the plane contributes no face. + public static List FacePolygon(float4[] treePlanes, int planeIndex, float extent = 64f) + { + var plane = treePlanes[planeIndex]; + var normal = plane.xyz; + var origin = -normal * plane.w; + Basis(normal, out var u, out var v); + + var polygon = new List + { + origin + ((-u - v) * extent), + origin + (( u - v) * extent), + origin + (( u + v) * extent), + origin + ((-u + v) * extent) + }; + + for (int p = 0; p < treePlanes.Length; p++) + { + if (p == planeIndex) + continue; + polygon = ClipByPlane(polygon, treePlanes[p]); + if (polygon.Count < 3) + return new List(); + } + return polygon; + } + + // Sample points on the face, kept inset from its edges: the edges are where welding and + // neighbouring geometry decide what happens, and this suite is asking about contents. + public static List GridSamples(List polygon, float3 normal, float inset, float spacing) + { + var samples = new List(); + if (polygon.Count < 3) + return samples; + + var centroid = float3.zero; + foreach (var point in polygon) + centroid += point; + centroid /= polygon.Count; + + var shrunk = InsetConvex(polygon, normal, centroid, inset); + for (int halving = 0; shrunk.Count < 3 && halving < 4; halving++) + { + inset *= 0.5f; + shrunk = InsetConvex(polygon, normal, centroid, inset); + } + if (shrunk.Count < 3) + return samples; + + Basis(normal, out var u, out var v); + var min = new float2(float.MaxValue); + var max = new float2(float.MinValue); + var flat = new List(shrunk.Count); + foreach (var point in shrunk) + { + var local = new float2(math.dot(point - centroid, u), math.dot(point - centroid, v)); + flat.Add(local); + min = math.min(min, local); + max = math.max(max, local); + } + + // Always include the centre, so even a face smaller than the grid spacing is asked about. + if (InsideConvex2D(flat, float2.zero)) + samples.Add(centroid); + + for (var x = min.x; x <= max.x; x += spacing) + { + for (var y = min.y; y <= max.y; y += spacing) + { + var local = new float2(x, y); + if (math.all(math.abs(local) < 1e-6f)) + continue; + if (!InsideConvex2D(flat, local)) + continue; + samples.Add(centroid + (u * local.x) + (v * local.y)); + } + } + return samples; + } + + public static List CellSamples(List polygon, float4 facePlane, IReadOnlyList otherBrushPlanes, + float minimumClearance, int maximumCells = 4096) + { + var samples = new List(); + if (polygon.Count < 3) + return samples; + var cells = new List> { polygon }; + for (int b = 0; b < otherBrushPlanes.Count && cells.Count < maximumCells; b++) + { + var planes = otherBrushPlanes[b]; + for (int p = 0; p < planes.Length; p++) + { + var plane = planes[p]; + if (math.abs(math.dot(plane.xyz, facePlane.xyz)) > 0.9999f) + continue; + for (int c = cells.Count - 1; c >= 0; c--) + { + var cell = cells[c]; + bool below = false, above = false; + foreach (var point in cell) + { + var distance = math.dot(plane.xyz, point) + plane.w; + if (distance < -1e-4f) below = true; + if (distance > 1e-4f) above = true; + } + if (!below || !above) + continue; + var inside = ClipByPlane(cell, plane); + var outside = ClipByPlane(cell, -plane); + if (inside.Count < 3 || outside.Count < 3) + continue; + cells[c] = inside; + cells.Add(outside); + } + } + } + + foreach (var cell in cells) + { + var centroid = float3.zero; + foreach (var point in cell) + centroid += point; + centroid /= cell.Count; + if (Clearance(cell, facePlane.xyz, centroid) >= minimumClearance) + samples.Add(centroid); + } + return samples; + } + + public static List FaceSamples(float4[] planes, int planeIndex, IReadOnlyList otherBrushPlanes, + IReadOnlyList<(float3 min, float3 max)> otherBounds, float extent, float inset, + float spacing, float clearance) + { + var polygon = FacePolygon(planes, planeIndex, extent); + var samples = GridSamples(polygon, planes[planeIndex].xyz, inset, spacing); + if (polygon.Count < 3) + return samples; + var min = new float3(float.PositiveInfinity); + var max = new float3(float.NegativeInfinity); + foreach (var point in polygon) { min = math.min(min, point); max = math.max(max, point); } + var reaching = new List(); + for (int o = 0; o < otherBrushPlanes.Count; o++) + { + var bounds = otherBounds[o]; + if (math.all(bounds.min <= max + 0.01f) && math.all(bounds.max >= min - 0.01f)) + reaching.Add(otherBrushPlanes[o]); + } + samples.AddRange(CellSamples(polygon, planes[planeIndex], reaching, clearance)); + return samples; + } + + // The box around a convex brush given by its planes, from its faces as FacePolygon clips them. + public static (float3 min, float3 max) BrushBounds(float4[] planes, float extent) + { + var min = new float3(float.PositiveInfinity); + var max = new float3(float.NegativeInfinity); + for (int p = 0; p < planes.Length; p++) + foreach (var point in FacePolygon(planes, p, extent)) { min = math.min(min, point); max = math.max(max, point); } + return (min, max); + } + + // Distance from a point inside a convex polygon to its nearest edge. + static float Clearance(List polygon, float3 normal, float3 point) + { + var nearest = float.MaxValue; + for (int i = 0; i < polygon.Count; i++) + { + var a = polygon[i]; + var edge = polygon[(i + 1) % polygon.Count] - a; + var length = math.length(edge); + if (length < 1e-6f) + continue; + var inward = math.cross(normal, edge / length); + nearest = math.min(nearest, math.abs(math.dot(inward, point - a))); + } + return nearest; + } + + public static bool CoversPoint(float3 a, float3 b, float3 c, float4 plane, float3 point, + float onPlaneEpsilon, float insideEpsilon) + { + if (math.abs(math.dot(plane.xyz, a) + plane.w) > onPlaneEpsilon) return false; + if (math.abs(math.dot(plane.xyz, b) + plane.w) > onPlaneEpsilon) return false; + if (math.abs(math.dot(plane.xyz, c) + plane.w) > onPlaneEpsilon) return false; + + // Same-side test around the three edges, in the plane. + var normal = plane.xyz; + float l0 = math.length(b - a), l1 = math.length(c - b), l2 = math.length(a - c); + if (!(l0 > 0) || !(l1 > 0) || !(l2 > 0)) + return false; + var d0 = math.dot(math.cross(b - a, point - a), normal) / l0; + var d1 = math.dot(math.cross(c - b, point - b), normal) / l1; + var d2 = math.dot(math.cross(a - c, point - c), normal) / l2; + return (d0 >= -insideEpsilon && d1 >= -insideEpsilon && d2 >= -insideEpsilon) || + (d0 <= insideEpsilon && d1 <= insideEpsilon && d2 <= insideEpsilon); + } + + public static bool NearACrossingPlane(IReadOnlyList otherBrushPlanes, float4 facePlane, + float3 point, float band) + { + for (int b = 0; b < otherBrushPlanes.Count; b++) + { + var planes = otherBrushPlanes[b]; + for (int p = 0; p < planes.Length; p++) + { + if (math.abs(math.dot(planes[p].xyz, facePlane.xyz)) > 0.9999f) + continue; + if (math.abs(math.dot(planes[p].xyz, point) + planes[p].w) < band) + return true; + } + } + return false; + } + + // The convex polygon with every edge moved inwards by `inset`: the intersection of the half-planes that lie at + // least that far inside each edge. Empty when the face is too thin for it. + static List InsetConvex(List polygon, float3 normal, float3 inside, float inset) + { + var result = new List(polygon); + for (int i = 0; i < polygon.Count && result.Count >= 3; i++) + { + var a = polygon[i]; + var b = polygon[(i + 1) % polygon.Count]; + var edge = b - a; + if (math.lengthsq(edge) < 1e-12f) + continue; + var inward = math.normalize(math.cross(normal, edge)); + if (math.dot(inward, inside - a) < 0) + inward = -inward; + // keep what is at least `inset` inside this edge: -dot(inward, x) + dot(inward, a) + inset <= 0 + result = ClipByPlane(result, new float4(-inward, math.dot(inward, a) + inset)); + } + return result.Count >= 3 ? result : new List(); + } + + static bool InsideConvex2D(List polygon, float2 point) + { + var positive = false; + var negative = false; + for (int i = 0; i < polygon.Count; i++) + { + var a = polygon[i]; + var b = polygon[(i + 1) % polygon.Count]; + var side = ((b.x - a.x) * (point.y - a.y)) - ((b.y - a.y) * (point.x - a.x)); + if (side > 1e-6f) positive = true; + if (side < -1e-6f) negative = true; + if (positive && negative) + return false; + } + return true; + } + + static List ClipByPlane(List polygon, float4 plane) + { + var result = new List(polygon.Count + 1); + for (int i = 0; i < polygon.Count; i++) + { + var current = polygon[i]; + var next = polygon[(i + 1) % polygon.Count]; + var dCurrent = math.dot(plane.xyz, current) + plane.w; + var dNext = math.dot(plane.xyz, next) + plane.w; + + if (dCurrent <= 0) + result.Add(current); + if ((dCurrent < 0 && dNext > 0) || (dCurrent > 0 && dNext < 0)) + result.Add(math.lerp(current, next, dCurrent / (dCurrent - dNext))); + } + return result; + } + + static void Basis(float3 normal, out float3 u, out float3 v) + { + var reference = math.abs(normal.y) < 0.9f ? new float3(0, 1, 0) : new float3(1, 0, 0); + u = math.normalize(math.cross(reference, normal)); + v = math.cross(normal, u); + } + } +} diff --git a/Core/Tests/Contents/ContentsFaceSampler.cs.meta b/Core/Tests/Contents/ContentsFaceSampler.cs.meta new file mode 100644 index 00000000..08aa2d9a --- /dev/null +++ b/Core/Tests/Contents/ContentsFaceSampler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 880370cd7039bdf46af2f08eb0c8c129 \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsHarvest.cs b/Core/Tests/Contents/ContentsHarvest.cs new file mode 100644 index 00000000..6ec8c221 --- /dev/null +++ b/Core/Tests/Contents/ContentsHarvest.cs @@ -0,0 +1,758 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + public static class ContentsHarvest + { + /// One failing spot: sample points on one brush face that lie together and fail. + public sealed class Site + { + public string brushName; + public int face; + public readonly List points = new List(); + public readonly List kinds = new List(); + public readonly List messages = new List(); + public float3 min, max; + + public string Key => $"{brushName} face {face} near ({F((min.x + max.x) * 0.5f)}, {F((min.y + max.y) * 0.5f)}, {F((min.z + max.z) * 0.5f)})"; + + public string Kind + { + get + { + var counts = new Dictionary(); + string best = null; + foreach (var kind in kinds) + { + counts.TryGetValue(kind, out var count); + counts[kind] = ++count; + if (best == null || count > counts[best]) + best = kind; + } + return best; + } + } + } + + /// One shrunk, reproducible disagreement. + public sealed class Finding + { + public int number; + public string label; + public List sites = new List(); + public ContentsScene minimal; + public List mismatches = new List(); // every disagreement in the minimal scene + public List unexamined = new List(); + public float extent; + public int brushesStart; + public int brushesAfter; + public string origin; // which neighbourhood it was shrunk from + public bool complete; // false when the time budget stopped the shrink + public int runs; + public double seconds; + public string CSharp => ContentsSceneShrinker.ToCSharp(minimal, "scene"); + } + + /// What a sweep saw, including what it could NOT see. + public sealed class Sweep + { + public List mismatches = new List(); + public List unexamined = new List(); + // Brushes replayed from their own mesh whose registered planes are not the scene's (ContentsTreeHarness.PlaneMismatches) + public List planeMismatches = new List(); + public int checkedSamples; + public int skipped; + public bool updated; + public bool Disagrees => mismatches.Count > 0; + public bool Complete => unexamined.Count == 0; + } + + // CSG runs and what went wrong in them. An exception inside the pipeline is a finding in its own right, so it + // is kept, not swallowed. + sealed class Stats + { + public int runs; + public readonly List exceptions = new List(); + public readonly HashSet exceptionKinds = new HashSet(); + } + + static string F(float value) => value.ToString("0.###", CultureInfo.InvariantCulture); + + public static float RequiredExtent(ContentsScene scene) + { + float furthest = 0f; + foreach (var brush in AllBrushes(scene)) + { + var planes = brush.TreePlanes(); + for (int i = 0; i < planes.Length; i++) + furthest = math.max(furthest, math.abs(planes[i].w)); + } + return math.max(ContentsComparison.kDefaultExtent, (furthest * 2f) + 16f); + } + + public static IEnumerable AllBrushes(ContentsScene scene) + { + var stack = new Stack(); + for (int i = scene.roots.Count - 1; i >= 0; i--) + stack.Push(scene.roots[i]); + while (stack.Count > 0) + { + var node = stack.Pop(); + if (node.IsBrush) { yield return node; continue; } + if (node.children == null) continue; + for (int i = node.children.Count - 1; i >= 0; i--) + stack.Push(node.children[i]); + } + } + + public static int BrushCount(ContentsScene scene) + { + int count = 0; + foreach (var _ in AllBrushes(scene)) count++; + return count; + } + + /// Build the scene, run one update, and report every disagreement and every unexamined face. + public static Sweep Run(ContentsScene scene, float extent, int maxReported = int.MaxValue) + { + var result = new Sweep(); + using (var harness = ContentsTreeHarness.Build(scene)) + { + harness.JudgeEachUpdate = false; // the disagreements are what a sweep is after, not a reason to throw + result.planeMismatches.AddRange(harness.PlaneMismatches); + result.updated = harness.Update(); + if (!result.updated) + return result; + result.mismatches = ContentsComparison.FindMismatchRecords(harness, + out result.checkedSamples, + out result.skipped, + out result.unexamined, + extent, maxReported); + } + return result; + } + + /// Builds the scene, runs one update and asserts it matches the oracle on every face, however far from the origin + public static void AssertMatchesOracle(ContentsScene scene, string what) + { + // A probe sets this to get a CSG trace of every scene a fixture asserts on (ContentsTrace, Library/ChiselHarvest/traces). + if (TraceTo != null) + ContentsTrace.RunToFile(scene, what, TraceTo + "_" + System.Text.RegularExpressions.Regex.Replace(what, "[^A-Za-z0-9]+", "_")); + ContentsComparison.BuildAndAssertMatchesOracle(scene, what, RequiredExtent(scene)); + } + + internal static string TraceTo; + + + const float kClusterDistance = 1.6f; + + // Neighbourhoods tried around a site's points before falling back to its whole face, then the whole scene. + static readonly float[] kPointMargins = { 0.5f, 2f, 8f }; + const float kFaceMargin = 1f; + + static List SitesOf(List mismatches, string label) + { + // group by brush face, in the order the sweep met them + var byFace = new Dictionary<(ContentsSceneNode, int), List>(); + var order = new List<(ContentsSceneNode, int)>(); + foreach (var mismatch in mismatches) + { + var key = (mismatch.brush, mismatch.face); + if (!byFace.TryGetValue(key, out var list)) + { + byFace[key] = list = new List(); + order.Add(key); + } + list.Add(mismatch); + } + + var sites = new List(); + foreach (var key in order) + { + var list = byFace[key]; + // single-linkage clusters of the failing points + var parent = new int[list.Count]; + for (int i = 0; i < parent.Length; i++) parent[i] = i; + int Root(int i) { while (parent[i] != i) i = parent[i] = parent[parent[i]]; return i; } + for (int i = 0; i < list.Count; i++) + for (int j = i + 1; j < list.Count; j++) + if (math.distance(list[i].point, list[j].point) <= kClusterDistance) + parent[Root(i)] = Root(j); + + var clusters = new Dictionary(); + for (int i = 0; i < list.Count; i++) + { + var root = Root(i); + if (!clusters.TryGetValue(root, out var site)) + { + clusters[root] = site = new Site + { + brushName = list[i].brush.name, + face = list[i].face, + min = list[i].point, + max = list[i].point + }; + sites.Add(site); + } + site.points.Add(list[i].point); + site.kinds.Add(ContentsComparison.KindOf(list[i].problem)); + site.messages.Add(list[i].Format(label)); + site.min = math.min(site.min, list[i].point); + site.max = math.max(site.max, list[i].point); + } + } + return sites; + } + + static ContentsSceneNode FindBrush(ContentsScene scene, string name) + { + foreach (var brush in AllBrushes(scene)) + if (brush.name == name) return brush; + return null; + } + + // Tree-space bounds of a brush, from its faces as the sampler clips them. False when nothing is left of it. + static bool BoundsOf(ContentsSceneNode brush, float extent, out float3 min, out float3 max) + { + min = new float3(float.PositiveInfinity); + max = new float3(float.NegativeInfinity); + var planes = brush.TreePlanes(); + bool any = false; + for (int p = 0; p < planes.Length; p++) + { + foreach (var v in ContentsFaceSampler.FacePolygon(planes, p, extent)) + { + min = math.min(min, v); + max = math.max(max, v); + any = true; + } + } + return any; + } + + static bool FaceBounds(ContentsSceneNode brush, int face, float extent, out float3 min, out float3 max) + { + min = new float3(float.PositiveInfinity); + max = new float3(float.NegativeInfinity); + var planes = brush.TreePlanes(); + if (face < 0 || face >= planes.Length) return false; + bool any = false; + foreach (var v in ContentsFaceSampler.FacePolygon(planes, face, extent)) + { + min = math.min(min, v); + max = math.max(max, v); + any = true; + } + return any; + } + + static ContentsScene Localise(ContentsScene scene, float3 min, float3 max, float extent, string keep, + Dictionary bounds) + { + var result = new ContentsScene(); + foreach (var root in scene.roots) + { + var kept = LocaliseNode(root, min, max, extent, keep, bounds); + if (kept != null) result.Add(kept); + } + return result; + } + + static ContentsSceneNode LocaliseNode(ContentsSceneNode node, float3 min, float3 max, float extent, string keep, + Dictionary bounds) + { + if (node.IsBrush) + { + if (node.operation == CSGOperationType.Intersecting || node.name == keep) + return node; + if (!bounds.TryGetValue(node, out var box)) + { + if (!BoundsOf(node, extent, out var bmin, out var bmax)) + return null; + bounds[node] = box = (bmin, bmax); + } + bool overlaps = math.all(box.Item1 <= max) && math.all(box.Item2 >= min); + return overlaps ? node : null; + } + var copy = ContentsSceneNode.Composite(node.operation); + copy.name = node.name; + if (node.children != null) + { + foreach (var child in node.children) + { + var kept = LocaliseNode(child, min, max, extent, keep, bounds); + if (kept != null) copy.children.Add(kept); + } + } + return copy.children.Count == 0 ? null : copy; + } + + static ContentsScene Without(ContentsScene scene, HashSet remove) + { + var result = new ContentsScene(); + foreach (var root in scene.roots) + { + var kept = WithoutNode(root, remove); + if (kept != null) result.Add(kept); + } + return result; + } + + static ContentsSceneNode WithoutNode(ContentsSceneNode node, HashSet remove) + { + if (node.IsBrush) + return remove.Contains(node) ? null : node; + var copy = ContentsSceneNode.Composite(node.operation); + copy.name = node.name; + foreach (var child in node.children) + { + var kept = WithoutNode(child, remove); + if (kept != null) copy.children.Add(kept); + } + return copy.children.Count == 0 ? null : copy; + } + + static bool FailsAt(ContentsScene scene, Site site, Stats stats) + { + var brush = FindBrush(scene, site.brushName); + if (brush == null) + return false; + stats.runs++; + try + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + harness.JudgeEachUpdate = false; + if (!harness.Update()) + return false; + if (CompactHierarchyManager.TreeUpdate.kExactCSG) + return FailsExactlyAt(harness, site); + var oracle = new ContentsOracle(harness.Scene); + var plane = oracle.TreePlanesOf(brush)[site.face]; + for (int i = 0; i < site.points.Count; i++) + { + var problem = ContentsComparison.MismatchAt(harness, oracle, plane, site.points[i]); + if (problem != null && ContentsComparison.KindOf(problem) == site.kinds[i]) + return true; + } + return false; + } + } + catch (Exception e) when (!IsHarnessRefusal(e)) + { + RecordException(scene, e, stats); + return false; + } + } + + const float kExactSiteMargin = 0.5f; + + static bool FailsExactlyAt(ContentsTreeHarness harness, Site site) + { + var mismatches = ExactJudge.FindMismatches(harness, int.MaxValue, out _); + var min = site.min - kExactSiteMargin; + var max = site.max + kExactSiteMargin; + var kinds = new HashSet(site.kinds); + foreach (var mismatch in mismatches) + { + if (mismatch.brush == null || mismatch.brush.name != site.brushName || mismatch.face != site.face) + continue; + if (!math.all(mismatch.point >= min) || !math.all(mismatch.point <= max)) + continue; + if (kinds.Contains(ContentsComparison.KindOf(mismatch.problem))) + return true; + } + return false; + } + + // The harness refuses to flush while a model in the open scene has pending work: a setup problem, not a result + static bool IsHarnessRefusal(Exception e) => e is InvalidOperationException && e.Message.Contains("Chisel model"); + + static void RecordException(ContentsScene scene, Exception e, Stats stats) + { + var kind = e.GetType().Name + ": " + e.Message; + if (!stats.exceptionKinds.Add(kind) && stats.exceptions.Count > 20) + return; + var text = new StringBuilder(); + text.AppendLine($"EXCEPTION in a CSG run over {BrushCount(scene)} brush(es): {kind}"); + text.AppendLine(e.StackTrace); + if (BrushCount(scene) <= 12) + text.AppendLine(ContentsSceneShrinker.ToCSharp(scene, "threw")); + stats.exceptions.Add(text.ToString()); + } + + static ContentsScene ShrinkSite(ContentsScene start, Site site, Stats stats, Stopwatch clock, double deadline, + out bool complete) + { + complete = false; + var current = start; + var candidates = new List(); + foreach (var brush in AllBrushes(current)) + if (brush.name != site.brushName) + candidates.Add(brush); + + int chunk = math.max(1, candidates.Count / 2); + while (candidates.Count > 0) + { + bool removed = false; + for (int i = 0; i < candidates.Count;) + { + if (clock.Elapsed.TotalSeconds > deadline) + return current; + var count = math.min(chunk, candidates.Count - i); + var candidate = Without(current, new HashSet(candidates.GetRange(i, count))); + if (FailsAt(candidate, site, stats)) + { + current = candidate; + candidates.RemoveRange(i, count); + removed = true; + } + else + i += count; + } + if (chunk > 1) + chunk = math.max(1, chunk / 2); + else if (!removed) + break; + } + + current = Simplify(current, site, stats, clock, deadline); + complete = clock.Elapsed.TotalSeconds <= deadline; + return current; + } + + static ContentsScene Simplify(ContentsScene scene, Site site, Stats stats, Stopwatch clock, double deadline) + { + bool changed = true; + while (changed && clock.Elapsed.TotalSeconds <= deadline) + { + changed = false; + for (int index = 0; ; index++) + { + var candidate = Dissolve(scene, index, out var exists); + if (!exists) + break; + if (candidate != null && FailsAt(candidate, site, stats)) + { + scene = candidate; + changed = true; + break; + } + } + } + return scene; + } + + static ContentsScene Dissolve(ContentsScene scene, int index, out bool exists) + { + int running = 0; + var state = 0; // 0 not reached, 1 dissolved, 2 reached but not dissolvable + var roots = DissolveIn(scene.roots, index, ref running, ref state); + exists = state != 0; + if (state != 1) + return null; + var result = new ContentsScene(); + foreach (var root in roots) + result.Add(root); + return result; + } + + static List DissolveIn(List nodes, int index, ref int running, ref int state) + { + var result = new List(nodes.Count); + foreach (var node in nodes) + { + if (node.IsBrush || state != 0) + { + result.Add(node); + continue; + } + if (running++ == index) + { + var lifted = Lift(node); + if (lifted != null) { result.AddRange(lifted); state = 1; } + else { result.Add(node); state = 2; } + continue; + } + var copy = ContentsSceneNode.Composite(node.operation); + copy.name = node.name; + copy.children.AddRange(DissolveIn(node.children, index, ref running, ref state)); + result.Add(copy); + } + return result; + } + + // What a composite can be replaced by in its parent without changing what the oracle says, or null. + static List Lift(ContentsSceneNode composite) + { + var children = composite.children; + if (children.Count == 0 || !children.TrueForAll(child => child.operation == CSGOperationType.Additive)) + return null; + if (composite.operation == CSGOperationType.Additive) + return new List(children); + if (children.Count == 1) + return new List { WithOperation(children[0], composite.operation) }; + return null; + } + + static ContentsSceneNode WithOperation(ContentsSceneNode node, CSGOperationType operation) + { + if (node.IsBrush) + { + var brush = ContentsSceneNode.Brush(node.localPlanes, node.contents, operation, node.localToTree, node.name); + brush.entityID = node.entityID; + brush.WithMesh(node.localVertices, node.polygonLoops); + return brush; + } + var copy = ContentsSceneNode.Composite(operation, node.children.ToArray()); + copy.name = node.name; + return copy; + } + + static string Signature(ContentsScene scene) + { + var names = new List(); + foreach (var brush in AllBrushes(scene)) + names.Add(brush.name ?? "?"); + names.Sort(StringComparer.Ordinal); + return string.Join("|", names); + } + + /// + /// One minimal scene per distinct failing spot, reported through as each is found. Each is + /// reduced to the brushes near its points and shrunk while its points still fail; sites the budget doesn't reach are listed. + /// + public static List HarvestSites(ContentsScene scene, string label, double budgetSeconds, Action emit, + int maxWholeSceneShrinks = 2) + { + var clock = Stopwatch.StartNew(); + var stats = new Stats(); + var findings = new List(); + + var names = new HashSet(); + foreach (var brush in AllBrushes(scene)) + { + if (brush.name == null || !names.Add(brush.name)) + { + emit($" REFUSED: brush names must be unique and non-null to follow a site through shrinking ('{brush.name}')\n"); + return findings; + } + } + + var extent = RequiredExtent(scene); + var sweep = Run(scene, extent); + stats.runs++; + if (!sweep.updated) + { + emit(" THE CSG UPDATE DID NOT RUN - nothing was examined\n"); + return findings; + } + var text = new StringBuilder(); + int withMesh = 0; + foreach (var brush in AllBrushes(scene)) + if (brush.localVertices != null) withMesh++; + text.AppendLine(sweep.planeMismatches.Count == 0 + ? $" replay: {withMesh} brush(es) rebuilt from their own mesh, every one with exactly the scene's planes" + : $" REPLAY IS NOT THE SCENE: {sweep.planeMismatches.Count} of {withMesh} brush(es) rebuilt from their own mesh " + + $"registered other planes, e.g. {string.Join("; ", sweep.planeMismatches.GetRange(0, math.min(3, sweep.planeMismatches.Count)))}"); + if (!sweep.Complete) + { + text.AppendLine($" WARNING: {sweep.unexamined.Count} face(s) produced NO samples - not examined, NOT passed:"); + foreach (var face in sweep.unexamined) + text.AppendLine(" " + face); + } + if (!sweep.Disagrees) + { + text.AppendLine($" agrees with the oracle everywhere it was examined ({sweep.checkedSamples} samples, " + + $"{sweep.skipped} skipped near crossing planes, extent {F(extent)})"); + emit(text.ToString()); + return findings; + } + + var sites = SitesOf(sweep.mismatches, label); + var faces = new HashSet<(ContentsSceneNode, int)>(); + foreach (var mismatch in sweep.mismatches) + faces.Add((mismatch.brush, mismatch.face)); + text.AppendLine($" {sweep.mismatches.Count} mismatch(es) over {sweep.checkedSamples} samples ({sweep.skipped} skipped " + + $"near crossing planes, extent {F(extent)}), on {faces.Count} brush face(s), in {sites.Count} site(s); " + + $"sweep took {clock.Elapsed.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s"); + emit(text.ToString()); + + // cheapest first: the size of each site's closest neighbourhood + var bounds = new Dictionary(); + var cost = new Dictionary(); + foreach (var site in sites) + cost[site] = BrushCount(Localise(scene, site.min - kPointMargins[0], site.max + kPointMargins[0], extent, site.brushName, bounds)); + var ordered = new List(sites); + ordered.Sort((a, b) => cost[a] != cost[b] ? cost[a].CompareTo(cost[b]) : string.CompareOrdinal(a.Key, b.Key)); + + var bySignature = new Dictionary(); + int wholeSceneShrinks = 0, credited = 0, unreached = 0, lost = 0; + for (int s = 0; s < ordered.Count; s++) + { + var site = ordered[s]; + var prefix = $" site {s + 1}/{ordered.Count} {site.Key} ({site.points.Count} point(s), {site.Kind}): "; + if (clock.Elapsed.TotalSeconds > budgetSeconds) + { + emit(prefix + "NOT REACHED - time budget spent\n"); + unreached++; + continue; + } + + Finding creditedTo = null; + foreach (var finding in findings) + { + if (FindBrush(finding.minimal, site.brushName) != null && FailsAt(finding.minimal, site, stats)) + { + creditedTo = finding; + break; + } + } + if (creditedTo != null) + { + creditedTo.sites.Add(site); + credited++; + emit(prefix + $"reproduced by FINDING {creditedTo.number}\n"); + continue; + } + + var siteClock = Stopwatch.StartNew(); + int runsBefore = stats.runs; + ContentsScene start = null; + string origin = null; + foreach (var margin in kPointMargins) + { + var local = Localise(scene, site.min - margin, site.max + margin, extent, site.brushName, bounds); + if (FailsAt(local, site, stats)) + { + start = local; + origin = $"points +{F(margin)}"; + break; + } + } + if (start == null) + { + var brush = FindBrush(scene, site.brushName); + if (FaceBounds(brush, site.face, extent, out var fmin, out var fmax)) + { + var local = Localise(scene, fmin - kFaceMargin, fmax + kFaceMargin, extent, site.brushName, bounds); + if (FailsAt(local, site, stats)) + { + start = local; + origin = $"face +{F(kFaceMargin)}"; + } + } + } + if (start == null) + { + if (wholeSceneShrinks >= maxWholeSceneShrinks || clock.Elapsed.TotalSeconds > budgetSeconds * 0.5) + { + emit(prefix + "needs more than its neighbourhood; NOT SHRUNK (whole-scene shrinks are capped at " + + $"{maxWholeSceneShrinks} per scene and the first half of the budget)\n"); + unreached++; + continue; + } + if (!FailsAt(scene, site, stats)) + { + emit(prefix + "does not fail when the scene is rebuilt - NOT REPRODUCIBLE, not recorded\n"); + lost++; + continue; + } + start = scene; + origin = "whole scene"; + wholeSceneShrinks++; + } + + var deadline = math.min(budgetSeconds, clock.Elapsed.TotalSeconds + 300.0); + var minimal = ShrinkSite(start, site, stats, clock, deadline, out var complete); + // The result, checked once more + if (!FailsAt(minimal, site, stats)) + { + emit(prefix + "the shrunk scene no longer fails - NOT RECORDED (a harvester bug)\n"); + lost++; + continue; + } + + var signature = Signature(minimal); + if (bySignature.TryGetValue(signature, out var existing)) + { + existing.sites.Add(site); + credited++; + emit(prefix + $"shrank to the same brushes as FINDING {existing.number}\n"); + continue; + } + + var minimalExtent = RequiredExtent(minimal); + var confirmed = Run(minimal, minimalExtent); + stats.runs++; + var result = new Finding + { + number = findings.Count + 1, + label = label, + minimal = minimal, + extent = minimalExtent, + unexamined = confirmed.unexamined, + brushesStart = BrushCount(start), + brushesAfter = BrushCount(minimal), + origin = origin, + complete = complete, + runs = stats.runs - runsBefore, + seconds = siteClock.Elapsed.TotalSeconds + }; + foreach (var mismatch in confirmed.mismatches) + result.mismatches.Add(mismatch.Format(label)); + result.sites.Add(site); + bySignature[signature] = result; + findings.Add(result); + emit(prefix + $"-> FINDING {result.number}\n" + FindingText(result)); + } + + var summary = new StringBuilder(); + summary.AppendLine(); + summary.AppendLine($"# SUMMARY {label}: {findings.Count} distinct minimal failing scene(s) from {sites.Count} site(s); " + + $"{credited} site(s) credited to an earlier finding, {unreached} not reached or not shrunk, " + + $"{lost} not reproducible; {stats.runs} CSG run(s) in " + + $"{clock.Elapsed.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)} s"); + if (stats.exceptions.Count > 0) + { + summary.AppendLine($"# {stats.exceptions.Count} EXCEPTION(S) inside CSG runs (findings in their own right):"); + foreach (var exception in stats.exceptions) + summary.AppendLine(exception); + } + else + summary.AppendLine("# no exception was thrown by any CSG run"); + emit(summary.ToString()); + return findings; + } + + /// One finding as report text, with markers a script can cut the C# out by. + public static string FindingText(Finding finding) + { + var text = new StringBuilder(); + text.AppendLine(); + text.AppendLine($"=== FINDING {finding.number}: {finding.brushesAfter} brush(es); kind: {finding.sites[0].Kind}; " + + $"shrunk from {finding.origin} ({finding.brushesStart} brushes) in {finding.runs} runs, " + + $"{finding.seconds.ToString("0.0", CultureInfo.InvariantCulture)} s" + + (finding.complete ? "" : "; PARTIAL - the budget stopped the shrink, not minimal")); + text.AppendLine($" site: {finding.sites[0].Key}"); + for (int i = 0; i < finding.sites[0].messages.Count && i < 3; i++) + text.AppendLine(" as swept: " + finding.sites[0].messages[i]); + text.AppendLine($" the minimal scene disagrees at {finding.mismatches.Count} point(s) (extent {F(finding.extent)}):"); + for (int i = 0; i < finding.mismatches.Count && i < 12; i++) + text.AppendLine(" " + finding.mismatches[i]); + if (finding.mismatches.Count > 12) + text.AppendLine($" ... and {finding.mismatches.Count - 12} more"); + if (finding.unexamined.Count > 0) + text.AppendLine($" {finding.unexamined.Count} face(s) of the minimal scene produced no samples: {string.Join("; ", finding.unexamined)}"); + text.AppendLine("--- C#"); + text.Append(finding.CSharp); + text.AppendLine("--- END"); + return text.ToString(); + } + } +} diff --git a/Core/Tests/Contents/ContentsHarvest.cs.meta b/Core/Tests/Contents/ContentsHarvest.cs.meta new file mode 100644 index 00000000..2bafeac6 --- /dev/null +++ b/Core/Tests/Contents/ContentsHarvest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64a1c0fc85da20649bc6c88a8e7adf48 \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsListScope.cs b/Core/Tests/Contents/ContentsListScope.cs new file mode 100644 index 00000000..b7c18bb2 --- /dev/null +++ b/Core/Tests/Contents/ContentsListScope.cs @@ -0,0 +1,41 @@ +using System; + +namespace Chisel.Core.Tests +{ + public sealed class ContentsListScope : IDisposable + { + // The list the generators draw their types from + public const int kGlass = 1, kGrate = 2, kWater = 3; + public static readonly string[] kDefaultNames = { ChiselContentsList.kSolidName, "Glass", "Grate", "Water" }; + + readonly ChiselContentsList previous; + ChiselContentsList installed; + + public ContentsListScope() : this(kDefaultNames) { } + + public ContentsListScope(params string[] names) + { + previous = ChiselContentsList.Instance; + Install(names); + } + + public void Install(params string[] names) + { + var old = installed; + installed = ChiselContentsList.Create(names); + ChiselContentsList.Instance = installed; + CompactHierarchyManager.ApplyContentsList(); + if (old != null) + UnityEngine.Object.DestroyImmediate(old); + } + + public void Dispose() + { + ChiselContentsList.Instance = previous; + CompactHierarchyManager.ApplyContentsList(); + if (installed != null) + UnityEngine.Object.DestroyImmediate(installed); + installed = null; + } + } +} diff --git a/Core/Tests/Contents/ContentsListScope.cs.meta b/Core/Tests/Contents/ContentsListScope.cs.meta new file mode 100644 index 00000000..8a300102 --- /dev/null +++ b/Core/Tests/Contents/ContentsListScope.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64ba8e469c04bda4f89eefc30fa154fd \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsListTests.cs b/Core/Tests/Contents/ContentsListTests.cs new file mode 100644 index 00000000..7a8d9341 --- /dev/null +++ b/Core/Tests/Contents/ContentsListTests.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + // How the contents list finds its entries + [TestFixture] + [Category("Contents")] + public class ContentsListTests + { + [Test] + public void IndexOf_FindsAnEntryByName_IgnoringCaseAndSpaces() + { + var list = ChiselContentsList.Create(ChiselContentsList.kSolidName, "Glass", "Water"); + try + { + Assert.That(list.IndexOf("Solid"), Is.EqualTo(ChiselContentsList.kSolidIndex)); + Assert.That(list.IndexOf("glass"), Is.EqualTo(1)); + Assert.That(list.IndexOf(" Water "), Is.EqualTo(2)); + Assert.That(list.IndexOf("Grate"), Is.EqualTo(-1)); + Assert.That(list.IndexOf(""), Is.EqualTo(-1)); + Assert.That(list.IndexOf(null), Is.EqualTo(-1)); + } + finally + { + UnityEngine.Object.DestroyImmediate(list); + } + } + + // A list handed in (by a test, or by the settings page) is the one in force until it is replaced, and + // putting the previous one back restores it + [Test] + public void AnAssignedList_IsTheOneInForce() + { + var previous = ChiselContentsList.Instance; + var list = ChiselContentsList.Create(ChiselContentsList.kSolidName, "Glass"); + try + { + ChiselContentsList.Instance = list; + Assert.That(ChiselContentsList.Instance, Is.SameAs(list)); + Assert.That(ChiselContentsList.Instance.IsDefault, Is.False); + } + finally + { + ChiselContentsList.Instance = previous; + UnityEngine.Object.DestroyImmediate(list); + } + Assert.That(ChiselContentsList.Instance, Is.SameAs(previous)); + } + } +} diff --git a/Core/Tests/Contents/ContentsListTests.cs.meta b/Core/Tests/Contents/ContentsListTests.cs.meta new file mode 100644 index 00000000..462b3f24 --- /dev/null +++ b/Core/Tests/Contents/ContentsListTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb0f503ecd05a8a44bb9e2a1aee0c98e \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsOracle.cs b/Core/Tests/Contents/ContentsOracle.cs new file mode 100644 index 00000000..0f70630b --- /dev/null +++ b/Core/Tests/Contents/ContentsOracle.cs @@ -0,0 +1,271 @@ +using System.Collections.Generic; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + public enum ContentsFaceVerdict + { + // Nothing is drawn here: either both sides are filled, or both are empty. + None, + // The face is drawn facing out of its brush: filled behind it, empty in front. + FacingOut, + // The face is drawn facing into its brush, the wall a carve leaves behind: empty behind, + // filled in front. + FacingIn + } + + public sealed class ContentsOracle + { + public const int kSolidContents = ContentsSceneNode.kSolidContents; + + sealed class Entry + { + public ContentsSceneNode node; + public float4[] treePlanes; + // True when this brush carves: its own operation is subtractive or intersecting, or it + // sits inside a composite that is. + public bool carving; + // Left to right order in the scene, so failures name a stable brush. + public int order; + } + + readonly ContentsScene scene; + readonly List entries = new List(); + readonly Dictionary byNode = new Dictionary(); + + readonly Dictionary places = new Dictionary(); + readonly Dictionary> intersecting = new Dictionary>(); + readonly List intersectingRoots = new List(); + + public ContentsOracle(ContentsScene scene) + { + this.scene = scene; + Flatten(scene.roots, null, ancestorCarving: false); + } + + void Flatten(List nodes, ContentsSceneNode parent, bool ancestorCarving) + { + if (nodes == null) + return; + for (int i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + places[node] = (parent, i); + if (node.operation == CSGOperationType.Intersecting) + { + if (parent == null) + intersectingRoots.Add(i); + else + { + if (!intersecting.TryGetValue(parent, out var list)) + intersecting[parent] = list = new List(); + list.Add(i); + } + } + var carving = ancestorCarving || + node.operation == CSGOperationType.Subtractive || + node.operation == CSGOperationType.Intersecting; + if (node.IsBrush) + { + var entry = new Entry + { + node = node, + treePlanes = node.TreePlanes(), + carving = carving, + order = entries.Count + }; + entries.Add(entry); + byNode[node] = entry; + } + else + Flatten(node.children, node, carving); + } + } + + public IReadOnlyList Brushes + { + get + { + var result = new List(entries.Count); + foreach (var entry in entries) + result.Add(entry.node); + return result; + } + } + + public float4[] TreePlanesOf(ContentsSceneNode brush) => byNode[brush].treePlanes; + public bool IsCarving(ContentsSceneNode brush) => byNode[brush].carving; + public int OrderOf(ContentsSceneNode brush) => byNode[brush].order; + + public bool IsFilled(float3 point, int judgingContents) + { + return Evaluate(scene.roots, brush => Inside(byNode[brush].treePlanes, point), judgingContents); + } + + // The same, with each brush's membership decided by the caller: ExactJudge asks about points an infinitely small + // step off a plane, which only exact predicates can place. + public bool IsFilled(System.Func insideBrush, int judgingContents) + { + return Evaluate(scene.roots, insideBrush, judgingContents); + } + + public bool IsFilled(IReadOnlyList candidates, System.Func insideBrush, int judgingContents) + { + try + { + foreach (var candidate in candidates) + { + var node = candidate; + while (involved.Add(node)) + { + var place = places[node]; + InvolvedIn(place.parent).Add(place.index); + if (place.parent == null) + break; + node = place.parent; + } + } + return EvaluateInvolved(scene.roots, involvedRoots, intersectingRoots, insideBrush, judgingContents); + } + finally + { + involved.Clear(); + involvedRoots.Clear(); + foreach (var list in involvedChildren.Values) + { + list.Clear(); + spareLists.Push(list); + } + involvedChildren.Clear(); + } + } + + readonly HashSet involved = new HashSet(); + readonly Dictionary> involvedChildren = new Dictionary>(); + readonly List involvedRoots = new List(); + readonly Stack> spareLists = new Stack>(); + + List InvolvedIn(ContentsSceneNode parent) + { + if (parent == null) + return involvedRoots; + if (!involvedChildren.TryGetValue(parent, out var list)) + involvedChildren[parent] = list = spareLists.Count > 0 ? spareLists.Pop() : new List(); + return list; + } + + // Evaluate over the involved and the intersecting nodes of one list of siblings, in their order. + bool EvaluateInvolved(List nodes, List involvedIndices, List intersectingIndices, + System.Func insideBrush, int judgingContents) + { + involvedIndices?.Sort(); + int involvedCount = involvedIndices?.Count ?? 0, intersectingCount = intersectingIndices?.Count ?? 0; + var filled = false; + for (int i = 0, j = 0; i < involvedCount || j < intersectingCount;) + { + int index; + bool isInvolved; + if (j >= intersectingCount || (i < involvedCount && involvedIndices[i] <= intersectingIndices[j])) + { + index = involvedIndices[i++]; + isInvolved = true; + if (j < intersectingCount && intersectingIndices[j] == index) + j++; + } else + { + index = intersectingIndices[j++]; + isInvolved = false; + } + + var node = nodes[index]; + bool inside = false; + if (node.IsBrush) + { + if (!Participates(byNode[node], judgingContents)) + continue; + inside = isInvolved && insideBrush(node); + } else if (isInvolved) + { + involvedChildren.TryGetValue(node, out var involvedBelow); + intersecting.TryGetValue(node, out var intersectingBelow); + inside = EvaluateInvolved(node.children, involvedBelow, intersectingBelow, insideBrush, judgingContents); + } + + switch (node.operation) + { + case CSGOperationType.Additive: filled = filled || inside; break; + case CSGOperationType.Subtractive: filled = filled && !inside; break; + case CSGOperationType.Intersecting: filled = filled && inside; break; + } + } + return filled; + } + + bool Evaluate(List nodes, System.Func insideBrush, int judgingContents) + { + var filled = false; + if (nodes == null) + return false; + foreach (var node in nodes) + { + bool inside; + if (node.IsBrush) + { + var entry = byNode[node]; + if (!Participates(entry, judgingContents)) + continue; + inside = insideBrush(node); + } + else + inside = Evaluate(node.children, insideBrush, judgingContents); + + switch (node.operation) + { + case CSGOperationType.Additive: filled = filled || inside; break; + case CSGOperationType.Subtractive: filled = filled && !inside; break; + case CSGOperationType.Intersecting: filled = filled && inside; break; + } + } + return filled; + } + + static bool Participates(Entry entry, int judgingContents) + { + return entry.carving || + entry.node.contents == judgingContents || + entry.node.contents == kSolidContents; + } + + static bool Inside(float4[] planes, float3 point) + { + for (int i = 0; i < planes.Length; i++) + { + if (math.dot(planes[i].xyz, point) + planes[i].w > 0) + return false; + } + return true; + } + + public ContentsFaceVerdict JudgeFace(ContentsSceneNode brush, int planeIndex, float3 pointOnFace, float offset) + { + var entry = byNode[brush]; + var normal = entry.treePlanes[planeIndex].xyz; + var judgingContents = brush.contents; + + var filledBehind = IsFilled(pointOnFace - normal * offset, judgingContents); + var filledFront = IsFilled(pointOnFace + normal * offset, judgingContents); + + if (filledBehind && !filledFront) return ContentsFaceVerdict.FacingOut; + if (!filledBehind && filledFront) return ContentsFaceVerdict.FacingIn; + return ContentsFaceVerdict.None; + } + + public static bool WinsTie(int contents, int against) + { + if (contents == against) return false; + if (contents == kSolidContents) return true; + if (against == kSolidContents) return false; + return contents < against; + } + } +} diff --git a/Core/Tests/Contents/ContentsOracle.cs.meta b/Core/Tests/Contents/ContentsOracle.cs.meta new file mode 100644 index 00000000..48b0dc33 --- /dev/null +++ b/Core/Tests/Contents/ContentsOracle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: feff503c5751cd24e953c4c6ed616454 \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsOracleTests.cs b/Core/Tests/Contents/ContentsOracleTests.cs new file mode 100644 index 00000000..8799b5c2 --- /dev/null +++ b/Core/Tests/Contents/ContentsOracleTests.cs @@ -0,0 +1,166 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + // The oracle is the reference the whole contents suite is judged against, so it gets its own + // tests: every rule from the design, stated as a scene whose answer can be read off by hand. + [TestFixture] + [Category("Contents")] + public class ContentsOracleTests + { + const int kSolid = 0; + const int kGlass = 1; + const int kWater = 2; + + // Well above the 12.5 mm weld distance, so no probe lands in the band where welding decides. + const float kOffset = 0.05f; + + static ContentsSceneNode Box(float3 min, float3 max, int contents = kSolid, + CSGOperationType operation = CSGOperationType.Additive, string name = null) + { + return ContentsSceneNode.Brush(ContentsScene.BoxPlanes(min, max), contents, operation, name: name); + } + + // Index of the +y plane in ContentsScene.BoxPlanes. + const int kPlaneMaxY = 3; + const int kPlaneMinY = 2; + + [Test] + public void SolidFaceAgainstGlass_IsDrawn() + { + // A metal post with a glass pane ending flush against it + var post = Box(new float3(-1, -1, 0), new float3(1, 1, 4), kSolid, name: "post"); + var pane = Box(new float3(-0.5f, 1, 0), new float3(0.5f, 5, 4), kGlass, name: "pane"); + var oracle = new ContentsOracle(new ContentsScene().Add(post).Add(pane)); + + // Right where the pane covers the post's face + var onFace = new float3(0, 1, 2); + Assert.That(oracle.JudgeFace(post, kPlaneMaxY, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.FacingOut)); + } + + [Test] + public void GlassFaceAgainstSolid_IsRemoved() + { + var post = Box(new float3(-1, -1, 0), new float3(1, 1, 4), kSolid, name: "post"); + var pane = Box(new float3(-0.5f, 1, 0), new float3(0.5f, 5, 4), kGlass, name: "pane"); + var oracle = new ContentsOracle(new ContentsScene().Add(post).Add(pane)); + + // The pane's end face sits inside the post's half space: Solid removes every type. + var onFace = new float3(0, 1, 2); + Assert.That(oracle.JudgeFace(pane, kPlaneMinY, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.None)); + } + + [Test] + public void FaceInsideItsOwnType_IsRemoved() + { + var left = Box(new float3(-2, -1, 0), new float3(0, 1, 4), kGlass, name: "left"); + var right = Box(new float3(0, -1, 0), new float3(2, 1, 4), kGlass, name: "right"); + var oracle = new ContentsOracle(new ContentsScene().Add(left).Add(right)); + + // Glass in glass is removed, as solid in solid is + var onFace = new float3(0, 0, 2); + Assert.That(oracle.JudgeFace(left, 1, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.None)); + } + + [Test] + public void SolidWallInsideWater_StaysVisible() + { + var water = Box(new float3(-5, -5, 0), new float3(5, 5, 4), kWater, name: "water"); + var wall = Box(new float3(-1, -1, 0), new float3(1, 1, 4), kSolid, name: "wall"); + var oracle = new ContentsOracle(new ContentsScene().Add(water).Add(wall)); + + var onFace = new float3(0, 1, 2); + Assert.That(oracle.JudgeFace(wall, kPlaneMaxY, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.FacingOut)); + } + + [Test] + public void GlassAgainstWater_KeepsBothFaces() + { + var glass = Box(new float3(-2, -1, 0), new float3(0, 1, 4), kGlass, name: "glass"); + var water = Box(new float3(0, -1, 0), new float3(2, 1, 4), kWater, name: "water"); + var oracle = new ContentsOracle(new ContentsScene().Add(glass).Add(water)); + + var onFace = new float3(0, 0, 2); + Assert.That(oracle.JudgeFace(glass, 1, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.FacingOut), "glass side"); + Assert.That(oracle.JudgeFace(water, 0, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.FacingOut), "water side"); + } + + [Test] + public void CarvedWall_FacesIntoTheCarve() + { + var solid = Box(new float3(-2, -2, 0), new float3(2, 2, 4), kSolid, name: "solid"); + var notch = Box(new float3(-1, 1, 0), new float3(1, 3, 4), kSolid, CSGOperationType.Subtractive, "notch"); + var oracle = new ContentsOracle(new ContentsScene().Add(solid).Add(notch)); + + // The notch's -y face at y = 1 is the wall the carve leaves: empty behind it, filled ahead. + var onFace = new float3(0, 1, 2); + Assert.That(oracle.JudgeFace(notch, kPlaneMinY, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.FacingIn)); + } + + [Test] + public void CarvingBrushOfAnotherType_StillCarvesSolid() + { + var solid = Box(new float3(-2, -2, 0), new float3(2, 2, 4), kSolid, name: "solid"); + var notch = Box(new float3(-1, 1, 0), new float3(1, 3, 4), kWater, CSGOperationType.Subtractive, "notch"); + var oracle = new ContentsOracle(new ContentsScene().Add(solid).Add(notch)); + + // A carving brush carves every type, and its wall belongs to its own type - so a Water + // carve leaves walls where it cuts solid, which is a known limit of the design. + var onFace = new float3(0, 1, 2); + Assert.That(oracle.JudgeFace(notch, kPlaneMinY, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.FacingIn)); + Assert.That(oracle.IsCarving(notch), Is.True); + } + + [Test] + public void BrushInsideSubtractiveComposite_Carves() + { + var solid = Box(new float3(-2, -2, 0), new float3(2, 2, 4), kSolid, name: "solid"); + var inner = Box(new float3(-1, 1, 0), new float3(1, 3, 4), kGlass, name: "inner"); + var cut = ContentsSceneNode.Composite(CSGOperationType.Subtractive, inner); + var oracle = new ContentsOracle(new ContentsScene().Add(solid).Add(cut)); + + Assert.That(oracle.IsCarving(inner), Is.True, "a brush inside a subtractive composite carves"); + var onFace = new float3(0, 1, 2); + Assert.That(oracle.JudgeFace(inner, kPlaneMinY, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.FacingIn)); + } + + [Test] + public void EverythingSolid_MatchesTheAnswersWithoutContents() + { + // The control every generated scene also runs: with one type everywhere, the oracle must + // give the plain CSG answer - here, no face at a contact between two brushes. + var left = Box(new float3(-2, -1, 0), new float3(0, 1, 4), kSolid, name: "left"); + var right = Box(new float3(0, -1, 0), new float3(2, 1, 4), kSolid, name: "right"); + var oracle = new ContentsOracle(new ContentsScene().Add(left).Add(right)); + + var onFace = new float3(0, 0, 2); + Assert.That(oracle.JudgeFace(left, 1, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.None)); + Assert.That(oracle.JudgeFace(right, 0, onFace, kOffset), Is.EqualTo(ContentsFaceVerdict.None)); + } + + [Test] + public void OuterFacesAreUnaffectedByOtherTypes() + { + // Separation, stated on one face: a brush of another type touching nothing changes nothing. + var solid = Box(new float3(-1, -1, 0), new float3(1, 1, 4), kSolid, name: "solid"); + var far = Box(new float3(10, 10, 0), new float3(12, 12, 4), kWater, name: "far"); + var alone = new ContentsOracle(new ContentsScene().Add(solid)); + var both = new ContentsOracle(new ContentsScene().Add(solid).Add(far)); + + var onFace = new float3(0, 1, 2); + Assert.That(both.JudgeFace(solid, kPlaneMaxY, onFace, kOffset), + Is.EqualTo(alone.JudgeFace(solid, kPlaneMaxY, onFace, kOffset))); + } + + [Test] + public void TieOrder_SolidFirstThenListOrder() + { + Assert.That(ContentsOracle.WinsTie(kSolid, kGlass), Is.True, "Solid wins every tie"); + Assert.That(ContentsOracle.WinsTie(kGlass, kSolid), Is.False); + Assert.That(ContentsOracle.WinsTie(kGlass, kWater), Is.True, "the earlier list entry wins"); + Assert.That(ContentsOracle.WinsTie(kWater, kGlass), Is.False); + Assert.That(ContentsOracle.WinsTie(kGlass, kGlass), Is.False, "same type: either may draw"); + } + } +} diff --git a/Core/Tests/Contents/ContentsOracleTests.cs.meta b/Core/Tests/Contents/ContentsOracleTests.cs.meta new file mode 100644 index 00000000..535f89a9 --- /dev/null +++ b/Core/Tests/Contents/ContentsOracleTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a52f6b6354f2f82489c3c5184f10c37d \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsPipelineTests.cs b/Core/Tests/Contents/ContentsPipelineTests.cs new file mode 100644 index 00000000..6be92055 --- /dev/null +++ b/Core/Tests/Contents/ContentsPipelineTests.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class ContentsPipelineTests + { + [Test] + public void PostAndPane_AllSolid_MatchesTheOracle() + { + // A post and a pane, both Solid: the post's face is removed where the pane covers it + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(-1, -1, 0), new float3(1, 1, 4)), + name: "post")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(-0.5f, 1, 0), new float3(0.5f, 5, 4)), + name: "pane")); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "post and pane"); + } + + [Test] + public void GridBoxes_AllSolid_MatchTheOracle([Values(1, 2, 3, 4, 5, 6, 7, 8)] int seed) + { + var scene = ContentsSceneGenerators.AllSolid(ContentsSceneGenerators.GridBoxes(seed)); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "grid boxes seed " + seed); + } + + [Test] + public void RotatedGridBoxes_AllSolid_MatchTheOracle([Values(1, 2, 3, 4)] int seed) + { + var scene = ContentsSceneGenerators.AllSolid(ContentsSceneGenerators.RotatedGridBoxes(seed)); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "rotated grid boxes seed " + seed); + } + + [Test] + public void FreeBrushes_AllSolid_MatchTheOracle([Values(1, 2, 3, 4)] int seed) + { + var scene = ContentsSceneGenerators.AllSolid(ContentsSceneGenerators.FreeBrushes(seed)); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "free brushes seed " + seed); + } + + [Test] + public void CutBrushes_AllSolid_MatchTheOracle([Values(1, 2, 3, 4)] int seed) + { + var scene = ContentsSceneGenerators.AllSolid(ContentsSceneGenerators.CutBrushes(seed)); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "cut brushes seed " + seed); + } + } +} diff --git a/Core/Tests/Contents/ContentsPipelineTests.cs.meta b/Core/Tests/Contents/ContentsPipelineTests.cs.meta new file mode 100644 index 00000000..f67ae933 --- /dev/null +++ b/Core/Tests/Contents/ContentsPipelineTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a04885fc67e36e842a7cc72bbcee3c10 \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsRulesTests.cs b/Core/Tests/Contents/ContentsRulesTests.cs new file mode 100644 index 00000000..4f634e2d --- /dev/null +++ b/Core/Tests/Contents/ContentsRulesTests.cs @@ -0,0 +1,169 @@ +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + // The contents rules layer, exhaustively: every category, every ordered pair of the 32 types, and both + // carving flags, against references written straight from the design's two rules and its tie order. + [TestFixture] + [Category("Contents")] + public class ContentsRulesTests + { + const int kTypes = ChiselContentsList.kMaxEntries; + const int kSolid = ChiselContentsList.kSolidIndex; + + // Is a face of type a removed where it lies inside a brush of type b? Only its own type and Solid + // remove it. + static bool FaceRemovedInside(int a, int b) => a == b || b == kSolid; + + // Does b's face win a coplanar tie against a's? Solid first, then the order of the list. + static bool TieGoesTo(int b, int a) + { + if (a == b) return false; + if (b == kSolid) return true; + if (a == kSolid) return false; + return b < a; + } + + static CategoryIndex ExpectedRewrite(CategoryIndex computed, int a, bool carvingA, int b, bool carvingB, bool intersectingB, bool bIsLater) + { + if (a == b) + return computed; + var bCarvesA = carvingB && !carvingA && bIsLater; + var aCarvesB = carvingA && !carvingB && !bIsLater; + switch (computed) + { + case CategoryIndex.Inside: + return (carvingB || b == kSolid) ? computed : CategoryIndex.Outside; + case CategoryIndex.ReverseAligned: + if (carvingB) + return (bCarvesA && !intersectingB) ? CategoryIndex.Outside : computed; + return (!aCarvesB && b == kSolid) ? computed : CategoryIndex.Outside; + case CategoryIndex.Aligned: + if (carvingB) + return bCarvesA ? CategoryIndex.Inside : computed; + return (!aCarvesB && TieGoesTo(b, a)) ? CategoryIndex.Inside : CategoryIndex.Outside; + default: + return computed; + } + } + + [Test] + public void Rewrite_FollowsTheRules_ForEveryPairOfTypes([Values] bool carvingA, [Values] bool carvingB, [Values] bool intersectingB, [Values] bool bIsLater) + { + for (int category = 0; category <= (int)CategoryIndex.LastCategory; category++) + { + var computed = (CategoryIndex)category; + for (int a = 0; a < kTypes; a++) + { + for (int b = 0; b < kTypes; b++) + { + var expected = ExpectedRewrite(computed, a, carvingA, b, carvingB, intersectingB, bIsLater); + var actual = ContentsRules.Rewrite(computed, (byte)a, carvingA, (byte)b, carvingB, intersectingB, bIsLater); + if (actual != expected) + Assert.Fail($"{computed} of type {a}{(carvingA ? " (carving)" : "")} against type {b}{(carvingB ? (intersectingB ? " (intersecting)" : " (carving)") : "")}{(bIsLater ? " after it" : " before it")}: {actual}, expected {expected}"); + } + } + } + } + + [Test] + public void MatchingTypes_ChangeNothing([Values] bool carvingA, [Values] bool carvingB, [Values] bool intersectingB, [Values] bool bIsLater) + { + for (int category = 0; category <= (int)CategoryIndex.LastCategory; category++) + { + for (int t = 0; t < kTypes; t++) + { + Assert.That(ContentsRules.Rewrite((CategoryIndex)category, (byte)t, carvingA, (byte)t, carvingB, intersectingB, bIsLater), + Is.EqualTo((CategoryIndex)category)); + } + } + } + + // Two coplanar faces of different types facing the same way: exactly one is drawn + [Test] + public void AlignedFacesOfDifferentTypes_KeepExactlyOne() + { + for (int a = 0; a < kTypes; a++) + { + for (int b = 0; b < kTypes; b++) + { + if (a == b) + continue; + var keptA = ContentsRules.Rewrite(CategoryIndex.Aligned, (byte)a, false, (byte)b, carvingB: false, intersectingB: false, bIsLater: true) == CategoryIndex.Outside; + var keptB = ContentsRules.Rewrite(CategoryIndex.Aligned, (byte)b, false, (byte)a, carvingB: false, intersectingB: false, bIsLater: false) == CategoryIndex.Outside; + Assert.That(keptA != keptB, Is.True, $"types {a} and {b}"); + } + } + } + + [Test] + public void CarvingBrush_LeavesFacesOnATypeItCarvesToThatType() + { + for (int a = 0; a < kTypes; a++) + { + for (int b = 0; b < kTypes; b++) + { + if (a == b) + continue; + // a carves b, which comes first + Assert.That(ContentsRules.Rewrite(CategoryIndex.Aligned, (byte)a, carvingA: true, (byte)b, carvingB: false, intersectingB: false, bIsLater: false), Is.EqualTo(CategoryIndex.Outside), $"carving type {a} on type {b}"); + Assert.That(ContentsRules.Rewrite(CategoryIndex.ReverseAligned, (byte)a, carvingA: true, (byte)b, carvingB: false, intersectingB: false, bIsLater: false), Is.EqualTo(CategoryIndex.Outside), $"carving type {a} against type {b}"); + Assert.That(ContentsRules.Rewrite(CategoryIndex.Aligned, (byte)b, carvingA: false, (byte)a, carvingB: true, intersectingB: false, bIsLater: true), Is.EqualTo(CategoryIndex.Inside), $"type {b} on carving type {a}"); + Assert.That(ContentsRules.Rewrite(CategoryIndex.ReverseAligned, (byte)b, carvingA: false, (byte)a, carvingB: true, intersectingB: false, bIsLater: true), Is.EqualTo(CategoryIndex.Outside), $"type {b} against subtracting type {a}"); + Assert.That(ContentsRules.Rewrite(CategoryIndex.ReverseAligned, (byte)b, carvingA: false, (byte)a, carvingB: true, intersectingB: true, bIsLater: true), Is.EqualTo(CategoryIndex.ReverseAligned), $"type {b} against intersecting type {a}"); + // b comes after the carve: nothing of it is carved + Assert.That(ContentsRules.Rewrite(CategoryIndex.Aligned, (byte)b, carvingA: false, (byte)a, carvingB: true, intersectingB: false, bIsLater: false), Is.EqualTo(CategoryIndex.Aligned), $"type {b} after carving type {a}"); + Assert.That(ContentsRules.Rewrite(CategoryIndex.ReverseAligned, (byte)b, carvingA: false, (byte)a, carvingB: true, intersectingB: false, bIsLater: false), Is.EqualTo(CategoryIndex.ReverseAligned), $"type {b} after carving type {a}, back to back"); + } + } + } + + [Test] + public void Ties_AreDecidedOnce_AndSolidWinsEvery() + { + for (int a = 0; a < kTypes; a++) + { + Assert.That(ContentsRules.WinsTie((byte)a, (byte)a), Is.False, $"type {a} against itself"); + if (a != kSolid) + { + Assert.That(ContentsRules.WinsTie(ContentsRules.kSolid, (byte)a), Is.True, $"Solid against {a}"); + Assert.That(ContentsRules.WinsTie((byte)a, ContentsRules.kSolid), Is.False, $"{a} against Solid"); + } + for (int b = 0; b < kTypes; b++) + { + if (a == b) + continue; + Assert.That(ContentsRules.WinsTie((byte)a, (byte)b) != ContentsRules.WinsTie((byte)b, (byte)a), Is.True, $"types {a} and {b}"); + Assert.That(ContentsRules.WinsTie((byte)b, (byte)a), Is.EqualTo(TieGoesTo(b, a)), $"types {b} against {a}"); + } + } + } + + [Test] + public void RemovesWhenInside_FollowsTheTwoRules([Values] bool carvingB) + { + for (int a = 0; a < kTypes; a++) + { + for (int b = 0; b < kTypes; b++) + { + Assert.That(ContentsRules.RemovesWhenInside((byte)a, (byte)b, carvingB), + Is.EqualTo(carvingB || FaceRemovedInside(a, b)), + $"type {a} inside type {b}{(carvingB ? " (carving)" : "")}"); + } + } + } + + // An index past the end of the list builds as Solid + [Test] + public void Resolve_BuildsAnIndexOutsideTheListAsSolid() + { + Assert.That(ContentsRules.Resolve(0, 1), Is.EqualTo(0)); + Assert.That(ContentsRules.Resolve(3, 4), Is.EqualTo(3)); + Assert.That(ContentsRules.Resolve(4, 4), Is.EqualTo(0), "one past the end"); + Assert.That(ContentsRules.Resolve(1, 1), Is.EqualTo(0), "a list with only Solid"); + Assert.That(ContentsRules.Resolve(-1, 4), Is.EqualTo(0), "negative"); + Assert.That(ContentsRules.Resolve(kTypes, kTypes + 8), Is.EqualTo(0), "past the largest list there can be"); + Assert.That(ContentsRules.Resolve(kTypes - 1, kTypes), Is.EqualTo(kTypes - 1)); + } + } +} diff --git a/Core/Tests/Contents/ContentsRulesTests.cs.meta b/Core/Tests/Contents/ContentsRulesTests.cs.meta new file mode 100644 index 00000000..76728b7e --- /dev/null +++ b/Core/Tests/Contents/ContentsRulesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 08c8af9c1aaaf7148936b426972fbb9b \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsSceneDescription.cs b/Core/Tests/Contents/ContentsSceneDescription.cs new file mode 100644 index 00000000..2050cd75 --- /dev/null +++ b/Core/Tests/Contents/ContentsSceneDescription.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + public sealed class ContentsSceneNode + { + public const int kSolidContents = 0; + + // How this node combines with what its siblings to the left already produced. + public CSGOperationType operation = CSGOperationType.Additive; + + // Brush only: the convex volume's planes, in local space. Null on a composite. + public float4[] localPlanes; + + // Brush only: index into the project's contents list. 0 is Solid. + public int contents = kSolidContents; + + // Brush only: where the volume sits in the tree. + public float4x4 localToTree = float4x4.identity; + + // Composite only: children, evaluated left to right. Null on a brush. + public List children; + + // A label that survives shrinking, so a failure names the same brush in the printed fixture + // as in the scene that produced it. + public string name; + + // Brush only: the entity id its node is created with (0 unless a test needs to tell brushes apart) + public ulong entityID; + + public float3[] localVertices; + public int[] polygonLoops; + + public bool IsBrush => localPlanes != null; + + // This brush with its own mesh (see localVertices). + public ContentsSceneNode WithMesh(float3[] vertices, int[] loops) + { + localVertices = vertices; + polygonLoops = loops; + return this; + } + + public static ContentsSceneNode Brush(float4[] localPlanes, int contents = kSolidContents, + CSGOperationType operation = CSGOperationType.Additive, + float4x4 localToTree = default, string name = null) + { + return new ContentsSceneNode + { + localPlanes = localPlanes, + contents = contents, + operation = operation, + localToTree = localToTree.Equals(default(float4x4)) ? float4x4.identity : localToTree, + name = name + }; + } + + public static ContentsSceneNode Composite(CSGOperationType operation, params ContentsSceneNode[] children) + { + return new ContentsSceneNode + { + operation = operation, + children = new List(children) + }; + } + + public float4[] TreePlanes() + { + var inverseTranspose = math.transpose(math.inverse(localToTree)); + var result = new float4[localPlanes.Length]; + for (int i = 0; i < localPlanes.Length; i++) + { + var plane = math.mul(inverseTranspose, localPlanes[i]); + var length = math.length(plane.xyz); + result[i] = plane / length; + } + return result; + } + } + + // The whole scene: the top level children of the tree, left to right. + public sealed class ContentsScene + { + public readonly List roots = new List(); + + public ContentsScene Add(ContentsSceneNode node) + { + roots.Add(node); + return this; + } + + // Six planes of an axis aligned box, normals pointing out, so the inside is the negative side + // of every plane. + public static float4[] BoxPlanes(float3 min, float3 max) + { + return new[] + { + new float4(-1, 0, 0, min.x), + new float4( 1, 0, 0, -max.x), + new float4( 0, -1, 0, min.y), + new float4( 0, 1, 0, -max.y), + new float4( 0, 0, -1, min.z), + new float4( 0, 0, 1, -max.z) + }; + } + + public static float4[] BoxWithTangentPlanePlanes(float3 min, float3 max) + { + var box = BoxPlanes(min, max); + var cut = math.normalize(new float3(1, 1, 0)); + var plane = new float4(cut, -math.dot(cut, new float3(max.x, max.y, min.z))); + var result = new float4[box.Length + 1]; + box.CopyTo(result, 0); + result[box.Length] = plane; + return result; + } + + // A box with its (max.x, max.y) edge cut off halfway along both faces that meet there, which + // gives the generators a face that is not axis aligned while the brush keeps all six of the box's. + public static float4[] WedgePlanes(float3 min, float3 max) + { + var box = BoxPlanes(min, max); + var size = max - min; + var onCut = new float3(max.x - (size.x * 0.5f), max.y, min.z); + var normal = math.normalize(new float3(size.y, size.x, 0)); + var result = new float4[box.Length + 1]; + box.CopyTo(result, 0); + result[box.Length] = new float4(normal, -math.dot(normal, onCut)); + return result; + } + } +} diff --git a/Core/Tests/Contents/ContentsSceneDescription.cs.meta b/Core/Tests/Contents/ContentsSceneDescription.cs.meta new file mode 100644 index 00000000..215aba4f --- /dev/null +++ b/Core/Tests/Contents/ContentsSceneDescription.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e00fa8b258ddaf44cb3b5bf5b137dcfa \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsSceneGenerators.cs b/Core/Tests/Contents/ContentsSceneGenerators.cs new file mode 100644 index 00000000..c25b8ea8 --- /dev/null +++ b/Core/Tests/Contents/ContentsSceneGenerators.cs @@ -0,0 +1,297 @@ +using System.Collections.Generic; +using Unity.Mathematics; +using Random = Unity.Mathematics.Random; + +namespace Chisel.Core.Tests +{ + public static class ContentsSceneGenerators + { + public const int kDefaultContentsCount = 4; + + enum BrushKind { GridBox, Free, Cut, NearMissBox } + + public static ContentsScene GridBoxes(int seed, int contentsCount = kDefaultContentsCount) + { + var random = MakeRandom(seed); + var scene = new ContentsScene(); + BuildTree(ref random, scene, contentsCount, BrushKind.GridBox); + return scene; + } + + public static ContentsScene RotatedGridBoxes(int seed, int contentsCount = kDefaultContentsCount) + { + var random = MakeRandom(seed); + var scene = new ContentsScene(); + BuildTree(ref random, scene, contentsCount, BrushKind.GridBox); + var placement = float4x4.TRS(random.NextFloat3(-4f, 4f), + quaternion.Euler(random.NextFloat3(-math.PI, math.PI)), + new float3(1)); + ApplyPlacement(scene.roots, placement); + return scene; + } + + // Boxes, some with a redundant seventh plane (see BoxWithTangentPlanePlanes), each with its own + // rotation and scale, so brushes meet at arbitrary angles and almost never share a plane. + public static ContentsScene FreeBrushes(int seed, int contentsCount = kDefaultContentsCount) + { + var random = MakeRandom(seed); + var scene = new ContentsScene(); + BuildTree(ref random, scene, contentsCount, BrushKind.Free); + return scene; + } + + // The layouts of FreeBrushes with real wedges where FreeBrushes has its redundant plane: the + // random sequence is the same, only that seventh plane differs. + public static ContentsScene CutBrushes(int seed, int contentsCount = kDefaultContentsCount) + { + var random = MakeRandom(seed); + var scene = new ContentsScene(); + BuildTree(ref random, scene, contentsCount, BrushKind.Cut); + return scene; + } + + public static ContentsScene NearMissBoxes(int seed, bool rotated, bool allowIntersecting = false) + { + var random = MakeRandom(seed); + var scene = new ContentsScene(); + BuildTree(ref random, scene, 1, BrushKind.NearMissBox, allowIntersecting); + if (rotated) + Turn(ref random, scene); + return scene; + } + + public static ContentsScene NearMissSlivers(int seed, bool rotated) + { + var random = MakeRandom(seed); + var scene = new ContentsScene(); + var pairs = random.NextInt(1, 4); + for (int pair = 0; pair < pairs; pair++) + { + var axisU = random.NextInt(0, 3); + var axisV = (axisU + random.NextInt(1, 3)) % 3; + var axisW = 3 - axisU - axisV; + + var slope = random.NextInt(0, 3); + var run = slope == 1 ? 3 : 1; + var rise = slope == 2 ? 3 : 1; + var min = (float3)random.NextInt3(new int3(0), new int3(4)); + var size = (float3)random.NextInt3(new int3(2), new int3(4)); + size[axisU] = run + random.NextInt(1, 3); + size[axisV] = rise + random.NextInt(1, 3); + var max = min + size; + scene.Add(ContentsSceneNode.Brush(SlopedBoxPlanes(min, max, axisU, axisV, run, rise, 0), name: "w" + pair)); + + // The slope's height at u0, and the box's corner delta below it, inside the wedge + var along = random.NextFloat(0.25f, 0.75f); + var delta = NudgeMagnitude(ref random); + var u0 = max[axisU] - run + (along * run); + var v0 = max[axisV] - (along * rise) - delta; + + // Strictly inside the wedge along w, so the box's edges cross the inside of the slope + var neighbourMin = float3.zero; + var neighbourMax = float3.zero; + neighbourMin[axisU] = u0; + neighbourMax[axisU] = u0 + random.NextInt(1, 3); + neighbourMin[axisV] = v0; + neighbourMax[axisV] = v0 + random.NextInt(1, 3); + neighbourMin[axisW] = min[axisW] + (random.NextFloat(0.1f, 0.4f) * size[axisW]); + neighbourMax[axisW] = min[axisW] + (random.NextFloat(0.6f, 0.9f) * size[axisW]); + scene.Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(neighbourMin, neighbourMax), name: "n" + pair)); + } + if (rotated) + Turn(ref random, scene); + return scene; + } + + public static ContentsScene NearMissLedges(int seed, bool rotated) + { + var random = MakeRandom(seed); + var scene = new ContentsScene(); + var pairs = random.NextInt(1, 4); + for (int pair = 0; pair < pairs; pair++) + { + var axisU = random.NextInt(0, 3); + var axisV = (axisU + random.NextInt(1, 3)) % 3; + var axisW = 3 - axisU - axisV; + + var min = (float3)random.NextInt3(new int3(0), new int3(4)); + var max = min + (float3)random.NextInt3(new int3(2), new int3(4)); + var alongU = random.NextInt(1, (int)(max[axisU] - min[axisU])); + var alongV = random.NextInt(1, (int)(max[axisV] - min[axisV])); + var offset = NudgeMagnitude(ref random) * (random.NextBool() ? 1 : -1); + scene.Add(ContentsSceneNode.Brush(SlopedBoxPlanes(min, max, axisU, axisV, alongU, alongV, offset), + name: "w" + pair)); + + // On the cut face, with its edge on the grid line the slope just misses + var neighbourMin = float3.zero; + var neighbourMax = float3.zero; + neighbourMin[axisU] = max[axisU] - alongU; + neighbourMax[axisU] = neighbourMin[axisU] + random.NextInt(1, 3); + neighbourMin[axisV] = max[axisV]; + neighbourMax[axisV] = neighbourMin[axisV] + random.NextInt(1, 3); + neighbourMin[axisW] = min[axisW] + random.NextInt(0, (int)(max[axisW] - min[axisW])); + neighbourMax[axisW] = neighbourMin[axisW] + random.NextInt(1, 3); + scene.Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(neighbourMin, neighbourMax), name: "n" + pair)); + } + if (rotated) + Turn(ref random, scene); + return scene; + } + + // A box with its (maxU, maxV) edge cut off by a slope from (maxU - alongU - offset, maxV) to + // (maxU, maxV - alongV). + static float4[] SlopedBoxPlanes(float3 min, float3 max, int axisU, int axisV, int alongU, int alongV, float offset) + { + var onCut = max; + onCut[axisU] = max[axisU] - alongU - offset; + var normal = float3.zero; + normal[axisU] = alongV; + normal[axisV] = alongU + offset; + normal = math.normalize(normal); + + var box = ContentsScene.BoxPlanes(min, max); + var result = new float4[box.Length + 1]; + box.CopyTo(result, 0); + result[box.Length] = new float4(normal, -math.dot(normal, onCut)); + return result; + } + + static void Turn(ref Random random, ContentsScene scene) + { + var placement = float4x4.TRS(random.NextFloat3(-4f, 4f), + quaternion.Euler(random.NextFloat3(-math.PI, math.PI)), + new float3(1)); + ApplyPlacement(scene.roots, placement); + } + + // Unity.Mathematics.Random rejects a zero state, and a caller passing seed 0 is likely. + static Random MakeRandom(int seed) => new Random((uint)(seed * 747796405 + 2891336453) | 1u); + + static void BuildTree(ref Random random, ContentsScene scene, int contentsCount, BrushKind kind, bool allowIntersecting = true) + { + var count = random.NextInt(2, 7); + var made = new List(count); + for (int i = 0; i < count; i++) + { + var planes = kind == BrushKind.GridBox ? GridBoxPlanes(ref random) : + kind == BrushKind.NearMissBox ? NearMissBoxPlanes(ref random) : + FreeBrushPlanes(ref random, kind); + var node = ContentsSceneNode.Brush( + planes, + contents: random.NextInt(0, contentsCount), + operation: i == 0 ? CSGOperationType.Additive : RandomOperation(ref random, allowIntersecting), + name: "b" + i); + + if (kind == BrushKind.Free || kind == BrushKind.Cut) + { + node.localToTree = float4x4.TRS(random.NextFloat3(-3f, 3f), + quaternion.Euler(random.NextFloat3(-math.PI, math.PI)), + random.NextFloat3(0.5f, 2f)); + } + made.Add(node); + } + + var index = 0; + while (index < made.Count) + { + // Occasionally wrap two or three siblings in a composite, which is where a carving + // composite turns additive brushes into carving ones. + var groupSize = (index > 0 && made.Count - index >= 2 && random.NextFloat() < 0.3f) + ? random.NextInt(2, math.min(4, made.Count - index + 1)) + : 1; + if (groupSize == 1) + { + scene.Add(made[index]); + index++; + continue; + } + + var children = new ContentsSceneNode[groupSize]; + for (int i = 0; i < groupSize; i++) + children[i] = made[index + i]; + // A composite's children start from empty, so the first one has to add something. + children[0].operation = CSGOperationType.Additive; + scene.Add(ContentsSceneNode.Composite(RandomOperation(ref random, allowIntersecting), children)); + index += groupSize; + } + } + + static float4[] GridBoxPlanes(ref Random random) + { + var min = (float3)random.NextInt3(new int3(0), new int3(5)); + var max = math.min(min + (float3)random.NextInt3(new int3(1), new int3(4)), new float3(6)); + return ContentsScene.BoxPlanes(min, max); + } + + static float4[] FreeBrushPlanes(ref Random random, BrushKind kind) + { + var size = random.NextFloat3(0.75f, 3f); + var wedge = random.NextFloat() < 0.35f; + if (!wedge) + return ContentsScene.BoxPlanes(-size * 0.5f, size * 0.5f); + return kind == BrushKind.Cut ? ContentsScene.WedgePlanes(-size * 0.5f, size * 0.5f) + : ContentsScene.BoxWithTangentPlanePlanes(-size * 0.5f, size * 0.5f); + } + + static float4[] NearMissBoxPlanes(ref Random random) + { + var min = (float3)random.NextInt3(new int3(0), new int3(5)); + var max = math.min(min + (float3)random.NextInt3(new int3(1), new int3(4)), new float3(6)); + min += new float3(Nudge(ref random), Nudge(ref random), Nudge(ref random)); + max += new float3(Nudge(ref random), Nudge(ref random), Nudge(ref random)); + return ContentsScene.BoxPlanes(min, max); + } + + static float Nudge(ref Random random) + { + if (random.NextFloat() >= 0.4f) + return 0; + var magnitude = NudgeMagnitude(ref random); + return random.NextBool() ? magnitude : -magnitude; + } + + // 0.05 to 12 mm, log-uniformly: the band where the weld has decisions to make + static float NudgeMagnitude(ref Random random) => math.pow(10f, random.NextFloat(math.log10(0.00005f), math.log10(0.012f))); + + static CSGOperationType RandomOperation(ref Random random, bool allowIntersecting = true) + { + var roll = random.NextFloat(); + if (roll < 0.70f) return CSGOperationType.Additive; + if (roll < 0.90f || !allowIntersecting) return CSGOperationType.Subtractive; + return CSGOperationType.Intersecting; + } + + static void ApplyPlacement(List nodes, float4x4 placement) + { + foreach (var node in nodes) + { + if (node.IsBrush) + node.localToTree = math.mul(placement, node.localToTree); + else + ApplyPlacement(node.children, placement); + } + } + + // The control: the same geometry with one type everywhere draws exactly what it draws without contents + public static ContentsScene AllSolid(ContentsScene scene) + { + var copy = new ContentsScene(); + foreach (var node in scene.roots) + copy.Add(CloneAsSolid(node)); + return copy; + } + + static ContentsSceneNode CloneAsSolid(ContentsSceneNode node) + { + if (node.IsBrush) + { + return ContentsSceneNode.Brush(node.localPlanes, ContentsSceneNode.kSolidContents, + node.operation, node.localToTree, node.name); + } + var children = new ContentsSceneNode[node.children.Count]; + for (int i = 0; i < node.children.Count; i++) + children[i] = CloneAsSolid(node.children[i]); + return ContentsSceneNode.Composite(node.operation, children); + } + } +} diff --git a/Core/Tests/Contents/ContentsSceneGenerators.cs.meta b/Core/Tests/Contents/ContentsSceneGenerators.cs.meta new file mode 100644 index 00000000..b8adbd8d --- /dev/null +++ b/Core/Tests/Contents/ContentsSceneGenerators.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4523cf67d42446148b81c9f3bfbd9aa9 \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsSceneShrinker.cs b/Core/Tests/Contents/ContentsSceneShrinker.cs new file mode 100644 index 00000000..ccf07541 --- /dev/null +++ b/Core/Tests/Contents/ContentsSceneShrinker.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + public static class ContentsSceneShrinker + { + public static ContentsScene Shrink(ContentsScene scene, Func stillFails, + int maxPasses = 8) + { + var current = Clone(scene); + if (!stillFails(current)) + return current; + + for (int pass = 0; pass < maxPasses; pass++) + { + var removedAnything = false; + var count = CountNodes(current.roots); + for (int index = count - 1; index >= 0; index--) + { + var candidate = Clone(current); + if (!RemoveNodeAt(candidate.roots, index)) + continue; + if (candidate.roots.Count == 0 || !stillFails(candidate)) + continue; + current = candidate; + removedAnything = true; + break; + } + if (!removedAnything) + break; + } + + // A composite with a single child says nothing the child doesn't, unless the composite is + // what makes the child carve. + Flatten(current.roots); + return current; + } + + public static string ToCSharp(ContentsScene scene, string sceneName = "scene") + { + var text = new StringBuilder(); + text.AppendLine($"var {sceneName} = new ContentsScene()"); + foreach (var node in scene.roots) + AppendNode(text, node, "\t"); + text.AppendLine("\t;"); + return text.ToString(); + } + + static void AppendNode(StringBuilder text, ContentsSceneNode node, string indent) + { + text.Append(indent).Append(".Add("); + AppendExpression(text, node, indent); + text.AppendLine(")"); + } + + // The node as one expression. Children are arguments, not chained Adds, so they read as a list. + static void AppendExpression(StringBuilder text, ContentsSceneNode node, string indent) + { + if (node.IsBrush) + { + text.AppendLine("ContentsSceneNode.Brush(new float4[]"); + text.AppendLine($"{indent}{{"); + foreach (var plane in node.localPlanes) + text.AppendLine($"{indent}\tnew float4({F(plane.x)}, {F(plane.y)}, {F(plane.z)}, {F(plane.w)}),"); + text.AppendLine($"{indent}}}, contents: {node.contents}, operation: CSGOperationType.{node.operation},"); + text.Append($"{indent}\tlocalToTree: {Matrix(node.localToTree)}, name: \"{node.name}\")"); + if (node.localVertices != null) + { + // its own mesh, which registration derives the planes the CSG gets from + text.AppendLine(); + text.Append($"{indent}\t.WithMesh(new float3[] {{ "); + for (int i = 0; i < node.localVertices.Length; i++) + { + var v = node.localVertices[i]; + text.Append($"new float3({F(v.x)}, {F(v.y)}, {F(v.z)})").Append(i + 1 < node.localVertices.Length ? ", " : " "); + } + text.Append($"}}, new int[] {{ {string.Join(", ", node.polygonLoops)} }})"); + } + return; + } + + if (node.children.Count == 0) + { + text.Append($"ContentsSceneNode.Composite(CSGOperationType.{node.operation})"); + return; + } + text.AppendLine($"ContentsSceneNode.Composite(CSGOperationType.{node.operation},"); + for (int i = 0; i < node.children.Count; i++) + { + text.Append(indent).Append('\t'); + AppendExpression(text, node.children[i], indent + "\t"); + if (i + 1 < node.children.Count) + text.AppendLine(","); + } + text.Append(")"); + } + + static string F(float value) => value.ToString("R", CultureInfo.InvariantCulture) + "f"; + + static string Matrix(float4x4 matrix) + { + if (matrix.Equals(float4x4.identity)) + return "float4x4.identity"; + var text = new StringBuilder("new float4x4("); + for (int c = 0; c < 4; c++) + { + var column = matrix[c]; + text.Append($"new float4({F(column.x)}, {F(column.y)}, {F(column.z)}, {F(column.w)})"); + if (c < 3) + text.Append(", "); + } + text.Append(")"); + return text.ToString(); + } + + public static ContentsScene Clone(ContentsScene scene) + { + var copy = new ContentsScene(); + foreach (var node in scene.roots) + copy.Add(CloneNode(node)); + return copy; + } + + static ContentsSceneNode CloneNode(ContentsSceneNode node) + { + if (node.IsBrush) + { + return ContentsSceneNode.Brush(node.localPlanes, node.contents, node.operation, + node.localToTree, node.name).WithMesh(node.localVertices, node.polygonLoops); + } + var children = new ContentsSceneNode[node.children.Count]; + for (int i = 0; i < node.children.Count; i++) + children[i] = CloneNode(node.children[i]); + return ContentsSceneNode.Composite(node.operation, children); + } + + static int CountNodes(List nodes) + { + var count = 0; + foreach (var node in nodes) + { + count++; + if (!node.IsBrush) + count += CountNodes(node.children); + } + return count; + } + + // Removes the node at a depth-first index, wherever it sits in the tree. + static bool RemoveNodeAt(List nodes, int index) + { + var running = 0; + return RemoveNodeAt(nodes, index, ref running); + } + + static bool RemoveNodeAt(List nodes, int index, ref int running) + { + for (int i = 0; i < nodes.Count; i++) + { + if (running == index) + { + nodes.RemoveAt(i); + return true; + } + running++; + + var node = nodes[i]; + if (node.IsBrush) + continue; + if (RemoveNodeAt(node.children, index, ref running)) + { + if (node.children.Count == 0) + nodes.RemoveAt(i); + return true; + } + } + return false; + } + + static void Flatten(List nodes) + { + for (int i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + if (node.IsBrush) + continue; + Flatten(node.children); + if (node.children.Count == 1 && node.operation == CSGOperationType.Additive && + node.children[0].operation == CSGOperationType.Additive) + { + nodes[i] = node.children[0]; + i--; + } + } + } + } +} diff --git a/Core/Tests/Contents/ContentsSceneShrinker.cs.meta b/Core/Tests/Contents/ContentsSceneShrinker.cs.meta new file mode 100644 index 00000000..a46efb9b --- /dev/null +++ b/Core/Tests/Contents/ContentsSceneShrinker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 85b75fb519c5a214197914715600bb9e \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsSceneTests.cs b/Core/Tests/Contents/ContentsSceneTests.cs new file mode 100644 index 00000000..051229a3 --- /dev/null +++ b/Core/Tests/Contents/ContentsSceneTests.cs @@ -0,0 +1,388 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; +using static Chisel.Core.Tests.ContentsListScope; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class ContentsSceneTests + { + const int kSolid = ContentsSceneNode.kSolidContents; + + ContentsListScope list; + + [SetUp] public void InstallList() { list = new ContentsListScope(); } + [TearDown] public void RestoreList() { list.Dispose(); list = null; } + + static ContentsSceneNode Box(string name, float3 min, float3 max, int contents = kSolid, + CSGOperationType operation = CSGOperationType.Additive) + { + return ContentsSceneNode.Brush(ContentsScene.BoxPlanes(min, max), contents, operation, name: name); + } + + static ContentsScene Scene(params ContentsSceneNode[] roots) + { + var scene = new ContentsScene(); + foreach (var root in roots) + scene.Add(root); + return scene; + } + + static ContentsScene Reversed(ContentsScene scene) + { + var result = new ContentsScene(); + for (int i = scene.roots.Count - 1; i >= 0; i--) + result.Add(scene.roots[i]); + return result; + } + + #region Fixed scenes + // A frame and a glass pane: the post's face behind the pane's end is drawn, the pane's end against the post is not + [Test] + public void PostAndGlassPane_KeepThePostFace([Values] bool paneFirst) + { + var post = Box("post", new float3(-1, -1, 0), new float3(1, 1, 4)); + var pane = Box("pane", new float3(-0.5f, 1, 0), new float3(0.5f, 5, 4), kGlass); + var scene = paneFirst ? Scene(pane, post) : Scene(post, pane); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "post and glass pane"); + } + + // Hook 2: a brush wholly inside another. Solid inside water keeps its faces, water inside solid loses + // them, and inside its own type a brush loses them as it always has. + [Test] + public void BrushWhollyInsideAnother_FollowsTheRules([Values(kSolid, kGlass, kWater)] int inner, + [Values(kSolid, kGlass, kWater)] int outer, + [Values] bool innerFirst) + { + var small = Box("inner", new float3(1), new float3(2), inner); + var large = Box("outer", new float3(0), new float3(4), outer); + var scene = innerFirst ? Scene(small, large) : Scene(large, small); + ContentsComparison.BuildAndAssertMatchesOracle(scene, $"type {inner} inside type {outer}"); + } + + // Two types overlapping: each keeps its faces inside the other. + [Test] + public void GlassOverlappingWater_KeepsBothInteriors([Values] bool glassFirst) + { + var glass = Box("glass", new float3(0), new float3(2), kGlass); + var water = Box("water", new float3(1), new float3(3), kWater); + var scene = glassFirst ? Scene(glass, water) : Scene(water, glass); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "glass overlapping water"); + } + + // Coplanar faces facing the same way: the tie goes to the earlier type, whichever brush comes first, + // and to Solid over any other type. + [Test] + public void CoplanarTie_GoesToTheEarlierType([Values(kSolid, kGlass)] int winner, [Values] bool winnerFirst) + { + var large = Box("large", new float3(0), new float3(2), winner); + var half = Box("half", new float3(0), new float3(2, 2, 1), kWater); + var scene = winnerFirst ? Scene(large, half) : Scene(half, large); + ContentsComparison.BuildAndAssertMatchesOracle(scene, $"type {winner} tied with water"); + } + + // Faces touching back to back across three types: solid against glass, glass against water. + [Test] + public void BackToBack_AcrossTypes() + { + var scene = Scene(Box("solid", new float3(0, 0, 0), new float3(1, 2, 2)), + Box("glass", new float3(1, 0, 0), new float3(2, 2, 2), kGlass), + Box("water", new float3(2, 0, 0), new float3(3, 2, 2), kWater)); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "solid, glass, water in a row"); + ContentsComparison.BuildAndAssertMatchesOracle(Reversed(scene), "water, glass, solid in a row"); + } + + // A carve removes every type, and its walls are Solid: they show where it cut the floor, not where it + // cut the glass. + [Test] + public void CarveThroughSolidAndGlass() + { + var scene = Scene(Box("floor", new float3(0, 0, 0), new float3(6, 6, 1)), + Box("pane", new float3(2, 0, 1), new float3(2.5f, 6, 3), kGlass), + Box("carve", new float3(1, 1, 0.5f), new float3(5, 5, 4), operation: CSGOperationType.Subtractive)); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "carve through solid and glass"); + } + + // The design's pool: carved into the floor and then filled with water. The pool walls stay visible + // below the water surface. + [Test] + public void FloorPoolWater_KeepsThePoolWalls() + { + var scene = Scene(Box("floor", new float3(0, 0, 0), new float3(6, 6, 2)), + Box("pool", new float3(1, 1, 1), new float3(5, 5, 3), operation: CSGOperationType.Subtractive), + Box("water", new float3(1, 1, 1), new float3(5, 5, 1.75f), kWater)); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "floor, pool and water"); + } + + // A brush inside a subtractive composite carves, whatever its type, and its walls take its own type. + [Test] + public void GlassInsideACarvingComposite_Carves([Values(CSGOperationType.Subtractive, CSGOperationType.Intersecting)] CSGOperationType operation) + { + var scene = Scene(Box("floor", new float3(0, 0, 0), new float3(6, 6, 2)), + Box("water", new float3(0, 0, 2), new float3(6, 3, 3), kWater), + ContentsSceneNode.Composite(operation, + Box("cut", new float3(1, 1, 1), new float3(5, 5, 4), kGlass))); + ContentsComparison.BuildAndAssertMatchesOracle(scene, $"glass in a {operation} composite"); + } + + static IEnumerable CarvingOnAnotherType() + { + foreach (var baseType in new[] { kSolid, kGlass, kWater }) + foreach (var cutType in new[] { kSolid, kGlass, kGrate }) + { + if (baseType == cutType) + continue; + foreach (var operation in new[] { CSGOperationType.Intersecting, CSGOperationType.Subtractive }) + foreach (var inComposite in new[] { false, true }) + yield return new TestCaseData(baseType, cutType, operation, inComposite); + } + } + + [TestCaseSource(nameof(CarvingOnAnotherType))] + public void CarvingBrush_OnTheFacesOfAnotherType(int baseType, int cutType, CSGOperationType operation, bool inComposite) + { + var block = Box("block", new float3(0), new float3(2), baseType); + var cut = Box("cut", new float3(0), new float3(2, 2, 1), cutType, inComposite ? CSGOperationType.Additive : operation); + var scene = Scene(block, inComposite ? ContentsSceneNode.Composite(operation, cut) : cut); + ContentsComparison.BuildAndAssertMatchesOracle(scene, $"{operation} type {cutType} on the faces of type {baseType}{(inComposite ? ", in a composite" : "")}"); + } + + // A carving brush back to back with a brush of another type: that brush keeps its face, and no wall is left on it + [Test] + public void CarvingBrush_BackToBackWithAnotherType([Values(kSolid, kWater)] int baseType, [Values(kGlass, kGrate)] int cutType, + [Values(CSGOperationType.Subtractive, CSGOperationType.Intersecting)] CSGOperationType operation, + [Values] bool inComposite) + { + var block = Box("block", new float3(0), new float3(4, 4, 2), baseType); + var cut = Box("cut", new float3(1, 1, 2), new float3(3, 3, 3), cutType, inComposite ? CSGOperationType.Additive : operation); + var scene = Scene(block, inComposite ? ContentsSceneNode.Composite(operation, cut) : cut); + ContentsComparison.BuildAndAssertMatchesOracle(scene, $"{operation} type {cutType} on top of type {baseType}{(inComposite ? ", in a composite" : "")}"); + } + + // An additive composite doesn't make its brushes carve: water in one is looked through like any water. + [Test] + public void WaterInsideAnAdditiveComposite_IsLookedThrough() + { + var scene = Scene(Box("post", new float3(-1, -1, 0), new float3(1, 1, 4)), + ContentsSceneNode.Composite(CSGOperationType.Additive, + Box("pool", new float3(-3, -3, 0), new float3(3, 3, 2), kWater), + Box("spill", new float3(-0.5f, 1, 0), new float3(0.5f, 5, 3), kWater))); + ContentsComparison.BuildAndAssertMatchesOracle(scene, "water in an additive composite"); + } + #endregion + + #region Generated scenes + static readonly int[] kGridDefects = { 11, 37 }; + static readonly int[] kRotatedDefects = { 11 }; + static readonly int[] kFreeDefects = { 14, 21, 23, 25, 26 }; + const int kCarvingTieLimit = 7; + + static IEnumerable Seeds(int count, params int[] skip) => Enumerable.Range(1, count).Where(seed => !skip.Contains(seed)); + static IEnumerable GridSeeds() => Seeds(80, kGridDefects.Append(kCarvingTieLimit).ToArray()); + static IEnumerable RotatedSeeds() => Seeds(30, kRotatedDefects.Append(kCarvingTieLimit).ToArray()); + static IEnumerable FreeSeeds() => Seeds(30, kFreeDefects); + static IEnumerable CutSeeds() => Seeds(30); + + [Test] + public void GridBoxes_WithTypes_MatchTheOracle([ValueSource(nameof(GridSeeds))] int seed) + { + ContentsComparison.BuildAndAssertMatchesOracle(ContentsSceneGenerators.GridBoxes(seed), "grid boxes with types, seed " + seed); + } + + [Test] + public void RotatedGridBoxes_WithTypes_MatchTheOracle([ValueSource(nameof(RotatedSeeds))] int seed) + { + ContentsComparison.BuildAndAssertMatchesOracle(ContentsSceneGenerators.RotatedGridBoxes(seed), "rotated grid boxes with types, seed " + seed); + } + + [Test] + public void FreeBrushes_WithTypes_MatchTheOracle([ValueSource(nameof(FreeSeeds))] int seed) + { + ContentsComparison.BuildAndAssertMatchesOracle(ContentsSceneGenerators.FreeBrushes(seed), "free brushes with types, seed " + seed); + } + + [Test] + public void CutBrushes_WithTypes_MatchTheOracle([ValueSource(nameof(CutSeeds))] int seed) + { + ContentsComparison.BuildAndAssertMatchesOracle(ContentsSceneGenerators.CutBrushes(seed), "cut brushes with types, seed " + seed); + } + #endregion + + #region Properties + // Every brush sharing one type, whichever it is, draws exactly what the all-Solid scene draws. + [Test] + public void OneTypeEverywhere_DrawsWhatAllSolidDraws([Values(kGlass, kGrate, kWater)] int type, [Values(1, 2, 3, 4)] int seed) + { + var solid = ContentsSceneGenerators.AllSolid(ContentsSceneGenerators.GridBoxes(seed)); + var typed = WithType(ContentsSceneGenerators.GridBoxes(seed), type); + var expected = Capture(solid); + var actual = Capture(typed); + Assert.That(actual.Count, Is.EqualTo(expected.Count), $"seed {seed}: triangle count"); + for (int i = 0; i < expected.Count; i++) + { + if (!expected[i].Equals(actual[i])) + Assert.Fail($"seed {seed}: triangle {i} differs: {expected[i]} in the all-Solid scene, {actual[i]} with every brush type {type}"); + } + } + + static ContentsScene WithType(ContentsScene scene, int type) + { + void Apply(List nodes) + { + foreach (var node in nodes) + { + if (node.IsBrush) node.contents = type; + else Apply(node.children); + } + } + Apply(scene.roots); + return scene; + } + + // Every triangle as (brush name, corners), in a stable order + static List Capture(ContentsScene scene) + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + return harness.Triangles + .Select(t => $"{harness.NodeOf(t.brushID)?.name}: {Format(t.a)} {Format(t.b)} {Format(t.c)}") + .OrderBy(s => s, System.StringComparer.Ordinal) + .ToList(); + } + } + + static string Format(float3 v) => $"({v.x:R}, {v.y:R}, {v.z:R})"; + #endregion + + #region Incremental updates + // Only the pane changes, but the post's face behind it has to follow: changing a brush's type rebuilds + // the brushes it touches. + [Test] + public void ChangingAType_RebuildsTheBrushesItTouches() + { + var post = Box("post", new float3(-1, -1, 0), new float3(1, 1, 4)); + var pane = Box("pane", new float3(-0.5f, 1, 0), new float3(0.5f, 5, 4)); + using (var harness = ContentsTreeHarness.Build(Scene(post, pane))) + { + Assert.That(harness.Update(), Is.True); + ContentsComparison.AssertMatchesOracle(harness, "both solid"); + + harness.SetContents(pane, kGlass); + Assert.That(harness.Update(), Is.True, "changing a type updated nothing"); + ContentsComparison.AssertMatchesOracle(harness, "after the pane became glass"); + + harness.SetContents(pane, kSolid); + Assert.That(harness.Update(), Is.True); + ContentsComparison.AssertMatchesOracle(harness, "after the pane became solid again"); + } + } + + // A water brush wholly inside solid: its faces come and go with its type and with the outer brush's. + [Test] + public void ChangingATypeInside_RebuildsBothBrushes() + { + var outer = Box("outer", new float3(0), new float3(4)); + var inner = Box("inner", new float3(1), new float3(2), kWater); + using (var harness = ContentsTreeHarness.Build(Scene(outer, inner))) + { + Assert.That(harness.Update(), Is.True); + ContentsComparison.AssertMatchesOracle(harness, "water inside solid"); + + harness.SetContents(outer, kGlass); + Assert.That(harness.Update(), Is.True); + ContentsComparison.AssertMatchesOracle(harness, "water inside glass"); + + harness.SetContents(inner, kGlass); + Assert.That(harness.Update(), Is.True); + ContentsComparison.AssertMatchesOracle(harness, "glass inside glass"); + } + } + + // Random type changes on generated scenes: after each, the incremental update draws what the oracle expects + [Test] + public void RandomTypeChanges_IncrementalMatchesTheOracle([Values(1, 2, 3, 4)] int seed) + { + var scene = ContentsSceneGenerators.GridBoxes(seed); + var brushes = new ContentsOracle(scene).Brushes; + var random = new Unity.Mathematics.Random((uint)(seed * 2654435761u) | 1u); + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True); + ContentsComparison.AssertMatchesOracle(harness, $"seed {seed} as generated"); + for (int step = 0; step < 6; step++) + { + var brush = brushes[random.NextInt(brushes.Count)]; + var type = (brush.contents + random.NextInt(1, kDefaultNames.Length)) % kDefaultNames.Length; + harness.SetContents(brush, type); + Assert.That(harness.Update(), Is.True, $"seed {seed} step {step}: changing {brush.name} to type {type} updated nothing"); + ContentsComparison.AssertMatchesOracle(harness, $"seed {seed} step {step}, {brush.name} now type {type}"); + } + } + } + + // An index past the end of the list builds as Solid, and growing the list to include it rebuilds the + // tree without any brush being touched. Renaming an entry rebuilds nothing. + [Test] + public void TheListsLength_DecidesWhatAnIndexBuildsAs() + { + list.Install(ChiselContentsList.kSolidName, "Glass", "Grate"); + var post = Box("post", new float3(-1, -1, 0), new float3(1, 1, 4)); + var pane = Box("pane", new float3(-0.5f, 1, 0), new float3(0.5f, 5, 4), kWater); + using (var harness = ContentsTreeHarness.Build(Scene(post, pane))) + { + harness.JudgeEachUpdate = false; // the pane builds as Solid while the description still says water + Assert.That(harness.Update(), Is.True); + pane.contents = kSolid; // the oracle's view only: this is what the pane builds as + ContentsComparison.AssertMatchesOracle(harness, "water past the end of the list"); + pane.contents = kWater; + + list.Install(kDefaultNames); + Assert.That(harness.Update(), Is.True, "growing the list rebuilt nothing"); + ContentsComparison.AssertMatchesOracle(harness, "water once the list has it"); + + list.Install(ChiselContentsList.kSolidName, "Glass", "Grate", "Liquid"); + Assert.That(harness.Update(), Is.False, "renaming an entry rebuilt something"); + } + } + #endregion + + #region Control + // With the feature switched off every brush builds as Solid, and the suite has to notice. + [Test] + public void WithContentsSwitchedOff_TheOracleDisagrees() + { + var original = CompactHierarchyManager.ContentsEnabled; + try + { + CompactHierarchyManager.ContentsEnabled = false; + var scenes = new List<(string, ContentsScene)> + { + ("post and glass pane", Scene(Box("post", new float3(-1, -1, 0), new float3(1, 1, 4)), + Box("pane", new float3(-0.5f, 1, 0), new float3(0.5f, 5, 4), kGlass))), + ("solid inside water", Scene(Box("outer", new float3(0), new float3(4), kWater), + Box("inner", new float3(1), new float3(2)))), + }; + foreach (var (what, scene) in scenes) + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + harness.JudgeEachUpdate = false; // the disagreement is what this control is after + Assert.That(harness.Update(), Is.True); + var mismatches = ContentsComparison.FindMismatches(harness, what, out var samples, out _); + Assert.That(samples, Is.GreaterThan(0), what); + Assert.That(mismatches, Is.Not.Empty, what + ": the oracle agrees with an output built without contents"); + } + } + } + finally + { + CompactHierarchyManager.ContentsEnabled = original; + } + } + #endregion + } +} diff --git a/Core/Tests/Contents/ContentsSceneTests.cs.meta b/Core/Tests/Contents/ContentsSceneTests.cs.meta new file mode 100644 index 00000000..b13c433e --- /dev/null +++ b/Core/Tests/Contents/ContentsSceneTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 891d9a552d321c24ca35c24d568e5852 \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsTrace.cs b/Core/Tests/Contents/ContentsTrace.cs new file mode 100644 index 00000000..911b8e79 --- /dev/null +++ b/Core/Tests/Contents/ContentsTrace.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + public static class ContentsTrace + { + // Library/ChiselHarvest/traces/.txt, so an editor snippet can ask for a trace without file access of its own. + public static string RunToFile(ContentsScene scene, string what, string name) + { + var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "..", "Library", "ChiselHarvest", "traces")); + System.IO.Directory.CreateDirectory(directory); + var path = System.IO.Path.Combine(directory, name + ".txt"); + var text = Run(scene, what); + System.IO.File.WriteAllText(path, text); + return $"{text.Length} chars -> {path}"; + } + + static bool s_LiveBurst, s_LiveJobCompiler; + + public static string BeginLive() + { + s_LiveBurst = Unity.Burst.BurstCompiler.Options.EnableBurstCompilation; + s_LiveJobCompiler = Unity.Jobs.LowLevel.Unsafe.JobsUtility.JobCompilerEnabled; + Unity.Burst.BurstCompiler.Options.EnableBurstCompilation = false; + Unity.Jobs.LowLevel.Unsafe.JobsUtility.JobCompilerEnabled = false; + CSGTrace.Sink = new StringBuilder(); + return "tracing"; + } + + public static string EndLive(string name) + { + var sink = CSGTrace.Sink; + CSGTrace.Sink = null; + Unity.Jobs.LowLevel.Unsafe.JobsUtility.JobCompilerEnabled = s_LiveJobCompiler; + Unity.Burst.BurstCompiler.Options.EnableBurstCompilation = s_LiveBurst; + if (sink == null) + return "no trace was running"; + var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "..", "Library", "ChiselHarvest", "traces")); + System.IO.Directory.CreateDirectory(directory); + var path = System.IO.Path.Combine(directory, name + ".txt"); + System.IO.File.WriteAllText(path, sink.ToString()); + return $"{sink.Length} chars -> {path}"; + } + + public static string Run(ContentsScene scene, string what) + { + var text = new StringBuilder(); + var brushes = new List(ContentsHarvest.AllBrushes(scene)); + text.AppendLine($"# {what}: {brushes.Count} brush(es) - " + ContentsComparison.DescribeScene(scene)); + for (int b = 0; b < brushes.Count; b++) + text.AppendLine($"# b{b} = {brushes[b].name} {brushes[b].operation}"); + + var burst = Unity.Burst.BurstCompiler.Options.EnableBurstCompilation; + var jobCompiler = Unity.Jobs.LowLevel.Unsafe.JobsUtility.JobCompilerEnabled; + var sink = new StringBuilder(); + try + { + Unity.Burst.BurstCompiler.Options.EnableBurstCompilation = false; + Unity.Jobs.LowLevel.Unsafe.JobsUtility.JobCompilerEnabled = false; + CSGTrace.Sink = sink; + using (var harness = ContentsTreeHarness.Build(scene)) + { + var updated = harness.Update(); + CSGTrace.Sink = null; + text.Append(sink); + text.AppendLine($"# update: {harness.LastUpdateReport}"); + if (!updated) + return text.ToString(); + + var drawn = new Dictionary(); + foreach (var triangle in harness.Triangles) + { + var node = harness.NodeOf(triangle.brushID); + if (node == null) continue; + drawn.TryGetValue(node, out var sum); + drawn[node] = (sum.count + 1, sum.area + (0.5 * math.length(math.cross((double3)triangle.b - triangle.a, (double3)triangle.c - triangle.a)))); + } + for (int b = 0; b < brushes.Count; b++) + { + drawn.TryGetValue(brushes[b], out var sum); + text.AppendLine($"# drawn b{b}: {sum.count} triangle(s), area {sum.area.ToString("0.####", CultureInfo.InvariantCulture)}"); + } + + var mismatches = ContentsComparison.FindMismatchRecords(harness, out var checkedSamples, out _, out _, + ContentsHarvest.RequiredExtent(scene), int.MaxValue); + var byFace = new Dictionary(); + var first = new Dictionary(); + foreach (var mismatch in mismatches) + { + var key = $"b{brushes.IndexOf(mismatch.brush)} face {mismatch.face}: {ContentsComparison.KindOf(mismatch.problem)}"; + byFace.TryGetValue(key, out var count); + byFace[key] = count + 1; + if (!first.ContainsKey(key)) + first[key] = mismatch.Format(what); + } + text.AppendLine($"# oracle: {mismatches.Count} mismatch(es) over {checkedSamples} samples"); + foreach (var pair in byFace) + text.AppendLine($"# {pair.Key} x{pair.Value} e.g. {first[pair.Key]}"); + } + } + finally + { + CSGTrace.Sink = null; + Unity.Jobs.LowLevel.Unsafe.JobsUtility.JobCompilerEnabled = jobCompiler; + Unity.Burst.BurstCompiler.Options.EnableBurstCompilation = burst; + } + return text.ToString(); + } + } +} diff --git a/Core/Tests/Contents/ContentsTrace.cs.meta b/Core/Tests/Contents/ContentsTrace.cs.meta new file mode 100644 index 00000000..991757ad --- /dev/null +++ b/Core/Tests/Contents/ContentsTrace.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca1801663a2a0b64d89473a6210a193b \ No newline at end of file diff --git a/Core/Tests/Contents/ContentsTreeHarness.cs b/Core/Tests/Contents/ContentsTreeHarness.cs new file mode 100644 index 00000000..72d9e9aa --- /dev/null +++ b/Core/Tests/Contents/ContentsTreeHarness.cs @@ -0,0 +1,758 @@ +using System; +using System.Collections.Generic; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + public sealed class ContentsTreeHarness : IDisposable + { + public struct CapturedTriangle + { + public float3 a, b, c; + public CompactNodeID brushID; + // The normal the pipeline gave the surface. Unlike Normal it is exact on a sliver a fraction + // of a millimetre wide, where the cross product of the edges is mostly rounding. + public float3 surfaceNormal; + // The material the triangle is drawn with (its sub-mesh's surface parameter), and its texture + // coordinates. Decals draw with their own material (Documentation~/Design/Decals.md). + public ulong material; + public float2 uvA, uvB, uvC; + // Its lightmap coordinates (UV1), and the renderable section (mesh) it came from: each has a lightmap of its own + public float2 lightmapA, lightmapB, lightmapC; + // Each vertex's normal and tangent as the mesh holds them, not made safe: a NaN here lights the surface NaN + public float3 normalA, normalB, normalC; + public float4 tangentA, tangentB, tangentC; + public int section; + // The entity a click on the triangle selects: its brush's, or its decal's + public ulong selectedEntityID; + + public readonly float3 Center => (a + b + c) / 3.0f; + // Geometric normal from the winding, which is what tells a face drawn outwards from the + // wall a carve leaves behind. + public readonly float3 Normal => math.normalizesafe(math.cross(b - a, c - a)); + } + + readonly List instances = new List(); + readonly List nodes = new List(); + readonly List brushes = new List(); + readonly List triangles = new List(); + // Every node this harness created, brushes and composites, attached or not, so edits can find + // them and Dispose can destroy all of them. + readonly Dictionary treeNodes = new Dictionary(); + + ContentsScene scene; + CSGTree tree; + bool capturing; + + public IReadOnlyList Triangles => triangles; + // Where the weld's last step moved the corners it moved at the last update (from, to; OutputModelWeld), what the + // exact judge checks and accounts for; settable to plant an error in a test + public List CollapseMoves { get; internal set; } = new List(); + // The total area of the collider triangles the last delivered update made + public double ColliderArea { get; private set; } + public int ColliderVertexCount { get; private set; } + public int ColliderPositionCount { get; private set; } + public int DegenerateTriangleCount { get; private set; } + public int ColliderDegenerateTriangleCount { get; private set; } + public string LookupMismatches { get; private set; } + public CSGTree Tree => tree; + public ContentsScene Scene => scene; + + // Whether the last update delivered meshes for this tree. When it didn't, Triangles still holds + // the previous output, which is exactly what the editor would keep showing. + public bool Delivered { get; private set; } + + public string LastUpdateReport { get; private set; } = "(no update yet)"; + int callbacksForThisTree, callbacksForOtherTrees, renderableSections, sectionsWithLookup; + + public static ContentsTreeHarness Build(ContentsScene scene) + { + PrepareForUpdate(); + + var harness = new ContentsTreeHarness { scene = scene }; + var children = new CSGTreeNode[scene.roots.Count]; + for (int i = 0; i < scene.roots.Count; i++) + children[i] = harness.CreateNode(scene.roots[i]); + harness.tree = CSGTree.Create(default(UnityEngine.EntityId), children); + + // The exact CSG's planes and triangles, for ExactJudge; only captured while the exact CSG runs. Held until + // Dispose, so it covers every update this harness makes. + ExactCSGCapture.Acquire(); + harness.capturing = true; + return harness; + } + + CSGTreeNode CreateNode(ContentsSceneNode node) + { + if (node.IsBrush) + { + var instance = node.localVertices != null ? CreateBrushMesh(node) : CreateBrushMesh(node.localPlanes); + instances.Add(instance); + + var brush = CSGTreeBrush.Create(UnityEngine.EntityId.FromULong(node.entityID), node.localToTree, instance, node.operation); + brush.Contents = node.contents; + nodes.Add(node); + brushes.Add(brush); + treeNodes[node] = brush; + return brush; + } + + var children = new CSGTreeNode[node.children.Count]; + for (int i = 0; i < node.children.Count; i++) + children[i] = CreateNode(node.children[i]); + var branch = CSGTreeBranch.Create(default(UnityEngine.EntityId), node.operation, children); + treeNodes[node] = branch; + return branch; + } + + static BrushMeshInstance CreateBrushMesh(float4[] planes) + { + var surfaceArray = new ChiselSurfaceArray(); + surfaceArray.EnsureSize(planes.Length); + for (int i = 0; i < surfaceArray.surfaces.Length; i++) + surfaceArray.surfaces[i] = new ChiselSurface(); + + if (!ExactBrushOutline.FromPlanes(planes, out var brushMesh, out _)) + return BrushMeshInstance.InvalidInstance; + return BrushMeshInstance.Create(brushMesh, in surfaceArray); + } + + BrushMeshInstance CreateBrushMesh(ContentsSceneNode node) + { + var vertices = node.localVertices; + var loops = node.polygonLoops; + var polygons = new List(); + var halfEdges = new List(); + int start = 0; + for (int i = 0; i < loops.Length; i++) + { + if (loops[i] >= 0) + continue; + polygons.Add(new BrushMesh.Polygon { firstEdge = halfEdges.Count, edgeCount = i - start, descriptionIndex = polygons.Count }); + for (int k = start; k < i; k++) + halfEdges.Add(new BrushMesh.HalfEdge { vertexIndex = loops[k], twinIndex = -1 }); + start = i + 1; + } + + // A half edge joins its own vertex to the one before it in its polygon; its twin joins the same two the other way + var edges = halfEdges.ToArray(); + var byEnds = new Dictionary<(int, int), int>(); + foreach (var polygon in polygons) + { + for (int e = polygon.firstEdge; e < polygon.firstEdge + polygon.edgeCount; e++) + { + int previous = e == polygon.firstEdge ? polygon.firstEdge + polygon.edgeCount - 1 : e - 1; + byEnds[(edges[e].vertexIndex, edges[previous].vertexIndex)] = e; + } + } + foreach (var polygon in polygons) + { + for (int e = polygon.firstEdge; e < polygon.firstEdge + polygon.edgeCount; e++) + { + int previous = e == polygon.firstEdge ? polygon.firstEdge + polygon.edgeCount - 1 : e - 1; + if (!byEnds.TryGetValue((edges[previous].vertexIndex, edges[e].vertexIndex), out var twin)) + throw new InvalidOperationException($"{node.name}: the edge {edges[previous].vertexIndex}-{edges[e].vertexIndex} of its mesh has no twin"); + edges[e].twinIndex = twin; + } + } + + var surfaceArray = new ChiselSurfaceArray(); + surfaceArray.EnsureSize(polygons.Count); + for (int i = 0; i < surfaceArray.surfaces.Length; i++) + surfaceArray.surfaces[i] = new ChiselSurface(); + var brushMesh = new BrushMesh { vertices = (float3[])vertices.Clone(), halfEdges = edges, polygons = polygons.ToArray(), + planes = node.localPlanes != null && node.localPlanes.Length == polygons.Count ? (float4[])node.localPlanes.Clone() : null }; + var instance = BrushMeshInstance.Create(brushMesh, in surfaceArray); + + var blob = BrushMeshManager.GetBrushMeshBlob(instance); + if (!blob.IsCreated) + planeMismatches.Add($"{node.name}: its mesh registered as nothing"); + else + { + ref var mesh = ref blob.Value; + if (mesh.localPlaneCount != node.localPlanes.Length) + planeMismatches.Add($"{node.name}: its mesh has {mesh.localPlaneCount} planes, the description {node.localPlanes.Length}"); + else + { + for (int p = 0; p < mesh.localPlaneCount; p++) + { + if (math.all(math.asint(mesh.localPlanes[p]) == math.asint(node.localPlanes[p]))) + continue; + planeMismatches.Add($"{node.name}: plane {p} registered as {mesh.localPlanes[p]}, the description has {node.localPlanes[p]}"); + break; + } + } + } + return instance; + } + + readonly List planeMismatches = new List(); + + // Brushes built from their own mesh whose registered planes are not the planes their description gives: a replay + // that is not the scene it replays. Empty when every brush came back exactly. + public IReadOnlyList PlaneMismatches => planeMismatches; + + #region Edits + + // Adds a new node under parent (null for the top level) at index. + public void Insert(ContentsSceneNode parent, int index, ContentsSceneNode node) + { + var created = CreateNode(node); + if (!InsertIntoTree(parent, index, created)) + throw new InvalidOperationException($"Could not insert {NameOf(node)} at {index} under {NameOf(parent)}"); + ChildrenOf(parent).Insert(index, node); + } + + // Takes a node out of the tree. Destroying it is what deleting its GameObject does; detaching + // keeps it alive, so Move can put it somewhere else. + public void Remove(ContentsSceneNode node, bool destroy) + { + var parent = ParentOf(node); + if (destroy) + Destroy(node); + else if (!RemoveFromTree(parent, treeNodes[node])) + throw new InvalidOperationException($"Could not detach {NameOf(node)} from {NameOf(parent)}"); + ChildrenOf(parent).Remove(node); + } + + public void Move(ContentsSceneNode node, ContentsSceneNode newParent, int index) + { + Remove(node, destroy: false); + if (!InsertIntoTree(newParent, index, treeNodes[node])) + throw new InvalidOperationException($"Could not move {NameOf(node)} to {index} under {NameOf(newParent)}"); + ChildrenOf(newParent).Insert(index, node); + } + + public void SetOperation(ContentsSceneNode node, CSGOperationType operation) + { + var treeNode = treeNodes[node]; + treeNode.Operation = operation; + node.operation = operation; + } + + // Moves a brush: its transform into the tree, as a user dragging it does. + public void SetLocalToTree(ContentsSceneNode brush, float4x4 localToTree) + { + if (!brush.IsBrush) + throw new ArgumentException($"{NameOf(brush)} is a composite; only brushes carry a transform here"); + var treeBrush = (CSGTreeBrush)treeNodes[brush]; + treeBrush.LocalTransformation = localToTree; + brush.localToTree = localToTree; + } + + public void SetContents(ContentsSceneNode brush, int contents) + { + if (!brush.IsBrush) + throw new ArgumentException($"{NameOf(brush)} is a composite, and composites don't carry contents"); + var treeBrush = (CSGTreeBrush)treeNodes[brush]; + treeBrush.Contents = contents; + brush.contents = contents; + } + + bool InsertIntoTree(ContentsSceneNode parent, int index, CSGTreeNode child) + { + return parent == null ? tree.Insert(index, child) + : ((CSGTreeBranch)treeNodes[parent]).Insert(index, child); + } + + bool RemoveFromTree(ContentsSceneNode parent, CSGTreeNode child) + { + return parent == null ? tree.Remove(child) + : ((CSGTreeBranch)treeNodes[parent]).Remove(child); + } + + void Destroy(ContentsSceneNode node) + { + if (!node.IsBrush) + { + foreach (var child in node.children) + Destroy(child); + } + + DestroyTreeNode(node, treeNodes[node]); + treeNodes.Remove(node); + if (node.IsBrush) + { + var index = nodes.IndexOf(node); + nodes.RemoveAt(index); + brushes.RemoveAt(index); + } + } + + static void DestroyTreeNode(ContentsSceneNode node, CSGTreeNode treeNode) + { + // Destroying an invalid node logs an error, which fails the test that is cleaning up + if (!treeNode.Valid) + return; + if (node.IsBrush) + ((CSGTreeBrush)treeNode).Destroy(); + else + ((CSGTreeBranch)treeNode).Destroy(); + } + + List ChildrenOf(ContentsSceneNode parent) => parent == null ? scene.roots : parent.children; + + ContentsSceneNode ParentOf(ContentsSceneNode node) + { + if (scene.roots.Contains(node)) + return null; + return FindParent(scene.roots, node) ?? throw new ArgumentException($"{NameOf(node)} is not in the scene"); + } + + static ContentsSceneNode FindParent(List candidates, ContentsSceneNode node) + { + foreach (var candidate in candidates) + { + if (candidate.IsBrush) + continue; + if (candidate.children.Contains(node)) + return candidate; + var found = FindParent(candidate.children, node); + if (found != null) + return found; + } + return null; + } + + static string NameOf(ContentsSceneNode node) => node == null ? "the tree" : (node.name ?? (node.IsBrush ? "a brush" : "a composite")); + #endregion + + // Runs the CSG update and fills Triangles. Returns false when nothing was dirty. + public bool Update() + { + var foreign = CountForeignDirtyTrees(tree); + if (foreign > 0 && AnyChiselModelInScene()) + { + throw new InvalidOperationException( + $"{foreign} other Chisel tree(s) need an update ({ForeignDirtyTreeOwners(tree)}) and a Chisel model is in the scene. " + + "Run this suite in a scene with no model, opened AFTER the last recompile."); + } + + Delivered = false; + callbacksForThisTree = callbacksForOtherTrees = renderableSections = sectionsWithLookup = 0; + var updated = CompactHierarchyManager.Flush(FinishMeshUpdates); + if (tree.Valid && ChiselTreeLookup.Value.HasTree(tree)) + { + var lookup = ChiselTreeLookup.Value[tree]; + lookup.lastJobHandle.Complete(); + CollapseMoves = new List(); + if (lookup.collapseMoves.IsCreated) + { + foreach (var move in lookup.collapseMoves) + CollapseMoves.Add(move); + } + } + LastUpdateReport = $"updated={updated}, rounds={CompactHierarchyManager.LastUpdateRounds}, " + + $"brushes flagged={CompactHierarchyManager.LastUpdateModifiedBrushCount}; " + + $"callbacks: mine={callbacksForThisTree}, other trees={callbacksForOtherTrees}; " + + $"renderable sections={renderableSections}, of which with a lookup={sectionsWithLookup}" + + (Delivered ? "" : "; nothing delivered, the previous triangles are kept"); + + // The exact CSG reports what it could not build instead of repairing it; under the harness any such report is + // an error of the CSG, never something to average away in a comparison. + if (CompactHierarchyManager.TreeUpdate.kExactCSG) + { + var failures = ExactCSGFailures(); + LastUpdateReport += "; exact CSG: " + (failures ?? "no failures"); + if (failures != null) + throw new InvalidOperationException("The exact CSG reported failures: " + failures + " (" + LastUpdateReport + ")"); + + if (updated && JudgeEachUpdate) + { + var mismatches = ExactJudge.FindMismatches(this, kMaxJudgeReports, out _, checkMesh: false); + if (mismatches.Count > 0) + { + var report = new System.Text.StringBuilder("The exact judge disagrees with this update's output:"); + foreach (var mismatch in mismatches) + report.Append(Environment.NewLine).Append(" ").Append(mismatch.Format("exact")); + report.Append(Environment.NewLine).Append(" (").Append(LastUpdateReport).Append(')'); + throw new InvalidOperationException(report.ToString()); + } + } + } + + // And every delivered update is checked for what the weld at the very end leaves (OutputWeld, ExactCSG.md step 6), + // whatever else the test checks, in both pipelines + if (updated && Delivered && CheckWeldEachUpdate) + { + var weld = WeldProblems(); + if (weld != null) + throw new InvalidOperationException("The weld at the very end left: " + weld + " (" + LastUpdateReport + ")"); + } + return updated; + } + + // Whether Update checks the delivered meshes for what the weld leaves. Off only in a test that reads the counts itself. + internal bool CheckWeldEachUpdate { get; set; } = true; + + internal string WeldProblems() + { + var text = new System.Text.StringBuilder(); + if (DegenerateTriangleCount > 0) + text.Append(DegenerateTriangleCount).Append(" render triangle(s) with two corners at one position; "); + if (ColliderDegenerateTriangleCount > 0) + text.Append(ColliderDegenerateTriangleCount).Append(" collider triangle(s) with two corners at one position; "); + if (ColliderVertexCount != ColliderPositionCount) + text.Append("the collider has ").Append(ColliderVertexCount).Append(" vertices at ").Append(ColliderPositionCount).Append(" positions; "); + if (LookupMismatches != null) + text.Append("picking lookups out of step: ").Append(LookupMismatches).Append("; "); + return text.Length == 0 ? null : text.ToString(0, text.Length - 2); + } + + const int kMaxJudgeReports = 8; + + internal bool JudgeEachUpdate { get; set; } = true; + + // The failure counters of the last update, or null when there were none. + internal static string ExactCSGFailures() + { + var stats = CompactHierarchyManager.TreeUpdate.s_LastExactCSGStats; + System.Text.StringBuilder text = null; + for (int i = (int)ExactCSGStat.InvalidPlane; i < (int)ExactCSGStat.Count; i++) + { + if (stats[i] == 0) + continue; + text ??= new System.Text.StringBuilder(); + if (text.Length > 0) text.Append(", "); + text.Append((ExactCSGStat)i).Append('=').Append(stats[i]); + } + return text?.ToString(); + } + + internal static bool TwoCornersAtOnePosition(float3 a, float3 b, float3 c) + { + return (a.x == b.x && a.y == b.y && a.z == b.z) || (b.x == c.x && b.y == c.y && b.z == c.z) || (c.x == a.x && c.y == a.y && c.z == a.z); + } + + // The triangles of each render mesh (its query, material and chunk) as last delivered: a mesh the update kept is not + // delivered again + Dictionary<(ulong, ulong, uint), List> meshTriangles = new(); + // The same for each collider (its physics material): its area and counts + Dictionary<(ulong, uint), (double area, int vertices, int positions, int degenerate)> colliderStats = new(); + + int FinishMeshUpdates(CSGTree updatedTree, ChiselMeshUpdates meshUpdates, JobHandle dependencies) + { + dependencies.Complete(); + try + { + if (updatedTree != tree) + { + callbacksForOtherTrees++; + return 0; + } + callbacksForThisTree++; + Delivered = true; + triangles.Clear(); + + ColliderArea = 0; + ColliderVertexCount = ColliderPositionCount = DegenerateTriangleCount = ColliderDegenerateTriangleCount = 0; + LookupMismatches = null; + var deliveredColliders = new Dictionary<(ulong, uint), (double area, int vertices, int positions, int degenerate)>(); + foreach (var meshUpdate in meshUpdates.meshUpdatesColliders) + { + var areaBefore = ColliderArea; var verticesBefore = ColliderVertexCount; var positionsBefore = ColliderPositionCount; var degenerateBefore = ColliderDegenerateTriangleCount; + var colliderData = meshUpdates.meshDataArray[meshUpdate.meshIndex]; + var colliderIndices = colliderData.GetIndexData(); + var colliderVertices = colliderData.GetVertexData(0); + ColliderVertexCount += colliderVertices.Length; + var positions = new HashSet(); + for (int v = 0; v < colliderVertices.Length; v++) + positions.Add(colliderVertices[v] + 0.0f); + ColliderPositionCount += positions.Count; + // only the declared triangles: a sub-mesh may write fewer indices than the buffer holds + var declaredIndices = colliderData.subMeshCount > 0 ? colliderData.GetSubMesh(0).indexCount : 0; + for (int i = 0; i + 2 < declaredIndices; i += 3) + { + var a = colliderVertices[colliderIndices[i]]; + var b = colliderVertices[colliderIndices[i + 1]]; + var c = colliderVertices[colliderIndices[i + 2]]; + ColliderArea += math.length(math.cross((double3)b - a, (double3)c - a)) * 0.5; + if (TwoCornersAtOnePosition(a, b, c)) + ColliderDegenerateTriangleCount++; + } + deliveredColliders[(meshUpdate.objectIndex, meshUpdate.chunk)] = (ColliderArea - areaBefore, ColliderVertexCount - verticesBefore, + ColliderPositionCount - positionsBefore, ColliderDegenerateTriangleCount - degenerateBefore); + } + if (meshUpdates.meshUpdatesKeptColliders.IsCreated) + { + foreach (var kept in meshUpdates.meshUpdatesKeptColliders) + { + if (!colliderStats.TryGetValue((kept.objectIndex, kept.chunk), out var stats)) + { + LookupMismatches = (LookupMismatches == null ? "" : LookupMismatches + "; ") + $"a kept collider (material {kept.objectIndex}) that was never delivered"; + continue; + } + deliveredColliders[(kept.objectIndex, kept.chunk)] = stats; + ColliderArea += stats.area; ColliderVertexCount += stats.vertices; ColliderPositionCount += stats.positions; ColliderDegenerateTriangleCount += stats.degenerate; + } + } + colliderStats = deliveredColliders; + + var delivered = new Dictionary<(ulong, ulong, uint), List>(); + foreach (var meshUpdate in meshUpdates.meshUpdatesRenderables) + { + var meshTriangleList = new List(); + delivered[(meshUpdate.objectIndex, meshUpdate.surfaceParameter, meshUpdate.chunk)] = meshTriangleList; + renderableSections++; + var lookupRef = meshUpdates.vertexBufferContents.subMeshTriangleLookups[meshUpdate.contentsIndex]; + if (!lookupRef.IsCreated) + continue; + sectionsWithLookup++; + ref var lookup = ref lookupRef.Value; + + var meshData = meshUpdates.meshDataArray[meshUpdate.meshIndex]; + var indices = meshData.GetIndexData(); + var vertices = meshData.GetVertexData(0); + + // The weld drops triangles, so the picking lookup has to cover exactly the triangles the sub-meshes declare, + // and none of those may have two corners at one position + var declaredTriangles = 0; + for (int s = 0; s < meshData.subMeshCount; s++) + { + var subMesh = meshData.GetSubMesh(s); + declaredTriangles += subMesh.indexCount / 3; + for (int i = subMesh.indexStart; i + 2 < subMesh.indexStart + subMesh.indexCount; i += 3) + { + if (TwoCornersAtOnePosition(vertices[indices[i]].position, vertices[indices[i + 1]].position, vertices[indices[i + 2]].position)) + DegenerateTriangleCount++; + } + } + if (lookup.triangleCount != declaredTriangles) + LookupMismatches = (LookupMismatches == null ? "" : LookupMismatches + "; ") + + $"section {renderableSections}: lookup {lookup.triangleCount} triangles, sub-meshes {declaredTriangles}"; + + var count = math.min(lookup.triangleCount, indices.Length / 3); + // The sub-mesh (and so the material) of each triangle, counted the same way + var descriptions = meshUpdates.vertexBufferContents.meshDescriptions; + var section = meshUpdate.subMeshSection; + var description = section.startIndex; + var descriptionEnd = description < section.endIndex ? descriptions[description].indexCount / 3 : int.MaxValue; + for (int t = 0; t < count; t++) + { + while (t >= descriptionEnd && description + 1 < section.endIndex) + { + description++; + descriptionEnd += descriptions[description].indexCount / 3; + } + var i0 = indices[(t * 3) + 0]; + var i1 = indices[(t * 3) + 1]; + var i2 = indices[(t * 3) + 2]; + meshTriangleList.Add(new CapturedTriangle + { + a = vertices[i0].position, + b = vertices[i1].position, + c = vertices[i2].position, + brushID = lookup.RunAt(t).nodeID, + surfaceNormal = math.normalizesafe(vertices[i0].normal), + material = description < section.endIndex ? descriptions[description].surfaceParameter : 0, + uvA = vertices[i0].uv0, + uvB = vertices[i1].uv0, + uvC = vertices[i2].uv0, + lightmapA = vertices[i0].uv1, + lightmapB = vertices[i1].uv1, + lightmapC = vertices[i2].uv1, + normalA = vertices[i0].normal, + normalB = vertices[i1].normal, + normalC = vertices[i2].normal, + tangentA = vertices[i0].tangent, + tangentB = vertices[i1].tangent, + tangentC = vertices[i2].tangent, + section = renderableSections, + selectedEntityID = lookup.selectionIndexDescriptions[lookup.RunAt(t).selectionID].entityID + }); + } + } + if (meshUpdates.meshUpdatesKept.IsCreated) + { + foreach (var kept in meshUpdates.meshUpdatesKept) + { + var key = (kept.objectIndex, kept.surfaceParameter, kept.chunk); + if (meshTriangles.TryGetValue(key, out var keptTriangles)) + delivered[key] = keptTriangles; + else + LookupMismatches = (LookupMismatches == null ? "" : LookupMismatches + "; ") + + $"a kept mesh (query {kept.objectIndex}, material {kept.surfaceParameter}) that was never delivered"; + } + } + foreach (var list in delivered.Values) + triangles.AddRange(list); + meshTriangles = delivered; + } + finally + { + // Ownership of the mesh data transfers to this callback, including for trees that are + // not ours. + if (meshUpdates.meshDataArray.Length > 0) + meshUpdates.meshDataArray.Dispose(); + } + return 0; + } + + // Which described brush produced this triangle, or null when the triangle came from a tree or + // a brush this harness did not build. + public ContentsSceneNode NodeOf(CompactNodeID brushID) + { + for (int i = 0; i < brushes.Count; i++) + { + if (brushes[i].Valid && brushes[i].CompactNodeID == brushID) + return nodes[i]; + } + return null; + } + + // Plant errors in the captured output, for tests that show a check notices one (ExactJudgeTests). + internal void RemoveTriangleForTest(int index) => triangles.RemoveAt(index); + internal void ReplaceTriangleForTest(int index, CapturedTriangle triangle) => triangles[index] = triangle; + + // The stable id of the tree brush built for this described brush (what ExactCSGCapture is keyed by), or + // NodeID.Invalid when the brush is not in this harness. + internal NodeID NodeIDOf(ContentsSceneNode brush) + { + var index = nodes.IndexOf(brush); + return index < 0 ? NodeID.Invalid : brushes[index].NodeID; + } + + // The tree brush built for this described brush; invalid when the brush is not in this harness. + internal CSGTreeBrush TreeBrushOf(ContentsSceneNode brush) + { + var index = nodes.IndexOf(brush); + return index < 0 ? default : brushes[index]; + } + + // How many of the captured triangles this brush drew. + public int TriangleCountOf(ContentsSceneNode brush) + { + var count = 0; + foreach (var triangle in triangles) + { + if (NodeOf(triangle.brushID) == brush) + count++; + } + return count; + } + + // Leave the editor with no pending CSG work, so that the flush this harness runs later sees only + // its own tree. Refuses when a model is in the scene, because draining would take its meshes. + public static void PrepareForUpdate() + { + if (CountForeignDirtyTrees(default) == 0) + return; + if (AnyChiselModelInScene()) + { + throw new InvalidOperationException( + $"A Chisel model in the open scene is waiting for an update ({ForeignDirtyTreeOwners(default)}). Run this suite in a scene " + + "with no model, opened AFTER the last recompile - a domain reload restores the previous " + + "scene, so an empty scene opened before a compile does not survive it."); + } + DrainForeignUpdates(); + } + + // Is a Chisel model open in the scene? Found by type name, so this test assembly does not have + // to depend on Chisel.Components. + public static bool AnyChiselModelInScene() + { + var behaviours = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include); + foreach (var behaviour in behaviours) + { + if (behaviour == null) + continue; + for (var type = behaviour.GetType(); type != null; type = type.BaseType) + { + if (type.Name == "ChiselModelComponent") + return true; + } + } + return false; + } + + public static void DrainForeignUpdates() + { + CompactHierarchyManager.Flush((tree, meshUpdates, dependencies) => + { + dependencies.Complete(); + if (meshUpdates.meshDataArray.Length > 0) + meshUpdates.meshDataArray.Dispose(); + return 0; + }); + } + + static string ForeignDirtyTreeOwners(CSGTree mine) + { + var all = new NativeList(Allocator.Temp); + try + { + CompactHierarchyManager.GetAllTrees(all); + var names = new System.Text.StringBuilder(); + for (int i = 0; i < all.Length; i++) + { + var other = all[i]; + if (!other.Valid || other == mine || !other.IsStatusFlagSet(NodeStatusFlags.TreeNeedsUpdate)) + continue; + var owner = UnityEngine.Resources.EntityIdToObject(other.EntityId); + if (names.Length > 0) + names.Append(", "); + names.Append(owner != null ? $"'{owner.name}'" : "a tree without an owner").Append($" with {other.Count} children"); + } + return names.ToString(); + } + finally + { + all.Dispose(); + } + } + + public static int CountForeignDirtyTrees(CSGTree mine) + { + var all = new NativeList(Allocator.Temp); + try + { + CompactHierarchyManager.GetAllTrees(all); + var count = 0; + for (int i = 0; i < all.Length; i++) + { + var other = all[i]; + if (!other.Valid || other == mine) + continue; + if (other.IsStatusFlagSet(NodeStatusFlags.TreeNeedsUpdate)) + count++; + } + return count; + } + finally + { + all.Dispose(); + } + } + + public void Dispose() + { + foreach (var pair in treeNodes) + { + if (pair.Key.IsBrush) + DestroyTreeNode(pair.Key, pair.Value); + } + foreach (var pair in treeNodes) + { + if (!pair.Key.IsBrush) + DestroyTreeNode(pair.Key, pair.Value); + } + treeNodes.Clear(); + if (tree.Valid) + tree.Destroy(); + foreach (var instance in instances) + instance.Destroy(); + instances.Clear(); + nodes.Clear(); + brushes.Clear(); + triangles.Clear(); + if (capturing) + { + capturing = false; + ExactCSGCapture.Release(); + } + } + } +} diff --git a/Core/Tests/Contents/ContentsTreeHarness.cs.meta b/Core/Tests/Contents/ContentsTreeHarness.cs.meta new file mode 100644 index 00000000..224ab439 --- /dev/null +++ b/Core/Tests/Contents/ContentsTreeHarness.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b79eb3ca79074c9479e0e0d1010dba99 \ No newline at end of file diff --git a/Core/Tests/Contents/DecalPipelineTests.cs b/Core/Tests/Contents/DecalPipelineTests.cs new file mode 100644 index 00000000..af76f577 --- /dev/null +++ b/Core/Tests/Contents/DecalPipelineTests.cs @@ -0,0 +1,626 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class DecalPipelineTests + { + const ulong kDecalMaterial = 0xDECA1; + const ulong kOtherDecalMaterial = 0xDECA2; + const double kAreaTolerance = 1e-4; + + // Projects down (local +Z is world -Y), with the image's up along world +Z + static readonly quaternion kDown = quaternion.LookRotation(new float3(0, -1, 0), new float3(0, 0, 1)); + + static ContentsSceneNode Box(string name, float3 min, float3 max) + { + return ContentsSceneNode.Brush(ContentsScene.BoxPlanes(min, max), name: name); + } + + static ChiselDecalInstance Decal(ulong id, float3 position, quaternion rotation, float3 size, bool transparent, + ulong material = kDecalMaterial, float maxAngle = 180, + ChiselDecalProjection projection = ChiselDecalProjection.Orthographic) + { + var settings = ChiselDecalSettings.Default; + settings.size = size; + settings.transparent = transparent; + settings.maxAngle = maxAngle; + settings.projection = projection; + return new ChiselDecalInstance + { + entityID = id, + decalToTree = float4x4.TRS(position, rotation, new float3(1)), + settings = settings, + renderMaterial = material, + destinationFlags = SurfaceDestinationFlags.Default + }; + } + + static void SetDecals(ContentsTreeHarness harness, params ChiselDecalInstance[] decals) + { + using var array = new NativeArray(decals, Allocator.Temp); + harness.Tree.SetDecals(array); + } + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene, params ChiselDecalInstance[] decals) + { + var harness = ContentsTreeHarness.Build(scene); + if (decals.Length > 0) + SetDecals(harness, decals); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(harness.Delivered, Is.True, "no meshes were delivered: " + harness.LastUpdateReport); + return harness; + } + + static double Area(in ContentsTreeHarness.CapturedTriangle triangle) + { + return math.length(math.cross((double3)triangle.b - triangle.a, (double3)triangle.c - triangle.a)) * 0.5; + } + + // The area of the triangles drawn with material that face along normal + static double AreaOf(ContentsTreeHarness harness, ulong material, float3 normal) + { + var area = 0.0; + foreach (var triangle in harness.Triangles) + { + if (triangle.material != material || math.dot(triangle.Normal, normal) < 0.99f) + continue; + area += Area(triangle); + } + return area; + } + + static double AreaOfMaterial(ContentsTreeHarness harness, ulong material) + { + var area = 0.0; + foreach (var triangle in harness.Triangles) + { + if (triangle.material == material) + area += Area(triangle); + } + return area; + } + + // Everything that isn't drawn with a decal material + static double SurfaceArea(ContentsTreeHarness harness, float3 normal) + { + var area = 0.0; + foreach (var triangle in harness.Triangles) + { + if (triangle.material == kDecalMaterial || triangle.material == kOtherDecalMaterial || + math.dot(triangle.Normal, normal) < 0.99f) + continue; + area += Area(triangle); + } + return area; + } + + // How many triangles facing up cover the point (x, height, z), by material + static Dictionary CoverageAt(ContentsTreeHarness harness, double x, double z, double height) + { + var result = new Dictionary(); + foreach (var triangle in harness.Triangles) + { + if (triangle.Normal.y < 0.99f) + continue; + if (math.abs(triangle.a.y - height) > 0.01f) + continue; + if (!Contains2D(triangle.a.xz, triangle.b.xz, triangle.c.xz, new double2(x, z))) + continue; + result.TryGetValue(triangle.material, out var count); + result[triangle.material] = count + 1; + } + return result; + } + + static bool Contains2D(double2 a, double2 b, double2 c, double2 p) + { + double Cross(double2 u, double2 v, double2 w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x); + var d0 = Cross(a, b, p); + var d1 = Cross(b, c, p); + var d2 = Cross(c, a, p); + return (d0 >= 0 && d1 >= 0 && d2 >= 0) || (d0 <= 0 && d1 <= 0 && d2 <= 0); + } + + static int CountOf(Dictionary coverage) + { + var total = 0; + foreach (var pair in coverage) + total += pair.Value; + return total; + } + + // Samples a grid over the top of a floor at height 0. Offsets are chosen to stay off triangle edges. + static IEnumerable FloorSamples(double min, double max) + { + const int kSteps = 23; + for (int i = 0; i < kSteps; i++) + { + for (int j = 0; j < kSteps; j++) + { + var x = min + (max - min) * (i + 0.37) / kSteps; + var z = min + (max - min) * (j + 0.61) / kSteps; + yield return new double2(x, z); + } + } + } + + static bool InsideSquare(double2 point, double2 center, double halfSize) + { + return math.all(math.abs(point - center) < halfSize); + } + + [Test] + public void TransparentDecal_IsDrawnOverTheFloor() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + using var harness = BuildAndUpdate(scene, Decal(1, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: true)); + + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance), "the decal covers its 1 by 1 footprint"); + Assert.That(AreaOfMaterial(harness, kDecalMaterial), Is.EqualTo(1.0).Within(kAreaTolerance), "and nothing else"); + Assert.That(SurfaceArea(harness, new float3(0, 1, 0)), Is.EqualTo(16.0).Within(kAreaTolerance), "the floor is all still there"); + + foreach (var triangle in harness.Triangles) + { + if (triangle.material != kDecalMaterial) + continue; + Assert.That(triangle.a.y, Is.EqualTo(ChiselDecalSettings.kDefaultSurfaceOffset).Within(1e-6), "lifted off the floor"); + foreach (var uv in new[] { triangle.uvA, triangle.uvB, triangle.uvC }) + Assert.That(math.all(uv >= -1e-5f) && math.all(uv <= 1 + 1e-5f), Is.True, $"uv {uv} is outside the image"); + } + + foreach (var point in FloorSamples(-2, 2)) + { + var coverage = CoverageAt(harness, point.x, point.y, 0); + var inDecal = InsideSquare(point, double2.zero, 0.5); + coverage.TryGetValue(kDecalMaterial, out var decal); + Assert.That(decal, Is.EqualTo(inDecal ? 1 : 0), $"decal at {point}"); + Assert.That(CountOf(coverage) - decal, Is.EqualTo(1), $"floor at {point}"); + } + } + + [Test] + public void OpaqueDecal_ReplacesTheFloorUnderIt() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + using var harness = BuildAndUpdate(scene); + var colliderArea = harness.ColliderArea; + var colliderVertices = harness.ColliderVertexCount; + + SetDecals(harness, Decal(1, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: false)); + Assert.That(harness.Update(), Is.True); + + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance)); + Assert.That(SurfaceArea(harness, new float3(0, 1, 0)), Is.EqualTo(15.0).Within(kAreaTolerance), "the floor under the decal is gone"); + Assert.That(harness.ColliderArea, Is.EqualTo(colliderArea).Within(kAreaTolerance), "the collider keeps the whole floor"); + Assert.That(harness.ColliderVertexCount, Is.EqualTo(colliderVertices), "and the decal doesn't cut it"); + + foreach (var triangle in harness.Triangles) + { + if (triangle.material == kDecalMaterial) + Assert.That(triangle.a.y, Is.EqualTo(0).Within(1e-6), "an opaque decal is not lifted"); + } + + foreach (var point in FloorSamples(-2, 2)) + { + var coverage = CoverageAt(harness, point.x, point.y, 0); + var inDecal = InsideSquare(point, double2.zero, 0.5); + coverage.TryGetValue(kDecalMaterial, out var decal); + Assert.That(CountOf(coverage), Is.EqualTo(1), $"exactly one triangle at {point}"); + Assert.That(decal, Is.EqualTo(inDecal ? 1 : 0), $"decal at {point}"); + } + } + + [Test] + public void Surface_FacingAwayFromTheDecal_IsLeftAlone() + { + // A thin wall. The decal box reaches through it, but its center is in front of the wall. + var scene = new ContentsScene().Add(Box("wall", new float3(-2, -2, -0.1f), new float3(2, 2, 0.1f))); + var towardsWall = quaternion.LookRotation(new float3(0, 0, -1), new float3(0, 1, 0)); + using var harness = BuildAndUpdate(scene, Decal(1, new float3(0, 0, 0.3f), towardsWall, new float3(1, 1, 1), transparent: false)); + + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 0, 1)), Is.EqualTo(1.0).Within(kAreaTolerance), "the front takes the decal"); + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 0, -1)), Is.EqualTo(0.0).Within(kAreaTolerance), "the back faces away from it"); + Assert.That(SurfaceArea(harness, new float3(0, 0, -1)), Is.EqualTo(16.0).Within(kAreaTolerance), "the back is whole"); + } + + [Test] + public void AngleLimit_KeepsTheDecalOffTheWall() + { + // A floor with a wall standing on it; the decal box covers the corner between them + var scene = new ContentsScene() + .Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))) + .Add(Box("wall", new float3(0.25f, 0, -2), new float3(1, 2, 2))); + using (var unlimited = BuildAndUpdate(scene, Decal(1, new float3(0, 0.25f, 0), kDown, new float3(1, 1, 1), transparent: true))) + { + Assert.That(AreaOf(unlimited, kDecalMaterial, new float3(-1, 0, 0)), Is.GreaterThan(0.1), + "without a limit the wall facing the center takes the decal"); + } + using (var limited = BuildAndUpdate(scene, Decal(1, new float3(0, 0.25f, 0), kDown, new float3(1, 1, 1), transparent: true, maxAngle: 60))) + { + Assert.That(AreaOf(limited, kDecalMaterial, new float3(-1, 0, 0)), Is.EqualTo(0.0).Within(kAreaTolerance)); + Assert.That(AreaOf(limited, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.75).Within(kAreaTolerance), + "the floor from the decal's edge to the wall"); + } + } + + [Test] + public void OpaqueDecal_OverTwoBrushes_LeavesNoCrackBetweenThem() + { + var a = Box("a", new float3(-2, -1, -2), new float3(0, 0, 2)); + var b = Box("b", new float3(0, -1, -2), new float3(2, 0, 2)); + var scene = new ContentsScene().Add(a).Add(b); + var rotated = math.mul(kDown, quaternion.RotateZ(0.4f)); + using var harness = BuildAndUpdate(scene, Decal(1, new float3(0.1f, 0, 0.2f), rotated, new float3(1.3f, 0.9f, 0.5f), transparent: false)); + + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.3 * 0.9).Within(kAreaTolerance)); + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)) + SurfaceArea(harness, new float3(0, 1, 0)), + Is.EqualTo(16.0).Within(kAreaTolerance)); + + // Both brushes put the same points on the line they share + var onSeamA = new HashSet(); + var onSeamB = new HashSet(); + foreach (var triangle in harness.Triangles) + { + if (triangle.Normal.y < 0.99f) + continue; + var node = harness.NodeOf(triangle.brushID); + foreach (var point in new[] { triangle.a, triangle.b, triangle.c }) + { + if (point.x != 0) + continue; + (node == a ? onSeamA : onSeamB).Add(point); + } + } + Assert.That(onSeamA.Count, Is.GreaterThan(2), "the decal should put points on the seam"); + Assert.That(onSeamB, Is.EquivalentTo(onSeamA)); + } + + [Test] + public void HigherOpaqueDecal_HidesTheLowerOne() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + using var harness = BuildAndUpdate(scene, + Decal(1, new float3(0, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false, material: kDecalMaterial), + Decal(2, new float3(0.5f, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false, material: kOtherDecalMaterial)); + + Assert.That(AreaOf(harness, kOtherDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance), "the higher decal is whole"); + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.5).Within(kAreaTolerance), "the lower one is half hidden"); + Assert.That(SurfaceArea(harness, new float3(0, 1, 0)), Is.EqualTo(14.5).Within(kAreaTolerance)); + foreach (var point in FloorSamples(-2, 2)) + Assert.That(CountOf(CoverageAt(harness, point.x, point.y, 0)), Is.EqualTo(1), $"exactly one triangle at {point}"); + } + + // Of one order, the decal given last is on top, whatever their ids + [Test] + public void OpaqueDecalsOfOneOrder_StackAsTheyAreGiven() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + var high = Decal(2, new float3(0, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false, material: kDecalMaterial); + var low = Decal(1, new float3(0.5f, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false, material: kOtherDecalMaterial); + using (var harness = BuildAndUpdate(scene, high, low)) + { + Assert.That(AreaOf(harness, kOtherDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance), "the one given last is whole"); + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.5).Within(kAreaTolerance)); + } + using (var harness = BuildAndUpdate(scene, low, high)) + { + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance), "given the other way round"); + Assert.That(AreaOf(harness, kOtherDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.5).Within(kAreaTolerance)); + } + } + + // Two decals stacked the other way round make what they cover again; one added elsewhere restacks nothing + [Test] + public void ReorderingDecals_RebuildsWhatTheyCover_AddingOneDoesNot() + { + var left = Box("left", new float3(-10, -1, -2), new float3(-6, 0, 2)); + var right = Box("right", new float3(6, -1, -2), new float3(10, 0, 2)); + var far = Box("far", new float3(-1, -1, 20), new float3(1, 0, 24)); + var scene = new ContentsScene().Add(left).Add(right).Add(far); + var a = Decal(1, new float3(-8, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false, material: kDecalMaterial); + var b = Decal(2, new float3(-7.5f, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false, material: kOtherDecalMaterial); + var c = Decal(3, new float3(0, 0, 22), kDown, new float3(1, 1, 0.5f), transparent: false); + using var harness = BuildAndUpdate(scene, a, b, c); + Assert.That(AreaOf(harness, kOtherDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance)); + + SetDecals(harness, b, a, c); + Assert.That(harness.Update(), Is.True); + Assert.That(CompactHierarchyManager.LastUpdateModifiedBrushCount, Is.EqualTo(1), "only the brush under the two: " + harness.LastUpdateReport); + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0 + 1.0).Within(kAreaTolerance), "the other one on top now (and the far one)"); + + var d = Decal(4, new float3(8, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false); + SetDecals(harness, b, d, a, c); + Assert.That(harness.Update(), Is.True); + Assert.That(CompactHierarchyManager.LastUpdateModifiedBrushCount, Is.EqualTo(1), "only the brush the added one is on: " + harness.LastUpdateReport); + } + + [Test] + public void TransparentDecal_UnderAnOpaqueOne_IsHiddenByIt() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + using var harness = BuildAndUpdate(scene, + Decal(1, new float3(0, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: true, material: kDecalMaterial), + Decal(2, new float3(0.5f, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false, material: kOtherDecalMaterial)); + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.5).Within(kAreaTolerance)); + + using var above = BuildAndUpdate(scene, + Decal(2, new float3(0.5f, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false, material: kOtherDecalMaterial), + Decal(1, new float3(0, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: true, material: kDecalMaterial)); + Assert.That(AreaOf(above, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance), "on top, it is drawn whole"); + } + + [Test] + public void PerspectiveDecal_IsCenteredOnItsImage() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + using var harness = BuildAndUpdate(scene, + Decal(1, new float3(0, 0, 0), kDown, new float3(1, 1, 1), transparent: true, projection: ChiselDecalProjection.Perspective)); + // The floor is at the box's center plane, where the image has the box's size + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance)); + } + + [Test] + public void MovingADecal_RebuildsOnlyTheBrushesItReaches() + { + var left = Box("left", new float3(-10, -1, -2), new float3(-6, 0, 2)); + var right = Box("right", new float3(6, -1, -2), new float3(10, 0, 2)); + var middle = Box("middle", new float3(-1, -1, 20), new float3(1, 0, 24)); + var scene = new ContentsScene().Add(left).Add(right).Add(middle); + using var harness = BuildAndUpdate(scene, Decal(1, new float3(-8, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false)); + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance)); + + SetDecals(harness, Decal(1, new float3(8, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: false)); + Assert.That(harness.Update(), Is.True); + Assert.That(CompactHierarchyManager.LastUpdateModifiedBrushCount, Is.EqualTo(2), "only where the decal was and is: " + harness.LastUpdateReport); + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(1.0).Within(kAreaTolerance)); + foreach (var triangle in harness.Triangles) + { + if (triangle.material == kDecalMaterial) + Assert.That(triangle.a.x, Is.GreaterThan(0), "the decal left the left brush"); + } + Assert.That(SurfaceArea(harness, new float3(0, 1, 0)), Is.EqualTo(16 + 15 + 8).Within(kAreaTolerance)); + } + + [Test] + public void RemovingDecals_RestoresTheSurface() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + using var harness = BuildAndUpdate(scene, Decal(1, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: false)); + Assert.That(harness.Tree.GetDecalCount(), Is.EqualTo(1)); + + harness.Tree.ClearDecals(); + Assert.That(harness.Update(), Is.True); + Assert.That(harness.Tree.GetDecalCount(), Is.EqualTo(0)); + Assert.That(AreaOfMaterial(harness, kDecalMaterial), Is.EqualTo(0.0)); + Assert.That(SurfaceArea(harness, new float3(0, 1, 0)), Is.EqualTo(16.0).Within(kAreaTolerance)); + } + + [Test] + public void SettingTheSameDecals_RebuildsNothing() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + var decal = Decal(1, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: false); + using var harness = BuildAndUpdate(scene, decal); + SetDecals(harness, decal); + Assert.That(harness.Tree.IsStatusFlagSet(NodeStatusFlags.TreeNeedsUpdate), Is.False, "nothing changed"); + } + + [Test] + public void DecalInEmptySpace_DrawsNothing() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2))); + using var harness = BuildAndUpdate(scene, Decal(1, new float3(0, 5, 0), kDown, new float3(1, 1, 0.5f), transparent: false)); + Assert.That(AreaOfMaterial(harness, kDecalMaterial), Is.EqualTo(0.0)); + Assert.That(SurfaceArea(harness, new float3(0, 1, 0)), Is.EqualTo(16.0).Within(kAreaTolerance)); + } + + #region Targets + static void SetDecals(ContentsTreeHarness harness, ChiselDecalInstance[] decals, ChiselDecalTarget[] targets) + { + using var decalArray = new NativeArray(decals, Allocator.Temp); + using var targetArray = new NativeArray(targets, Allocator.Temp); + harness.Tree.SetDecals(decalArray, targetArray); + } + + static ChiselDecalInstance Targeting(ChiselDecalInstance decal, int start, int count) + { + decal.targetStart = start; + decal.targetCount = count; + return decal; + } + + static ContentsSceneNode Box(string name, float3 min, float3 max, ulong entityID) + { + var box = Box(name, min, max); + box.entityID = entityID; + return box; + } + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene, ChiselDecalInstance[] decals, ChiselDecalTarget[] targets) + { + var harness = ContentsTreeHarness.Build(scene); + SetDecals(harness, decals, targets); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(harness.Delivered, Is.True, "no meshes were delivered: " + harness.LastUpdateReport); + return harness; + } + + // Plane 3 of ContentsScene.BoxPlanes is its top (+Y), plane 5 its front (+Z) + const int kTopSurface = 3; + const int kFrontSurface = 5; + + [Test] + public void TargetedTransparentDecal_DrawsOnlyOnItsTarget() + { + var scene = new ContentsScene() + .Add(Box("a", new float3(-2, -1, -2), new float3(0, 0, 2), 101)) + .Add(Box("b", new float3(0, -1, -2), new float3(2, 0, 2), 102)); + using var harness = BuildAndUpdate(scene, + new[] { Targeting(Decal(1, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: true), 0, 1) }, + new[] { new ChiselDecalTarget { brushEntityID = 101, surfaceIndex = ChiselDecalTarget.kAllSurfaces } }); + + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.5).Within(kAreaTolerance), "only the half on a"); + foreach (var triangle in harness.Triangles) + { + if (triangle.material == kDecalMaterial) + Assert.That(triangle.Center.x, Is.LessThan(0), "the decal is on b"); + } + } + + [Test] + public void TargetedOpaqueDecal_CutsTheOtherBrushWithoutCoveringIt() + { + var a = Box("a", new float3(-2, -1, -2), new float3(0, 0, 2), 101); + var b = Box("b", new float3(0, -1, -2), new float3(2, 0, 2), 102); + var scene = new ContentsScene().Add(a).Add(b); + var rotated = math.mul(kDown, quaternion.RotateZ(0.4f)); + using var harness = BuildAndUpdate(scene, + new[] { Targeting(Decal(1, new float3(0.1f, 0, 0.2f), rotated, new float3(1.3f, 0.9f, 0.5f), transparent: false), 0, 1) }, + new[] { new ChiselDecalTarget { brushEntityID = 101, surfaceIndex = kTopSurface } }); + + var decalArea = AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)); + Assert.That(decalArea, Is.GreaterThan(0.1).And.LessThan(1.3 * 0.9 - 0.1), "only a's part"); + Assert.That(decalArea + SurfaceArea(harness, new float3(0, 1, 0)), Is.EqualTo(16.0).Within(kAreaTolerance), + "b keeps its whole top"); + + // b was still cut, so both brushes put the same points on the line they share + var onSeamA = new HashSet(); + var onSeamB = new HashSet(); + foreach (var triangle in harness.Triangles) + { + if (triangle.Normal.y < 0.99f) + continue; + var node = harness.NodeOf(triangle.brushID); + foreach (var point in new[] { triangle.a, triangle.b, triangle.c }) + { + if (point.x != 0) + continue; + (node == a ? onSeamA : onSeamB).Add(point); + } + } + Assert.That(onSeamA.Count, Is.GreaterThan(2), "the decal should put points on the seam"); + Assert.That(onSeamB, Is.EquivalentTo(onSeamA)); + } + + [Test] + public void TargetingOneSurface_LeavesTheBrushsOtherSurfacesAlone() + { + // The decal box hangs over the block's top front edge, so it reaches a quarter of a square metre of both + var scene = new ContentsScene().Add(Box("block", new float3(-1, -1, -1), new float3(1, 1, 1), 201)); + var decal = Decal(1, new float3(0, 1.25f, 1.25f), kDown, new float3(1, 1, 1), transparent: true); + + using (var everything = BuildAndUpdate(scene, decal)) + { + Assert.That(AreaOf(everything, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.25).Within(kAreaTolerance)); + Assert.That(AreaOf(everything, kDecalMaterial, new float3(0, 0, 1)), Is.EqualTo(0.25).Within(kAreaTolerance), + "without targets the front takes it too"); + Assert.That(AreaOfMaterial(everything, kDecalMaterial), Is.EqualTo(0.5).Within(kAreaTolerance)); + } + using (var top = BuildAndUpdate(scene, new[] { Targeting(decal, 0, 1) }, + new[] { new ChiselDecalTarget { brushEntityID = 201, surfaceIndex = kTopSurface } })) + { + Assert.That(AreaOf(top, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.25).Within(kAreaTolerance)); + Assert.That(AreaOfMaterial(top, kDecalMaterial), Is.EqualTo(0.25).Within(kAreaTolerance), "only the top"); + } + using (var front = BuildAndUpdate(scene, new[] { Targeting(decal, 0, 1) }, + new[] { new ChiselDecalTarget { brushEntityID = 201, surfaceIndex = kFrontSurface } })) + { + Assert.That(AreaOf(front, kDecalMaterial, new float3(0, 0, 1)), Is.EqualTo(0.25).Within(kAreaTolerance)); + Assert.That(AreaOfMaterial(front, kDecalMaterial), Is.EqualTo(0.25).Within(kAreaTolerance), "only the front"); + } + } + + [Test] + public void DecalLimitedToSurfacesThatArentThere_DrawsNothing() + { + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2), 301)); + using var harness = BuildAndUpdate(scene, + new[] + { + Targeting(Decal(1, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: false), 0, 1), + // Its targets are past the end of the array + Targeting(Decal(2, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: false, material: kOtherDecalMaterial), 5, 2) + }, + new[] { new ChiselDecalTarget { brushEntityID = 999, surfaceIndex = ChiselDecalTarget.kAllSurfaces } }); + Assert.That(AreaOfMaterial(harness, kDecalMaterial), Is.EqualTo(0.0)); + Assert.That(AreaOfMaterial(harness, kOtherDecalMaterial), Is.EqualTo(0.0)); + Assert.That(SurfaceArea(harness, new float3(0, 1, 0)), Is.EqualTo(16.0).Within(kAreaTolerance)); + } + + [Test] + public void ChangingTheTargets_MovesTheDecal() + { + var scene = new ContentsScene() + .Add(Box("a", new float3(-2, -1, -2), new float3(0, 0, 2), 101)) + .Add(Box("b", new float3(0, -1, -2), new float3(2, 0, 2), 102)); + var decal = Targeting(Decal(1, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: true), 0, 1); + using var harness = BuildAndUpdate(scene, new[] { decal }, + new[] { new ChiselDecalTarget { brushEntityID = 101, surfaceIndex = ChiselDecalTarget.kAllSurfaces } }); + + // The same decal and targets at another place in the array are no change + SetDecals(harness, new[] { Targeting(decal, 1, 1) }, + new[] { new ChiselDecalTarget { brushEntityID = 999 }, new ChiselDecalTarget { brushEntityID = 101, surfaceIndex = ChiselDecalTarget.kAllSurfaces } }); + Assert.That(harness.Tree.IsStatusFlagSet(NodeStatusFlags.TreeNeedsUpdate), Is.False, "nothing changed"); + + SetDecals(harness, new[] { decal }, + new[] { new ChiselDecalTarget { brushEntityID = 102, surfaceIndex = ChiselDecalTarget.kAllSurfaces } }); + Assert.That(harness.Update(), Is.True); + foreach (var triangle in harness.Triangles) + { + if (triangle.material == kDecalMaterial) + Assert.That(triangle.Center.x, Is.GreaterThan(0), "the decal stayed on a"); + } + Assert.That(AreaOf(harness, kDecalMaterial, new float3(0, 1, 0)), Is.EqualTo(0.5).Within(kAreaTolerance)); + } + #endregion + + [Test] + public void ClickingADecal_SelectsTheDecal() + { + const ulong kFloor = 401, kTransparent = 7001, kOpaque = 7002; + var scene = new ContentsScene().Add(Box("floor", new float3(-2, -1, -2), new float3(2, 0, 2), kFloor)); + using var harness = BuildAndUpdate(scene, + Decal(kTransparent, float3.zero, kDown, new float3(1, 1, 0.5f), transparent: true), + Decal(kOpaque, new float3(1.25f, 0, 1.25f), kDown, new float3(1, 1, 0.5f), transparent: false, material: kOtherDecalMaterial)); + + int transparentTriangles = 0, opaqueTriangles = 0, floorTriangles = 0; + foreach (var triangle in harness.Triangles) + { + if (triangle.material == kDecalMaterial) + { + transparentTriangles++; + Assert.That(triangle.selectedEntityID, Is.EqualTo(kTransparent), "a transparent decal's triangle"); + } else + if (triangle.material == kOtherDecalMaterial) + { + opaqueTriangles++; + Assert.That(triangle.selectedEntityID, Is.EqualTo(kOpaque), "an opaque decal's triangle"); + } else + { + floorTriangles++; + Assert.That(triangle.selectedEntityID, Is.EqualTo(kFloor), "the floor's triangle"); + } + } + Assert.That(transparentTriangles, Is.GreaterThan(0)); + Assert.That(opaqueTriangles, Is.GreaterThan(0)); + Assert.That(floorTriangles, Is.GreaterThan(0)); + } + + [Test] + public void DecalOnAFaceTheCSGRemoved_DrawsNothingThere() + { + // Two stacked boxes: the lower one's top is inside the upper one and isn't drawn + var scene = new ContentsScene() + .Add(Box("lower", new float3(-2, -1, -2), new float3(2, 0, 2))) + .Add(Box("upper", new float3(-2, 0, -2), new float3(2, 1, 2))); + using var harness = BuildAndUpdate(scene, Decal(1, new float3(0, 0, 0), kDown, new float3(1, 1, 0.5f), transparent: true)); + Assert.That(AreaOfMaterial(harness, kDecalMaterial), Is.EqualTo(0.0).Within(kAreaTolerance)); + } + } +} diff --git a/Core/Tests/Contents/DecalPipelineTests.cs.meta b/Core/Tests/Contents/DecalPipelineTests.cs.meta new file mode 100644 index 00000000..973a149f --- /dev/null +++ b/Core/Tests/Contents/DecalPipelineTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 22d79df71ad3e47439630d6b0255d6d9 \ No newline at end of file diff --git a/Core/Tests/Contents/ExactCSGProbeTests.cs b/Core/Tests/Contents/ExactCSGProbeTests.cs new file mode 100644 index 00000000..b7fb49db --- /dev/null +++ b/Core/Tests/Contents/ExactCSGProbeTests.cs @@ -0,0 +1,352 @@ +using System; +using System.Reflection; +using System.Text; +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Probe")] + [Explicit("writes traces of the exact CSG to Library/ChiselHarvest/traces; run it deliberately")] + public class ExactCSGProbeTests + { + // Corpus fixtures: they assert through ContentsHarvest.AssertMatchesOracle, which traces when TraceTo is set. + static readonly (Type fixture, string method)[] kFixtures = + { + (typeof(SampleSceneHoleTests), "HoleOnExtrudedShapeBottom_WithAddBoxAndAddBox15AndAddExtrudedShape2_Model2F04"), + }; + + // Generated scenes, asserted the same way so they are traced too. + static readonly (string what, Func scene)[] kScenes = + { + ("cut brushes with types seed 29", () => ContentsSceneGenerators.CutBrushes(29)), + ("free brushes with types seed 29", () => ContentsSceneGenerators.FreeBrushes(29)), + }; + + [Test] + public void TraceFailingFixtures() + { + var saved = CompactHierarchyManager.TreeUpdate.kExactCSG; + var report = new StringBuilder(); + CompactHierarchyManager.TreeUpdate.kExactCSG = true; + try + { + foreach (var (fixture, method) in kFixtures) + { + ContentsHarvest.TraceTo = "exact"; + try + { + var instance = Activator.CreateInstance(fixture); + fixture.GetMethod(method, BindingFlags.Public | BindingFlags.Instance).Invoke(instance, null); + report.AppendLine($"{method}: PASS"); + } + catch (TargetInvocationException e) + { + var message = e.InnerException?.Message ?? e.Message; + report.AppendLine($"{method}: FAIL {message.Split('\n')[0]}"); + } + finally + { + ContentsHarvest.TraceTo = null; + } + } + foreach (var (what, scene) in kScenes) + { + ContentsHarvest.TraceTo = "exact"; + try + { + ContentsHarvest.AssertMatchesOracle(scene(), what); + report.AppendLine($"{what}: PASS"); + } + catch (Exception e) + { + report.AppendLine($"{what}: FAIL {e.Message.Split('\n')[0]}"); + } + finally + { + ContentsHarvest.TraceTo = null; + } + } + } + finally + { + CompactHierarchyManager.TreeUpdate.kExactCSG = saved; + } + Assert.Pass(report.ToString()); + } + + [Test] + public void NestedSubtractionDepths() + { + var saved = CompactHierarchyManager.TreeUpdate.kExactCSG; + var report = new StringBuilder(); + try + { + foreach (var exact in new[] { true, false }) + { + CompactHierarchyManager.TreeUpdate.kExactCSG = exact; + foreach (int depth in new[] { 4, 8, 10, 12, 13, 14, 15, 16 }) + { + using (var harness = ContentsTreeHarness.Build(NestedSubtractions(depth))) + { + harness.JudgeEachUpdate = false; + harness.Update(); + var mismatches = ContentsComparison.FindMismatchRecords(harness, out int judged, out int skipped, out _, 64f, 3); + var data = ChiselTreeLookup.Value[harness.Tree]; + int maxRows = 0, maxLookupRows = 0, maxDestination = 0, tables = 0; + for (int i = 0; i < data.routingTableCache.Length; i++) + { + var table = data.routingTableCache[i]; + if (!table.IsCreated) continue; + tables++; + ref var rows = ref table.Value.routingRows; + maxRows = Math.Max(maxRows, rows.Length); + ref var lookups = ref table.Value.routingLookups; + for (int k = 0; k < lookups.Length; k++) + maxLookupRows = Math.Max(maxLookupRows, lookups[k].endIndex - lookups[k].startIndex); + for (int r = 0; r < rows.Length; r++) + { + var row = rows[r]; + foreach (var destination in new[] { (int)row.inside, (int)row.aligned, (int)row.selfAligned, + (int)row.selfReverseAligned, (int)row.reverseAligned, (int)row.outside }) + maxDestination = Math.Max(maxDestination, destination); + } + } + report.Append($"{(exact ? "exact" : "loops")} depth {depth}: {mismatches.Count} mismatch(es) over {judged} judged ({skipped} skipped); ") + .Append($"{tables} tables, rows max {maxRows}, rows per lookup max {maxLookupRows}, destination max {maxDestination}"); + if (mismatches.Count > 0) + report.Append("; first: ").Append(mismatches[0].Format("n")); + report.AppendLine(); + } + } + } + } + finally + { + CompactHierarchyManager.TreeUpdate.kExactCSG = saved; + } + // Also to the log: a script that runs this reads the numbers there, where a passed result carries no message + UnityEngine.Debug.Log("ExactCSGProbeTests.NestedSubtractionDepths\n" + report); + Assert.Pass(report.ToString()); + } + + static ContentsScene NestedSubtractions(int depth) + { + var block = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new Unity.Mathematics.float3(-10), new Unity.Mathematics.float3(10)), name: "block"); + ContentsSceneNode inner = null; + for (int i = depth - 1; i >= 0; i--) + { + var offset = i * 0.5f; + var bite = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new Unity.Mathematics.float3(-5 + offset, -12, -5 - offset), + new Unity.Mathematics.float3( 5 + offset, 12, 5 - offset)), name: "bite" + i); + inner = inner == null ? ContentsSceneNode.Composite(CSGOperationType.Subtractive, bite) + : ContentsSceneNode.Composite(CSGOperationType.Subtractive, bite, inner); + } + return new ContentsScene().Add(block).Add(inner); + } + + // Does every output triangle lie on a face plane of the brush it is attributed to? Written to + // Library/ChiselHarvest/traces/attribution_*.txt for the exact CSG and for the loop pipeline. + [Test] + public void CheckTriangleAttribution() + { + var saved = CompactHierarchyManager.TreeUpdate.kExactCSG; + var report = new StringBuilder(); + try + { + foreach (var exact in new[] { true, false }) + { + CompactHierarchyManager.TreeUpdate.kExactCSG = exact; + var text = new StringBuilder(); + var scene = Model2F04Scene(); + using (var harness = ContentsTreeHarness.Build(scene)) + { + harness.Update(); + var brushes = new System.Collections.Generic.List(); + foreach (var root in scene.roots) AddBrushes(root, brushes); + int wrong = 0, total = 0; + for (int t = 0; t < harness.Triangles.Count; t++) + { + var triangle = harness.Triangles[t]; + var node = harness.NodeOf(triangle.brushID); + total++; + bool onOwn = node != null && OnAPlaneOf(node, triangle); + if (onOwn) + continue; + wrong++; + var owners = new StringBuilder(); + foreach (var other in brushes) + if (OnAPlaneOf(other, triangle)) owners.Append(other.name).Append(' '); + text.AppendLine($"triangle {t} section {triangle.section} material {triangle.material} attributed to {node?.name ?? "nothing"} " + + $"({triangle.a}, {triangle.b}, {triangle.c}) lies on a plane of: {owners}"); + } + // The failing sample point on Extruded Shape #172's bottom, and every triangle of Box (15) + var point = new Unity.Mathematics.float3(-11.988f, -1f, -13.033f); + var bottom = new Unity.Mathematics.float4(0, -1, 0, -1); + for (int t = 0; t < harness.Triangles.Count; t++) + { + var triangle = harness.Triangles[t]; + var node = harness.NodeOf(triangle.brushID); + bool covers = ContentsComparison.Covers(triangle.a, triangle.b, triangle.c, bottom, point, out int facing); + if (covers || (node != null && node.name.StartsWith("Box (15)"))) + text.AppendLine($" {(covers ? "COVERS P " : "")}triangle {t} of {node?.name ?? "nothing"} facing {(covers ? facing : 0)}: {triangle.a} {triangle.b} {triangle.c}"); + } + text.Insert(0, $"exact={exact}: {wrong} of {total} triangles are not on a plane of the brush they are attributed to\n"); + report.AppendLine($"exact={exact}: {wrong} of {total} misattributed"); + } + var directory = System.IO.Path.Combine("Library", "ChiselHarvest", "traces"); + System.IO.Directory.CreateDirectory(directory); + System.IO.File.WriteAllText(System.IO.Path.Combine(directory, $"attribution_model2f04_{(exact ? "exact" : "loops")}.txt"), text.ToString()); + } + } + finally + { + CompactHierarchyManager.TreeUpdate.kExactCSG = saved; + } + Assert.Pass(report.ToString()); + } + + [Test] + public void CheckLoopHoleCoverage() + { + var saved = CompactHierarchyManager.TreeUpdate.kExactCSG; + var text = new StringBuilder(); + CompactHierarchyManager.TreeUpdate.kExactCSG = false; + try + { + var build = typeof(IntersectionLoopCapacityTests).GetMethod("MutuallyOverlappingBrushes", BindingFlags.NonPublic | BindingFlags.Static); + var scene = (ContentsScene)build.Invoke(null, new object[] { 10 }); + var brushes = new System.Collections.Generic.List(); + foreach (var root in scene.roots) AddBrushes(root, brushes); + var b7 = brushes[7]; + var toTree = Unity.Mathematics.math.transpose(Unity.Mathematics.math.inverse(b7.localToTree)); + var plane = Unity.Mathematics.math.mul(toTree, b7.localPlanes[1]); + plane /= Unity.Mathematics.math.length(plane.xyz); + var point = new Unity.Mathematics.float3(0.273f, -0.764f, -0.35f); + text.AppendLine($"b7 face 1 plane {plane}, point {point} is {Unity.Mathematics.math.dot(plane.xyz, point) + plane.w} off it"); + using (var harness = ContentsTreeHarness.Build(scene)) + { + harness.Update(); + for (int t = 0; t < harness.Triangles.Count; t++) + { + var triangle = harness.Triangles[t]; + bool covers = ContentsComparison.Covers(triangle.a, triangle.b, triangle.c, plane, point, out _); + bool oldCovers = OldCoversPoint(triangle.a, triangle.b, triangle.c, plane, point, 0.02f, 0.001f); + if (!covers && !oldCovers) + continue; + var node = harness.NodeOf(triangle.brushID); + float longest = Unity.Mathematics.math.max(Unity.Mathematics.math.length(triangle.b - triangle.a), + Unity.Mathematics.math.max(Unity.Mathematics.math.length(triangle.c - triangle.b), Unity.Mathematics.math.length(triangle.a - triangle.c))); + var center = (triangle.a + triangle.b + triangle.c) / 3; + text.AppendLine($"triangle {t} of {node?.name ?? "nothing"}: covers now {covers}, under the old test {oldCovers}; longest edge {longest}, " + + $"centre {Unity.Mathematics.math.distance(center, point)} from the point: {triangle.a} {triangle.b} {triangle.c}"); + } + } + } + finally + { + CompactHierarchyManager.TreeUpdate.kExactCSG = saved; + } + var directory = System.IO.Path.Combine("Library", "ChiselHarvest", "traces"); + System.IO.Directory.CreateDirectory(directory); + System.IO.File.WriteAllText(System.IO.Path.Combine(directory, "coverage_ten_overlapping_brushes.txt"), text.ToString()); + Assert.Pass(text.ToString()); + } + + // ContentsFaceSampler.CoversPoint with exact edge tests instead of distances + static bool OldCoversPoint(Unity.Mathematics.float3 a, Unity.Mathematics.float3 b, Unity.Mathematics.float3 c, + Unity.Mathematics.float4 plane, Unity.Mathematics.float3 point, float onPlaneEpsilon, float insideEpsilon) + { + if (Unity.Mathematics.math.abs(Unity.Mathematics.math.dot(plane.xyz, a) + plane.w) > onPlaneEpsilon) return false; + if (Unity.Mathematics.math.abs(Unity.Mathematics.math.dot(plane.xyz, b) + plane.w) > onPlaneEpsilon) return false; + if (Unity.Mathematics.math.abs(Unity.Mathematics.math.dot(plane.xyz, c) + plane.w) > onPlaneEpsilon) return false; + var normal = plane.xyz; + var d0 = Unity.Mathematics.math.dot(Unity.Mathematics.math.cross(b - a, point - a), normal); + var d1 = Unity.Mathematics.math.dot(Unity.Mathematics.math.cross(c - b, point - b), normal); + var d2 = Unity.Mathematics.math.dot(Unity.Mathematics.math.cross(a - c, point - c), normal); + return (d0 >= -insideEpsilon && d1 >= -insideEpsilon && d2 >= -insideEpsilon) || + (d0 <= insideEpsilon && d1 <= insideEpsilon && d2 <= insideEpsilon); + } + + static void AddBrushes(ContentsSceneNode node, System.Collections.Generic.List brushes) + { + if (node.IsBrush) { brushes.Add(node); return; } + foreach (var child in node.children) AddBrushes(child, brushes); + } + + // SampleSceneHoleTests.HoleOnExtrudedShapeBottom_WithAddBoxAndAddBox15AndAddExtrudedShape2_Model2F04's scene + static ContentsScene Model2F04Scene() + { + return new ContentsScene() + .Add(ContentsSceneNode.Brush(new Unity.Mathematics.float4[] + { + new Unity.Mathematics.float4(-1.74902783E-14f, -1f, 5.9604627E-08f, -1f), + new Unity.Mathematics.float4(1.74902783E-14f, 1f, -5.9604627E-08f, 0.5f), + new Unity.Mathematics.float4(-0.8944274f, 0f, 0.4472131f, -5.813776f), + new Unity.Mathematics.float4(-0.5144956f, 0f, -0.857493f, -1.11474156f), + new Unity.Mathematics.float4(0.8944272f, 0f, -0.44721362f, -5.81377649f), + new Unity.Mathematics.float4(0.51449573f, 0f, 0.8574929f, -1.11474061f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new Unity.Mathematics.float4x4(new Unity.Mathematics.float4(-1.00000048f, 0f, 0f, 0f), new Unity.Mathematics.float4(0f, 1f, 0f, 0f), new Unity.Mathematics.float4(0f, 0f, -1.00000048f, 0f), new Unity.Mathematics.float4(-7.500004f, 0f, -16.0000076f, 1f)), name: "Extruded Shape #172")) + .Add(ContentsSceneNode.Brush(new Unity.Mathematics.float4[] + { + new Unity.Mathematics.float4(-1.45915264E-15f, 1f, -2.12873918E-09f, 1.06436939E-08f), + new Unity.Mathematics.float4(0f, -1f, 0f, -1f), + new Unity.Mathematics.float4(-1f, 4.4822762E-05f, -1.53269343E-06f, -7.99999142f), + new Unity.Mathematics.float4(1f, -4.43458557E-05f, 4.72003418E-14f, -7.99999952f), + new Unity.Mathematics.float4(-1.37090876E-06f, 4.386908E-05f, -1f, -8.999989f), + new Unity.Mathematics.float4(0f, -4.386902E-05f, 1f, -4.999999f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new Unity.Mathematics.float4x4(new Unity.Mathematics.float4(-1.00000048f, 0f, 0f, 0f), new Unity.Mathematics.float4(0f, 1f, 0f, 0f), new Unity.Mathematics.float4(0f, 0f, -1.00000048f, 0f), new Unity.Mathematics.float4(-1.36423772E-12f, -1f, -25f, 1f)), name: "Box #173")) + .Add(ContentsSceneNode.Brush(new Unity.Mathematics.float4[] + { + new Unity.Mathematics.float4(-1f, 0f, 0f, -1f), + new Unity.Mathematics.float4(0f, -1f, 0f, -3.5f), + new Unity.Mathematics.float4(0f, 0f, -1f, -1f), + new Unity.Mathematics.float4(1f, 0f, 0f, -1f), + new Unity.Mathematics.float4(0f, 1f, 0f, -6.5f), + new Unity.Mathematics.float4(0f, 0f, 1f, -1f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new Unity.Mathematics.float4x4(new Unity.Mathematics.float4(1.00000048f, 0f, 0f, 0f), new Unity.Mathematics.float4(0f, 1f, 0f, 0f), new Unity.Mathematics.float4(0f, 0f, 1.00000048f, 0f), new Unity.Mathematics.float4(-7.5f, 2.5f, -18f, 1f)), name: "Box (15) #260")) + .Add(ContentsSceneNode.Brush(new Unity.Mathematics.float4[] + { + new Unity.Mathematics.float4(9.169917E-09f, -1f, 7.488786E-08f, -1f), + new Unity.Mathematics.float4(-5.73119063E-09f, 1f, -6.667312E-08f, 0.5f), + new Unity.Mathematics.float4(-0.8944274f, 0f, 0.447213173f, -5.813776f), + new Unity.Mathematics.float4(-0.5144956f, 0f, -0.857493f, -1.11474156f), + new Unity.Mathematics.float4(0.8944272f, 0f, -0.4472136f, -5.81377649f), + new Unity.Mathematics.float4(0.51449573f, 0f, 0.8574929f, -0.5573704f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new Unity.Mathematics.float4x4(new Unity.Mathematics.float4(-1.00000048f, 0f, 0f, 0f), new Unity.Mathematics.float4(0f, 1f, 0f, 0f), new Unity.Mathematics.float4(0f, 0f, -1.00000048f, 0f), new Unity.Mathematics.float4(-7.500004f, 0f, -16.0000076f, 1f)), name: "Extruded Shape (2) #319")); + } + + // On one of the brush's face planes, and inside the brush: coplanar brushes share planes, so the plane alone says + // nothing about which of them a triangle belongs to. + static bool OnAPlaneOf(ContentsSceneNode brush, ContentsTreeHarness.CapturedTriangle triangle) + { + var toTree = Unity.Mathematics.math.transpose(Unity.Mathematics.math.inverse(brush.localToTree)); + var center = (triangle.a + triangle.b + triangle.c) / 3.0f; + foreach (var local in brush.localPlanes) + { + var plane = Unity.Mathematics.math.mul(toTree, local); + var length = Unity.Mathematics.math.length(plane.xyz); + if (length <= 0) continue; + plane /= length; + if (Unity.Mathematics.math.dot(plane.xyz, center) + plane.w > 1e-3f) + return false; + } + foreach (var local in brush.localPlanes) + { + var plane = Unity.Mathematics.math.mul(toTree, local); + var length = Unity.Mathematics.math.length(plane.xyz); + if (length <= 0) continue; + plane /= length; + if (Unity.Mathematics.math.abs(Unity.Mathematics.math.dot(plane.xyz, triangle.a) + plane.w) < 1e-3f && + Unity.Mathematics.math.abs(Unity.Mathematics.math.dot(plane.xyz, triangle.b) + plane.w) < 1e-3f && + Unity.Mathematics.math.abs(Unity.Mathematics.math.dot(plane.xyz, triangle.c) + plane.w) < 1e-3f) + return true; + } + return false; + } + } +} diff --git a/Core/Tests/Contents/ExactCSGProbeTests.cs.meta b/Core/Tests/Contents/ExactCSGProbeTests.cs.meta new file mode 100644 index 00000000..7fa55a08 --- /dev/null +++ b/Core/Tests/Contents/ExactCSGProbeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c2617bc86c2dcfe4a81dce72bfa770fc \ No newline at end of file diff --git a/Core/Tests/Contents/ExactDelaunayCheck.cs b/Core/Tests/Contents/ExactDelaunayCheck.cs new file mode 100644 index 00000000..4f57a842 --- /dev/null +++ b/Core/Tests/Contents/ExactDelaunayCheck.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using BigInteger = System.Numerics.BigInteger; + +namespace Chisel.Core.Tests +{ + internal static class ExactDelaunayCheck + { + internal struct Result + { + public int innerEdges; // edges with a triangle on each side + public int violations; // of those, the ones that should have been flipped + public int floatUncertain; // triangles that do not certainly turn left in the drawn floats + public string first; // the first violation, described + } + + // vertices: exact; positions: x, y, z per vertex; triangles: counter-clockwise seen from the plane, in its projection + // (axisU, axisV) as ExactPredicates.ProjectionAxes gives it. + internal static Result Check(ExactVertex[] vertices, float[] positions, int[] triangles, int axisU, int axisV) + { + var result = new Result(); + var directed = new Dictionary(); // (from, to) -> how many triangles have that edge + var slotOf = new Dictionary(); // (from, to) -> the slot of one of them + for (int slot = 0; slot < triangles.Length; slot++) + { + int triangle = slot / 3; + long key = Key(triangles[slot], triangles[triangle * 3 + (slot % 3 == 2 ? 0 : slot % 3 + 1)]); + directed.TryGetValue(key, out int count); + directed[key] = count + 1; + slotOf[key] = slot; + } + for (int t = 0; t + 2 < triangles.Length; t += 3) + if (!TurnsLeftCertainly(positions, triangles[t], triangles[t + 1], triangles[t + 2], axisU, axisV)) + result.floatUncertain++; + + for (int slot = 0; slot < triangles.Length; slot++) + { + int t = slot / 3, k = slot % 3; + int a = triangles[slot], b = triangles[t * 3 + (k + 1) % 3], c = triangles[t * 3 + (k + 2) % 3]; + if (a > b) + continue; // each inner edge once + if (directed[Key(a, b)] != 1 || !directed.TryGetValue(Key(b, a), out int reverse) || reverse != 1) + continue; + int twin = slotOf[Key(b, a)]; + int s = twin / 3, j = twin % 3; + int d = triangles[s * 3 + (j + 2) % 3]; + result.innerEdges++; + + if (!InCircleCertainly(positions, a, b, c, d, axisU, axisV)) + continue; + if (ExactPredicates.Orientation(vertices[a], vertices[d], vertices[c], axisU, axisV) <= 0 || + ExactPredicates.Orientation(vertices[d], vertices[b], vertices[c], axisU, axisV) <= 0) + continue; + if (!TurnsLeftCertainly(positions, a, d, c, axisU, axisV) || !TurnsLeftCertainly(positions, d, b, c, axisU, axisV)) + continue; + result.violations++; + if (result.first == null) + result.first = $"the edge {Describe(positions, a)} - {Describe(positions, b)} between {Describe(positions, c)} and " + + $"{Describe(positions, d)}: the last lies inside the circle through the other three, and the " + + "other diagonal would make two valid triangles"; + } + return result; + } + + static long Key(int from, int to) => ((long)from << 32) | (uint)to; + + static string Describe(float[] positions, int v) + { + return $"({positions[v * 3]:R}, {positions[v * 3 + 1]:R}, {positions[v * 3 + 2]:R})"; + } + + // A float times 2^149, which is an integer for every float. + internal static BigInteger Scaled(float value) + { + int bits = BitConverter.ToInt32(BitConverter.GetBytes(value), 0); + int exponent = (bits >> 23) & 0xFF; + int mantissa = bits & 0x7FFFFF; + BigInteger scaled = exponent == 0 ? mantissa : (mantissa | 0x800000); + if (exponent > 1) + scaled <<= exponent - 1; + return bits < 0 ? -scaled : scaled; + } + + static BigInteger U(float[] positions, int v, int axisU) => Scaled(positions[v * 3 + axisU]); + + // Whether the determinant exceeds 2^-45 of its permanent. + static bool Certainly(BigInteger determinant, BigInteger permanent) => (determinant << 45) > permanent; + + internal static bool TurnsLeftCertainly(float[] positions, int a, int b, int c, int axisU, int axisV) + { + BigInteger acu = U(positions, a, axisU) - U(positions, c, axisU), bcu = U(positions, b, axisU) - U(positions, c, axisU); + BigInteger acv = U(positions, a, axisV) - U(positions, c, axisV), bcv = U(positions, b, axisV) - U(positions, c, axisV); + BigInteger left = acu * bcv, right = acv * bcu; + return Certainly(left - right, BigInteger.Abs(left) + BigInteger.Abs(right)); + } + + // Whether d lies inside the circle through a, b, c (when those turn left; its sign the other way when they don't). + internal static bool InCircleCertainly(float[] positions, int a, int b, int c, int d, int axisU, int axisV) + { + BigInteger du = U(positions, d, axisU), dv = U(positions, d, axisV); + BigInteger adu = U(positions, a, axisU) - du, adv = U(positions, a, axisV) - dv; + BigInteger bdu = U(positions, b, axisU) - du, bdv = U(positions, b, axisV) - dv; + BigInteger cdu = U(positions, c, axisU) - du, cdv = U(positions, c, axisV) - dv; + BigInteger aLift = adu * adu + adv * adv, bLift = bdu * bdu + bdv * bdv, cLift = cdu * cdu + cdv * cdv; + BigInteger determinant = aLift * (bdu * cdv - cdu * bdv) + bLift * (cdu * adv - adu * cdv) + cLift * (adu * bdv - bdu * adv); + BigInteger permanent = (BigInteger.Abs(bdu * cdv) + BigInteger.Abs(cdu * bdv)) * aLift + + (BigInteger.Abs(cdu * adv) + BigInteger.Abs(adu * cdv)) * bLift + + (BigInteger.Abs(adu * bdv) + BigInteger.Abs(bdu * adv)) * cLift; + return Certainly(determinant, permanent); + } + } +} diff --git a/Core/Tests/Contents/ExactDelaunayCheck.cs.meta b/Core/Tests/Contents/ExactDelaunayCheck.cs.meta new file mode 100644 index 00000000..c8f9a482 --- /dev/null +++ b/Core/Tests/Contents/ExactDelaunayCheck.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 32a731fc8af2c7a4e8e13fab73b644ca \ No newline at end of file diff --git a/Core/Tests/Contents/ExactDelaunayTests.cs b/Core/Tests/Contents/ExactDelaunayTests.cs new file mode 100644 index 00000000..b2d896c3 --- /dev/null +++ b/Core/Tests/Contents/ExactDelaunayTests.cs @@ -0,0 +1,97 @@ +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class ExactDelaunayTests + { + static ContentsScene StripScene() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0, 0, 0), new float3(16, 1, 0.25f)), name: "strip")); + int index = 0; + for (float x = 0.5f; x + 0.375f <= 16; x += 1.25f) + scene.Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(x, 0, -0.5f), new float3(x + 0.375f, 1, 0)), name: "near " + index++)); + for (float x = 1.0625f; x + 0.5f <= 16; x += 1.4375f) + scene.Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(x, 0, 0.25f), new float3(x + 0.5f, 1, 0.75f)), name: "far " + index++)); + return scene; + } + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene) + { + if (!CompactHierarchyManager.TreeUpdate.kExactCSG) + Assert.Ignore("judges the exact CSG's triangles, and the exact CSG is off"); + var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + return harness; + } + + static ExactDelaunayCheck.Result CheckPart(ExactCSGCapture.Brush brush, ExactCSGCapture.Part part) + { + var plane = brush.planes[part.surface]; + int dominant = plane.DominantAxis(); + long component = dominant == 0 ? plane.a : (dominant == 1 ? plane.b : plane.c); + ExactPredicates.ProjectionAxes(dominant, component < 0, out int axisU, out int axisV); + var positions = new float[part.positions.Length * 3]; + for (int v = 0; v < part.positions.Length; v++) + { + positions[v * 3] = part.positions[v].x; + positions[v * 3 + 1] = part.positions[v].y; + positions[v * 3 + 2] = part.positions[v].z; + } + return ExactDelaunayCheck.Check(part.vertices, positions, part.triangles, axisU, axisV); + } + + [Test] + public void AStripWithVerticesAlongBothSides_IsTriangulatedLocallyDelaunay() + { + using (var harness = BuildAndUpdate(StripScene())) + { + int innerEdges = 0, violations = 0, stripTopEdges = 0; + string first = null; + foreach (var node in harness.Scene.roots) + { + Assert.That(ExactCSGCapture.Brushes.TryGetValue(harness.NodeIDOf(node), out var brush) && brush.parts != null, Is.True, + "no triangles captured for " + node.name); + foreach (var part in brush.parts) + { + var result = CheckPart(brush, part); + innerEdges += result.innerEdges; + violations += result.violations; + if (first == null && result.first != null) + first = $"{node.name} face {part.surface}: {result.first}"; + var plane = brush.planes[part.surface]; + if (node.name == "strip" && plane.a == 0 && plane.c == 0 && plane.b > 0) + stripTopEdges += result.innerEdges; + } + } + // the strip's top has a vertex at every box's end on both sides: dozens of inner edges, or it tested nothing + Assert.That(stripTopEdges, Is.GreaterThan(40), "the strip's top has too few inner edges to test anything"); + Assert.That(violations, Is.Zero, $"{violations} of {innerEdges} inner edges are not locally Delaunay; first: {first}"); + + var mismatches = ExactJudge.FindMismatches(harness, 100, out var judged); + Assert.That(judged, Is.GreaterThan(0), "the judge judged nothing"); + Assert.That(mismatches, Is.Empty, string.Join("; ", mismatches.Select(m => m.Format("")))); + } + } + + [Test] + public void TheCheck_ReportsTheDiagonalThatShouldHaveBeenFlipped() + { + ExactVertex Point(double x, double y) => ExactVertex.Intersect(ExactPlane.Quantize(1, 0, 0, -x), ExactPlane.Quantize(0, 1, 0, -y), + ExactPlane.Quantize(0, 0, 1, 0)); + var vertices = new[] { Point(0, 0), Point(4, 0), Point(4, 1), Point(1, 1) }; + var positions = new float[] { 0, 0, 0, 4, 0, 0, 4, 1, 0, 1, 1, 0 }; + var floor = ExactPlane.Quantize(0, 0, 1, 0); + ExactPredicates.ProjectionAxes(floor.DominantAxis(), floor.c < 0, out int u, out int v); + + var wrong = ExactDelaunayCheck.Check(vertices, positions, new[] { 0, 1, 2, 0, 2, 3 }, u, v); + Assert.That((wrong.innerEdges, wrong.violations), Is.EqualTo((1, 1)), "the diagonal that should have been flipped"); + var right = ExactDelaunayCheck.Check(vertices, positions, new[] { 0, 1, 3, 1, 2, 3 }, u, v); + Assert.That((right.innerEdges, right.violations), Is.EqualTo((1, 0)), "the Delaunay diagonal: " + right.first); + } + } +} diff --git a/Core/Tests/Contents/ExactDelaunayTests.cs.meta b/Core/Tests/Contents/ExactDelaunayTests.cs.meta new file mode 100644 index 00000000..de53e3e2 --- /dev/null +++ b/Core/Tests/Contents/ExactDelaunayTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97010c88044182e469b45ca9f5c8809d \ No newline at end of file diff --git a/Core/Tests/Contents/ExactFloatLine.cs b/Core/Tests/Contents/ExactFloatLine.cs new file mode 100644 index 00000000..afc18525 --- /dev/null +++ b/Core/Tests/Contents/ExactFloatLine.cs @@ -0,0 +1,52 @@ +using System.Numerics; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + internal static class ExactFloatLine + { + public static BigInteger Exact(float value) + { + var bits = math.asuint(value); + var field = (int)((bits >> 23) & 0xFF); + long whole = field == 0 ? (bits & 0x7FFFFF) : ((bits & 0x7FFFFF) | 0x800000); + var result = new BigInteger(whole) << (field == 0 ? 0 : field - 1); + return (bits & 0x80000000u) != 0 ? -result : result; + } + + // NaN and infinity are on no line + public static bool OnOneLine(float3 a, float3 b, float3 c) + { + if (!math.all(math.isfinite(a)) || !math.all(math.isfinite(b)) || !math.all(math.isfinite(c))) + return false; + var ux = Exact(b.x) - Exact(a.x); var uy = Exact(b.y) - Exact(a.y); var uz = Exact(b.z) - Exact(a.z); + var wx = Exact(c.x) - Exact(a.x); var wy = Exact(c.y) - Exact(a.y); var wz = Exact(c.z) - Exact(a.z); + return (uy * wz - uz * wy).IsZero && (uz * wx - ux * wz).IsZero && (ux * wy - uy * wx).IsZero; + } + + // m strictly between a and b, given that the three lie on one line (float comparisons, exact) + public static bool StrictlyBetween(float3 a, float3 m, float3 b) + { + for (int k = 0; k < 3; k++) + { + if (a[k] != b[k]) + return (a[k] < m[k] && m[k] < b[k]) || (b[k] < m[k] && m[k] < a[k]); + } + return false; + } + + // The needle a-b-c read from its middle corner: u, m, w in its own winding, its long edge running w -> u; false when + // the three are no needle (not three distinct positions on one line) + public static bool AsNeedle(float3 a, float3 b, float3 c, out float3 u, out float3 m, out float3 w) + { + u = m = w = default; + if (a.Equals(b) || b.Equals(c) || c.Equals(a) || !OnOneLine(a, b, c)) + return false; + if (StrictlyBetween(c, a, b)) { u = c; m = a; w = b; } + else if (StrictlyBetween(a, b, c)) { u = a; m = b; w = c; } + else if (StrictlyBetween(b, c, a)) { u = b; m = c; w = a; } + else return false; + return true; + } + } +} diff --git a/Core/Tests/Contents/ExactFloatLine.cs.meta b/Core/Tests/Contents/ExactFloatLine.cs.meta new file mode 100644 index 00000000..8eed8121 --- /dev/null +++ b/Core/Tests/Contents/ExactFloatLine.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: df596d2948dbf7341929502e91c678df \ No newline at end of file diff --git a/Core/Tests/Contents/ExactInputTests.cs b/Core/Tests/Contents/ExactInputTests.cs new file mode 100644 index 00000000..8839976f --- /dev/null +++ b/Core/Tests/Contents/ExactInputTests.cs @@ -0,0 +1,93 @@ +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class ExactInputTests + { + static int InputBrushes => CompactHierarchyManager.TreeUpdate.s_LastExactCSGStats[(int)ExactCSGStat.InputBrushes]; + + // Every counter of the last update, for the failure message + static string Counters() + { + var stats = CompactHierarchyManager.TreeUpdate.s_LastExactCSGStats; + return string.Join(", ", Enumerable.Range(0, stats.Length).Select(i => $"{(ExactCSGStat)i}={stats[i]}")); + } + + // Boxes in a row, each overlapping the next, so every brush touches its neighbours + static ContentsScene Row(int count) + { + var scene = new ContentsScene(); + for (int i = 0; i < count; i++) + scene.Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(i, 0, 0), new float3(i + 1.5f, 1, 1)), name: "box" + i)); + return scene; + } + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene) + { + if (!CompactHierarchyManager.TreeUpdate.kExactCSG) + Assert.Ignore("tests the exact CSG's input, which is off"); + var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, "the first update did not run"); + return harness; + } + + static ContentsSceneNode Brush(ContentsTreeHarness harness, string name) => harness.Scene.roots.First(n => n.name == name); + + [Test] + public void TheFirstUpdate_BuildsEveryBrushesInput() + { + using (var harness = BuildAndUpdate(Row(4))) + Assert.That(InputBrushes, Is.EqualTo(4), Counters()); + } + + [Test] + public void MovingOneBrush_BuildsOnlyItsInput() + { + using (var harness = BuildAndUpdate(Row(4))) + { + harness.SetLocalToTree(Brush(harness, "box2"), float4x4.Translate(new float3(0, 0.25f, 0))); + Assert.That(harness.Update(), Is.True, "moving a brush updated nothing"); + Assert.That(InputBrushes, Is.EqualTo(1), "brushes whose exact input was built after one brush moved; " + Counters()); + ContentsComparison.AssertMatchesOracle(harness, "box2 moved up by a quarter"); + } + } + + // Removing a brush changes no other brush, but moves the ones after it to other node orders: their inputs have to + // move with them (CacheRemappingJob), not be rebuilt. + [Test] + public void RemovingABrush_KeepsTheOthersInputsWhereTheyGo() + { + using (var harness = BuildAndUpdate(Row(4))) + { + harness.Remove(Brush(harness, "box1"), destroy: true); + Assert.That(harness.Update(), Is.True, "removing a brush updated nothing"); + Assert.That(InputBrushes, Is.EqualTo(0), "brushes whose exact input was built after a brush was removed; " + Counters()); + ContentsComparison.AssertMatchesOracle(harness, "box1 removed"); + + harness.SetLocalToTree(Brush(harness, "box3"), float4x4.Translate(new float3(-0.5f, 0, 0))); + Assert.That(harness.Update(), Is.True, "moving a brush updated nothing"); + Assert.That(InputBrushes, Is.EqualTo(1), "brushes whose exact input was built after one brush moved; " + Counters()); + ContentsComparison.AssertMatchesOracle(harness, "box3 moved back by half"); + } + } + + // Planted: a brush moves and its exact input is not rebuilt. The judge has to see that the planes the CSG used are not + // the brush's planes any more. + [Test] + public void AStaleInput_IsReported() + { + using (var harness = BuildAndUpdate(Row(3))) + { + harness.SetLocalToTree(Brush(harness, "box1"), float4x4.Translate(new float3(0, 0.25f, 0))); + var mismatches = ExactJudge.FindMismatches(harness, 100, out _); + Assert.That(mismatches.Any(m => m.problem.Contains("not the brush's current plane")), Is.True, + "a brush moved without an update, and the judge did not notice: " + + (mismatches.Count == 0 ? "nothing reported" : string.Join("; ", mismatches.Select(m => m.Format(""))))); + } + } + } +} diff --git a/Core/Tests/Contents/ExactInputTests.cs.meta b/Core/Tests/Contents/ExactInputTests.cs.meta new file mode 100644 index 00000000..34c5f9fd --- /dev/null +++ b/Core/Tests/Contents/ExactInputTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e2e587181fd4540971ae2490f72b0d3 diff --git a/Core/Tests/Contents/ExactJudge.cs b/Core/Tests/Contents/ExactJudge.cs new file mode 100644 index 00000000..f289c5fc --- /dev/null +++ b/Core/Tests/Contents/ExactJudge.cs @@ -0,0 +1,1557 @@ +using System; +using System.Collections.Generic; +using Unity.Mathematics; +using Unity.Collections; +using BigInteger = System.Numerics.BigInteger; + +namespace Chisel.Core.Tests +{ + static class ExactJudge + { + + // a x + b y + c z + w = 0, the solid on its negative side + struct Plane + { + public BigInteger a, b, c, w; + public BigInteger Normal(int axis) => axis == 0 ? a : (axis == 1 ? b : c); + } + + // (X/W, Y/W, Z/W), always with W > 0 + struct Point + { + public BigInteger X, Y, Z, W; + public BigInteger Coordinate(int axis) => axis == 0 ? X : (axis == 1 ? Y : Z); + } + + // A point of one plane in that plane's (u, v) projection: (U/W, V/W), W > 0 + struct Point2 + { + public BigInteger U, V, W; + } + + // num / den with den > 0 + struct Rational + { + public BigInteger num, den; + public double Approximate => (double)num / (double)den; + } + + static int Compare(in Rational x, in Rational y) => (x.num * y.den - y.num * x.den).Sign; + + static BigInteger Big(Chisel.Core.Int128 value) => ((BigInteger)(long)value.hi << 64) + value.lo; + + static Plane ToPlane(in ExactPlane plane) => new Plane { a = plane.a, b = plane.b, c = plane.c, w = plane.w }; + + static Point ToPoint(in ExactVertex vertex) => MakePoint(Big(vertex.X), Big(vertex.Y), Big(vertex.Z), Big(vertex.W)); + + static Point MakePoint(BigInteger X, BigInteger Y, BigInteger Z, BigInteger W) + { + if (W.Sign < 0) + return new Point { X = -X, Y = -Y, Z = -Z, W = -W }; + return new Point { X = X, Y = Y, Z = Z, W = W }; + } + + static int Side(in Plane plane, in Point point) => (plane.a * point.X + plane.b * point.Y + plane.c * point.Z + plane.w * point.W).Sign; + + static bool SamePoint(in Point p, in Point q) => p.X * q.W == q.X * p.W && p.Y * q.W == q.Y * p.W && p.Z * q.W == q.Z * p.W; + + static BigInteger Det3(BigInteger a0, BigInteger a1, BigInteger a2, + BigInteger b0, BigInteger b1, BigInteger b2, + BigInteger c0, BigInteger c1, BigInteger c2) + { + return a0 * (b1 * c2 - b2 * c1) - a1 * (b0 * c2 - b2 * c0) + a2 * (b0 * c1 - b1 * c0); + } + + // Where three planes meet, by Cramer's rule on N x = -w; false when they do not meet in one point. + static bool Intersect(in Plane p, in Plane q, in Plane r, out Point point) + { + point = default; + var determinant = Det3(p.a, p.b, p.c, q.a, q.b, q.c, r.a, r.b, r.c); + if (determinant.IsZero) + return false; + var X = Det3(-p.w, p.b, p.c, -q.w, q.b, q.c, -r.w, r.b, r.c); + var Y = Det3(p.a, -p.w, p.c, q.a, -q.w, q.c, r.a, -r.w, r.c); + var Z = Det3(p.a, p.b, -p.w, q.a, q.b, -q.w, r.a, r.b, -r.w); + point = MakePoint(X, Y, Z, determinant); + return true; + } + + // A plane as a set: the same key for every positive or negative multiple of it. `orientation` is +1 when the plane + // is a positive multiple of its key. + struct PlaneKey : IEquatable + { + public BigInteger a, b, c, w; + public bool Equals(PlaneKey other) => a == other.a && b == other.b && c == other.c && w == other.w; + public override bool Equals(object obj) => obj is PlaneKey other && Equals(other); + public override int GetHashCode() => ((a.GetHashCode() * 31 + b.GetHashCode()) * 31 + c.GetHashCode()) * 31 + w.GetHashCode(); + public Plane AsPlane => new Plane { a = a, b = b, c = c, w = w }; + } + + static PlaneKey KeyOf(in Plane plane, out int orientation) + { + var divisor = BigInteger.GreatestCommonDivisor(BigInteger.GreatestCommonDivisor(plane.a, plane.b), + BigInteger.GreatestCommonDivisor(plane.c, plane.w)); + orientation = plane.a.Sign != 0 ? plane.a.Sign : (plane.b.Sign != 0 ? plane.b.Sign : plane.c.Sign); + var scale = divisor * orientation; + return new PlaneKey { a = plane.a / scale, b = plane.b / scale, c = plane.c / scale, w = plane.w / scale }; + } + + // The largest normal component (ties to the lowest axis), and the two axes a plane is projected onto. + static void Axes(in Plane plane, out int dominant, out int u, out int v) + { + var ax = BigInteger.Abs(plane.a); var ay = BigInteger.Abs(plane.b); var az = BigInteger.Abs(plane.c); + dominant = (ax >= ay && ax >= az) ? 0 : (ay >= az ? 1 : 2); + u = (dominant + 1) % 3; + v = (dominant + 2) % 3; + } + + static Point2 Project(in Point point, int u, int v) => new Point2 { U = point.Coordinate(u), V = point.Coordinate(v), W = point.W }; + + // +1 counter-clockwise in (u, v), -1 clockwise, 0 collinear + static int Orientation(in Point2 p, in Point2 q, in Point2 r) + { + return Det3(p.U, p.V, p.W, q.U, q.V, q.W, r.U, r.V, r.W).Sign; + } + + + // A corner of a convex polygon on a plane; the edge from it to the next corner lies on `edge`. + struct Corner + { + public Point point; + public Plane edge; + } + + // Far beyond anything an exact plane reaches (2^24 from the origin): a brush the CSG accepts lies well inside. + static readonly BigInteger kWorld = BigInteger.One << 26; + + static Plane AxisBound(int axis, int sign) + { + var plane = new Plane { w = -kWorld }; + if (axis == 0) plane.a = sign; else if (axis == 1) plane.b = sign; else plane.c = sign; + return plane; + } + + // The plane within a square far larger than the world, counter-clockwise in its (u, v) projection. + static List WorldSquare(in Plane plane, int u, int v) + { + var edges = new[] { AxisBound(v, -1), AxisBound(u, +1), AxisBound(v, +1), AxisBound(u, -1) }; + var polygon = new List(4); + for (int i = 0; i < 4; i++) + { + if (!Intersect(plane, edges[(i + 3) % 4], edges[i], out var corner)) + throw new InvalidOperationException("ExactJudge: a plane parallel to its own projection axes"); + polygon.Add(new Corner { point = corner, edge = edges[i] }); + } + return polygon; + } + + // The part of a convex polygon on `face` where `plane` is not positive. + static List Clip(in Plane face, List polygon, in Plane plane) + { + int n = polygon.Count; + if (n == 0) + return polygon; + var sides = new int[n]; + bool anyOutside = false, anyInside = false; + for (int i = 0; i < n; i++) + { + sides[i] = Side(plane, polygon[i].point); + anyOutside |= sides[i] > 0; + anyInside |= sides[i] < 0; + } + if (!anyOutside) + return polygon; + if (!anyInside) + return new List(); + var result = new List(n + 1); + for (int i = 0; i < n; i++) + { + int j = (i + 1) % n; + var corner = polygon[i]; + if (sides[i] <= 0) + { + if (sides[j] > 0) + { + // leaving: along the clipping plane from where the edge crosses it (or from this corner, on it) + if (sides[i] < 0) + { + result.Add(corner); + result.Add(new Corner { point = Cut(face, corner.edge, plane), edge = plane }); + } else + result.Add(new Corner { point = corner.point, edge = plane }); + } else + result.Add(corner); + } else if (sides[j] < 0) + { + // entering: along this edge again from where it crosses the clipping plane + result.Add(new Corner { point = Cut(face, corner.edge, plane), edge = corner.edge }); + } + } + return result; + } + + static Point Cut(in Plane face, in Plane edge, in Plane plane) + { + if (!Intersect(face, edge, plane, out var point)) + throw new InvalidOperationException("ExactJudge: an edge crossing a plane it is parallel to"); + return point; + } + + // Whether a convex polygon encloses any area. + static bool HasArea(List polygon, int u, int v) + { + if (polygon.Count < 3) + return false; + var first = Project(polygon[0].point, u, v); + for (int i = 1; i + 1 < polygon.Count; i++) + { + if (Orientation(first, Project(polygon[i].point, u, v), Project(polygon[i + 1].point, u, v)) != 0) + return true; + } + return false; + } + + + sealed class Brush + { + public ContentsSceneNode node; + public int index; // in the oracle's brush order + public ExactCSGCapture.Brush capture; + public Plane[] planes; + public PlaneKey[] keys; + public int[] orientations; // of each plane against its key + public bool flat; // a plane and its opposite: no volume + public bool hasVolume; + public readonly List corners = new List(); + public double3 min, max; // around the corners, widened far beyond the rounding of doubles + } + + static Brush Prepare(ContentsSceneNode node, int index, ExactCSGCapture.Brush capture) + { + int count = capture.planes.Length; + var brush = new Brush + { + node = node, index = index, capture = capture, + planes = new Plane[count], keys = new PlaneKey[count], orientations = new int[count] + }; + for (int p = 0; p < count; p++) + { + brush.planes[p] = ToPlane(capture.planes[p]); + if (brush.planes[p].a.IsZero && brush.planes[p].b.IsZero && brush.planes[p].c.IsZero) + throw new InvalidOperationException($"ExactJudge: {Name(brush)} has an invalid plane {p}"); + brush.keys[p] = KeyOf(brush.planes[p], out brush.orientations[p]); + } + for (int p = 0; p < count && !brush.flat; p++) + for (int q = p + 1; q < count && !brush.flat; q++) + brush.flat = brush.keys[p].Equals(brush.keys[q]) && brush.orientations[p] != brush.orientations[q]; + if (brush.flat) + return brush; + + for (int f = 0; f < count; f++) + { + Axes(brush.planes[f], out _, out int u, out int v); + var polygon = Section(brush, brush.planes[f], brush.keys[f], u, v); + if (!HasArea(polygon, u, v)) + continue; + brush.hasVolume = true; + foreach (var corner in polygon) + { + bool known = false; + foreach (var existing in brush.corners) + if (SamePoint(existing, corner.point)) { known = true; break; } + if (!known) + brush.corners.Add(corner.point); + } + } + + // A box around the corners, only ever used to skip what cannot meet: widened by far more than the doubles round. + brush.min = new double3(double.PositiveInfinity); + brush.max = new double3(double.NegativeInfinity); + foreach (var corner in brush.corners) + { + double w = (double)corner.W; + var position = new double3((double)corner.X / w, (double)corner.Y / w, (double)corner.Z / w); + brush.min = math.min(brush.min, position); + brush.max = math.max(brush.max, position); + } + for (int axis = 0; axis < 3; axis++) + { + brush.min[axis] -= Slack(brush.min[axis]); + brush.max[axis] += Slack(brush.max[axis]); + } + return brush; + } + + // The brush cut by the plane with the given key: the polygon of the plane inside every plane of the brush that is + // not that plane itself. + static List Section(Brush brush, in Plane plane, in PlaneKey key, int u, int v) + { + var polygon = WorldSquare(plane, u, v); + for (int p = 0; p < brush.planes.Length && polygon.Count > 0; p++) + { + if (brush.keys[p].Equals(key)) + continue; + polygon = Clip(plane, polygon, brush.planes[p]); + } + return polygon; + } + + static string Name(Brush brush) => brush.node.name ?? ("brush" + brush.index); + + + const int kCrossing = 0, kBelow = 1, kAbove = 2; + + // A convex polygon on the judged plane: a brush's cross-section, or an output triangle + sealed class Shape + { + public Brush brush; + public bool triangle; + public int relation; // cross-sections: kCrossing, kBelow (the brush has a face on the plane and lies + // behind it), kAbove (in front of it) + public int face; // the brush's plane on the judged plane (kBelow / kAbove), or the triangle's face + public int facing; // triangles: +1 drawn along the judged plane's normal, -1 against it + public Point2[] corners; + public bool active; + } + + sealed class Edge + { + public Shape shape; + public Point2 a, b; + public BigInteger A, B, C; // the line: A U + B V + C W = 0 + public double minU, maxU, minV, maxV; + } + + struct Crossing + { + public Rational v; + public Shape shape; + } + + sealed class Context + { + public ContentsOracle oracle; + public List brushes; + public Dictionary byNode; + public List mismatches = new List(); + public int maxReported; + public int intervals; + // Rounded exact triangles with two corners at one float position, which the weld at the very end drops (OutputWeld) + public int droppedByTheWeld; + public int flippedByTheWeld; + // Needles the weld across the model split into the triangle across them, of another brush (OutputModelWeld) + public int splitByTheWeld; + // Pairs of facing needles the weld across the model dropped (OutputModelWeld) + public int pairedByTheWeld; + // Pairs of identical triangles facing opposite ways the weld across the model dropped (OutputModelWeld) + public int cancelledByTheWeld; + // Triangles the weld's last step across the model moved or dropped, collapsing edges shorter than a millimetre + public int collapsedByTheWeld; + public bool[] inMinus, inPlus; + public bool Full => mismatches.Count >= maxReported; + + // Every brush with a face on a plane, and which of its planes that is (the first, if it has the plane twice) + public readonly Dictionary> onPlane = new Dictionary>(); + + // The brushes that can hold an interval's probes, and whether they do: nothing else can (ContentsOracle.IsFilled) + public readonly List candidates = new List(); + public Func insideMinus, insidePlus; + + // Per plane, how much work the judgement may take (WorkBudget); a plane that would take more is not judged but + // listed in `unexamined`, with its numbers, so it is never mistaken for one that passed + public WorkBudget budget = WorkBudget.Unlimited; + public readonly List unexamined = new List(); + // spent over the whole judgement so far, and on the last plane (for the progress output) + public long spentPairs, spentSlab, lastPairs, lastSlab; + } + + internal struct WorkBudget + { + public long edgePairs; + public long slabCrossings; + public long totalEdgePairs; + public long totalSlabCrossings; + public static WorkBudget Unlimited => new WorkBudget { edgePairs = long.MaxValue, slabCrossings = long.MaxValue, + totalEdgePairs = long.MaxValue, totalSlabCrossings = long.MaxValue }; + } + + internal static readonly WorkBudget SweepBudget = new WorkBudget { edgePairs = 2_000_000_000L, slabCrossings = 50_000_000L, + totalEdgePairs = 6_000_000_000L, totalSlabCrossings = 150_000_000L }; + + // What this plane may still take: its own budget, or what is left of the judgement's, whichever is less + static long Allowed(long perPlane, long total, long spent) => total == long.MaxValue ? perPlane : Math.Min(perPlane, Math.Max(0, total - spent)); + + static void JudgePlane(Context context, in PlaneKey key, List triangles) + { + var plane = key.AsPlane; + Axes(plane, out int dominant, out int u, out int v); + + // The faces on the plane, and the triangles drawn on it: nothing can be expected or drawn anywhere else on it, so + // they are the region the plane is judged in. + var shapes = new List(triangles); + var region = new List(); + foreach (var triangle in triangles) + region.Add(BoxOf(triangle.corners)); + var onPlane = new HashSet(); + if (context.onPlane.TryGetValue(key, out var faces)) + { + foreach (var (brush, face) in faces) + { + onPlane.Add(brush); + var shape = SectionShape(brush, plane, key, u, v, brush.orientations[face] > 0 ? kBelow : kAbove, face); + if (shape == null) + continue; + shapes.Add(shape); + region.Add(BoxOf(shape.corners)); + } + } + if (region.Count == 0) + return; + var union = region[0]; + foreach (var box in region) + union = Union(union, box); + + double pa = (double)plane.a, pb = (double)plane.b, pc = (double)plane.c, pw = (double)plane.w; + foreach (var brush in context.brushes) + { + if (!brush.hasVolume || onPlane.Contains(brush)) + continue; + if (!MayStraddle(brush, pa, pb, pc, pw)) + continue; + var reach = new Box2 { minU = brush.min[u], maxU = brush.max[u], minV = brush.min[v], maxV = brush.max[v] }; + if (!Overlaps(reach, union)) + continue; + if (region.Count <= kRegionBoxes) + { + bool meets = false; + foreach (var box in region) + if (Overlaps(reach, box)) { meets = true; break; } + if (!meets) + continue; + } + + // a brush entirely on one side of the plane does not meet it + bool below = false, above = false; + foreach (var corner in brush.corners) + { + int side = Side(plane, corner); + below |= side < 0; + above |= side > 0; + } + if (!below || !above) + continue; + var crossing = SectionShape(brush, plane, key, u, v, kCrossing, -1); + if (crossing != null) + shapes.Add(crossing); + } + + // Their edges, and every u-coordinate where the overlay has a vertex + var edges = new List(); + var vertexU = new List(); + foreach (var shape in shapes) + { + for (int i = 0; i < shape.corners.Length; i++) + { + var a = shape.corners[i]; + var b = shape.corners[(i + 1) % shape.corners.Length]; + vertexU.Add(new Rational { num = a.U, den = a.W }); + var edge = new Edge + { + shape = shape, a = a, b = b, + A = a.V * b.W - a.W * b.V, + B = a.W * b.U - a.U * b.W, + C = a.U * b.V - a.V * b.U + }; + if (edge.B.IsZero) + continue; // runs along u = constant: never crosses a slab, and its ends are vertices already + double ua = (double)a.U / (double)a.W, ub = (double)b.U / (double)b.W; + double va = (double)a.V / (double)a.W, vb = (double)b.V / (double)b.W; + edge.minU = Math.Min(ua, ub); edge.maxU = Math.Max(ua, ub); + edge.minV = Math.Min(va, vb); edge.maxV = Math.Max(va, vb); + edges.Add(edge); + } + } + // in the order they start in u, so each edge only meets the ones that start before it ends + edges.Sort((x, y) => x.minU.CompareTo(y.minU)); + long pairs = 0; + long allowedPairs = Allowed(context.budget.edgePairs, context.budget.totalEdgePairs, context.spentPairs); + context.lastPairs = context.lastSlab = 0; + for (int i = 0; i < edges.Count; i++) + { + var e1 = edges[i]; + var end = e1.maxU + Slack(e1.maxU); + for (int j = i + 1; j < edges.Count; j++) + { + var e2 = edges[j]; + if (end < e2.minU) + break; + if (++pairs > allowedPairs) + { + context.spentPairs += pairs; + NotExamined(context, key, triangles.Count, shapes.Count, edges.Count, + allowedPairs < context.budget.edgePairs ? "the judgement's edge pairs are spent" + : $"more than {context.budget.edgePairs} edge pairs to compare"); + return; + } + // a box test with room far beyond any rounding of the doubles: only pairs that cannot meet are skipped + if (e1.maxV + Slack(e1.maxV) < e2.minV || e2.maxV + Slack(e2.maxV) < e1.minV) + continue; + int o1 = Orientation(e1.a, e1.b, e2.a), o2 = Orientation(e1.a, e1.b, e2.b); + if (o1 == 0 || o2 == 0 || o1 == o2) + continue; // no crossing, or one that is at a vertex already + int o3 = Orientation(e2.a, e2.b, e1.a), o4 = Orientation(e2.a, e2.b, e1.b); + if (o3 == 0 || o4 == 0 || o3 == o4) + continue; + // the lines' intersection, (A1, B1, C1) x (A2, B2, C2) + var U = e1.B * e2.C - e1.C * e2.B; + var W = e1.A * e2.B - e1.B * e2.A; + if (W.Sign < 0) { U = -U; W = -W; } + vertexU.Add(new Rational { num = U, den = W }); + } + } + vertexU.Sort((x, y) => Compare(x, y)); + context.spentPairs += pairs; + context.lastPairs = pairs; + + // The slab lines' work, counted before it is done: for every slab, the edges whose u-range reaches its line + long allowedSlab = Allowed(context.budget.slabCrossings, context.budget.totalSlabCrossings, context.spentSlab); + if (allowedSlab != long.MaxValue) + { + var starts = new double[edges.Count]; + var ends = new double[edges.Count]; + for (int i = 0; i < edges.Count; i++) { starts[i] = edges[i].minU; ends[i] = edges[i].maxU; } + Array.Sort(starts); + Array.Sort(ends); + long slabWork = 0; + for (int s = 0; s + 1 < vertexU.Count; s++) + { + double lo = vertexU[s].Approximate, hi = vertexU[s + 1].Approximate; + if (!(hi > lo)) + continue; // one boundary, or two a double cannot tell apart: an estimate needs no more + double mid = (lo + hi) * 0.5; + slabWork += CountAtMost(starts, mid) - CountBelow(ends, mid); + if (slabWork > allowedSlab) + { + NotExamined(context, key, triangles.Count, shapes.Count, edges.Count, + allowedSlab < context.budget.slabCrossings + ? $"the judgement's slab crossings are spent ({context.spentSlab} of {context.budget.totalSlabCrossings})" + : $"more than {context.budget.slabCrossings} slab crossings (edges a slab line reaches) on {vertexU.Count} slab boundaries"); + return; + } + } + context.spentSlab += slabWork; + context.lastSlab = slabWork; + } + + // One line through the middle of every slab, left to right, with the edges whose u-range can reach it + var crossings = new List(); + var active = new List(); + var reaching = new List(); + int nextEdge = 0; + for (int s = 0; s + 1 < vertexU.Count && !context.Full; s++) + { + var lo = vertexU[s]; + var hi = vertexU[s + 1]; + if (Compare(lo, hi) >= 0) + continue; + Between(lo, hi, out var k, out int e); + var pow = BigInteger.One << e; + double m = (double)k / Math.Pow(2, e); + + while (nextEdge < edges.Count && edges[nextEdge].minU - Slack(edges[nextEdge].minU) <= m) + reaching.Add(edges[nextEdge++]); + reaching.RemoveAll(edge => m > edge.maxU + Slack(edge.maxU)); + + crossings.Clear(); + foreach (var edge in reaching) + { + int sa = (edge.a.U * pow - k * edge.a.W).Sign; + int sb = (edge.b.U * pow - k * edge.b.W).Sign; + if (sa == sb || sa == 0 || sb == 0) + continue; + var num = -(edge.A * k + edge.C * pow); + var den = edge.B * pow; + if (den.Sign < 0) { num = -num; den = -den; } + crossings.Add(new Crossing { v = new Rational { num = num, den = den }, shape = edge.shape }); + } + crossings.Sort((x, y) => Compare(x.v, y.v)); + + active.Clear(); + for (int i = 0; i < crossings.Count && !context.Full;) + { + int j = i; + for (; j < crossings.Count && Compare(crossings[j].v, crossings[i].v) == 0; j++) + { + var shape = crossings[j].shape; + shape.active = !shape.active; + if (shape.active) active.Add(shape); else active.Remove(shape); + } + if (j < crossings.Count && active.Count > 0) + JudgeInterval(context, plane, dominant, u, v, m, crossings[i].v, crossings[j].v, active); + i = j; + } + if (active.Count != 0 && !context.Full) + throw new InvalidOperationException("ExactJudge: a convex shape crossed a line an odd number of times"); + foreach (var shape in active) + shape.active = false; + } + } + + // A plane over the work budget: listed, with what made it heavy, instead of judged + static void NotExamined(Context context, in PlaneKey key, int triangles, int shapes, int edges, string reason) + { + var plane = key.AsPlane; + string first = null; + if (context.onPlane.TryGetValue(key, out var faces) && faces.Count > 0) + first = faces[0].brush.node.name; + context.unexamined.Add($"plane ({(double)plane.a:G6}, {(double)plane.b:G6}, {(double)plane.c:G6}, {(double)plane.w:G6})" + + (first != null ? $" of {first} and {faces.Count - 1} more brush(es)" : "") + + $": {triangles} triangle(s), {shapes} shape(s), {edges} edge(s), {reason} - NOT judged"); + } + + // How many of the sorted values are <= x, and < x + static long CountAtMost(double[] sorted, double x) + { + int lo = 0, hi = sorted.Length; + while (lo < hi) { int mid = (lo + hi) >> 1; if (sorted[mid] <= x) lo = mid + 1; else hi = mid; } + return lo; + } + + static long CountBelow(double[] sorted, double x) + { + int lo = 0, hi = sorted.Length; + while (lo < hi) { int mid = (lo + hi) >> 1; if (sorted[mid] < x) lo = mid + 1; else hi = mid; } + return lo; + } + + // A brush's cross-section with the plane as a shape, or null where it has no area there. + static Shape SectionShape(Brush brush, in Plane plane, in PlaneKey key, int u, int v, int relation, int face) + { + var polygon = Section(brush, plane, key, u, v); + if (!HasArea(polygon, u, v)) + return null; + var corners = new Point2[polygon.Count]; + for (int i = 0; i < polygon.Count; i++) + corners[i] = Project(polygon[i].point, u, v); + return new Shape { brush = brush, relation = relation, face = face, corners = corners }; + } + + // Up to this many boxes a brush is tested against each box of a plane's region, past it only against their union. + const int kRegionBoxes = 64; + + // A box in a plane's (u, v) projection, in doubles, only ever used to skip what cannot meet. + struct Box2 + { + public double minU, maxU, minV, maxV; + } + + static Box2 BoxOf(Point2[] corners) + { + var box = new Box2 { minU = double.PositiveInfinity, maxU = double.NegativeInfinity, + minV = double.PositiveInfinity, maxV = double.NegativeInfinity }; + foreach (var corner in corners) + { + double w = (double)corner.W, cu = (double)corner.U / w, cv = (double)corner.V / w; + box.minU = Math.Min(box.minU, cu); box.maxU = Math.Max(box.maxU, cu); + box.minV = Math.Min(box.minV, cv); box.maxV = Math.Max(box.maxV, cv); + } + box.minU -= Slack(box.minU); box.maxU += Slack(box.maxU); + box.minV -= Slack(box.minV); box.maxV += Slack(box.maxV); + return box; + } + + static Box2 Union(in Box2 x, in Box2 y) + { + return new Box2 { minU = Math.Min(x.minU, y.minU), maxU = Math.Max(x.maxU, y.maxU), + minV = Math.Min(x.minV, y.minV), maxV = Math.Max(x.maxV, y.maxV) }; + } + + static bool Overlaps(in Box2 x, in Box2 y) => x.minU <= y.maxU && y.minU <= x.maxU && x.minV <= y.maxV && y.minV <= x.maxV; + + // Whether a brush's box can reach both sides of the plane a x + b y + c z + w = 0: false only when the whole box + // lies on one side by far more than the doubles round. + static bool MayStraddle(Brush brush, double a, double b, double c, double w) + { + double low = w, high = w, magnitude = Math.Abs(w); + Accumulate(a, brush.min.x, brush.max.x, ref low, ref high, ref magnitude); + Accumulate(b, brush.min.y, brush.max.y, ref low, ref high, ref magnitude); + Accumulate(c, brush.min.z, brush.max.z, ref low, ref high, ref magnitude); + var slack = Slack(magnitude); + return low <= slack && high >= -slack; + } + + static void Accumulate(double n, double min, double max, ref double low, ref double high, ref double magnitude) + { + double p = n * min, q = n * max; + low += Math.Min(p, q); + high += Math.Max(p, q); + magnitude += Math.Max(Math.Abs(p), Math.Abs(q)); + } + + // Room for the rounding of an exact value converted to double (a few units in the last place): far more than + // enough, so a box test that uses it never skips anything that could meet. + static double Slack(double value) => 1e-9 * (Math.Abs(value) + 1); + + // The simplest k / 2^e strictly between lo and hi. + static void Between(in Rational lo, in Rational hi, out BigInteger k, out int e) + { + for (e = 0; ; e++) + { + var pow = BigInteger.One << e; + k = FloorDivide(lo.num * pow, lo.den) + 1; + if ((k * hi.den - hi.num * pow).Sign < 0) + return; + } + } + + static BigInteger FloorDivide(BigInteger numerator, BigInteger denominator) + { + var quotient = BigInteger.DivRem(numerator, denominator, out var remainder); + return remainder.Sign < 0 ? quotient - 1 : quotient; + } + + static void JudgeInterval(Context context, in Plane plane, int dominant, int u, int v, double m, + in Rational vLow, in Rational vHigh, List active) + { + context.intervals++; + + // Which brushes hold the points an infinitely small step behind (minus) and in front of (plus) the plane here; + // no other brush can + context.candidates.Clear(); + foreach (var shape in active) + { + if (shape.triangle) + continue; + int b = shape.brush.index; + context.inMinus[b] = shape.relation != kAbove; + context.inPlus[b] = shape.relation != kBelow; + context.candidates.Add(shape.brush.node); + } + + var expected = new List(); + foreach (var shape in active) + { + if (shape.triangle || shape.relation == kCrossing) + continue; + var judging = shape.brush.node.contents; + bool minusFilled = context.oracle.IsFilled(context.candidates, context.insideMinus, judging); + bool plusFilled = context.oracle.IsFilled(context.candidates, context.insidePlus, judging); + // the brush's face points along the plane's normal when the brush lies behind it + bool behind = shape.relation == kBelow ? minusFilled : plusFilled; + bool front = shape.relation == kBelow ? plusFilled : minusFilled; + int facing; + if (behind && !front) facing = 1; // drawn facing out of its brush + else if (!behind && front) facing = -1; // drawn facing into it + else continue; + if (shape.relation == kAbove) + facing = -facing; + expected.Add(new ContentsComparison.Face { brush = shape.brush.node, facing = facing }); + } + + var found = new List(); + string problem = null; + foreach (var shape in active) + { + if (!shape.triangle) + continue; + var face = new ContentsComparison.Face { brush = shape.brush.node, facing = shape.facing }; + if (found.Contains(face)) + problem ??= $"the output draws {face} more than once here"; + found.Add(face); + } + problem ??= ContentsComparison.Compare(expected, found); + + foreach (var shape in active) + { + if (!shape.triangle) + context.inMinus[shape.brush.index] = context.inPlus[shape.brush.index] = false; + } + if (problem == null) + return; + + // Named after a face the oracle expects here, else a triangle drawn here + Shape named = null; + foreach (var shape in active) + if (!shape.triangle && shape.relation != kCrossing) { named = shape; break; } + if (named == null) + foreach (var shape in active) + if (shape.triangle) { named = shape; break; } + named ??= active[0]; + + double pv = (vLow.Approximate + vHigh.Approximate) * 0.5; + var point = new double3(); + point[u] = m; + point[v] = pv; + point[dominant] = -((double)plane.Normal(u) * m + (double)plane.Normal(v) * pv + (double)plane.w) / (double)plane.Normal(dominant); + context.mismatches.Add(new ContentsComparison.Mismatch + { + brush = named.brush.node, + brushIndex = named.brush.index, + face = named.face, + point = (float3)point, + problem = problem, + nearest = 0 + }); + } + + + // Where a long judgement spends its time, plane by plane: set by a caller that needs to see it + // (SampleSceneHarvestTests.ProfileModel), null otherwise. + internal static Action progress; + + internal static List FindMismatches(ContentsTreeHarness harness, int maxReported, out int judged, + bool checkMesh = true) + { + return FindMismatches(harness, maxReported, out judged, out _, WorkBudget.Unlimited, checkMesh); + } + + // The same, with a work budget per plane: a plane over it is listed in `unexamined` instead of judged + internal static List FindMismatches(ContentsTreeHarness harness, int maxReported, out int judged, + out List unexamined, WorkBudget budget, + bool checkMesh = true) + { + var oracle = new ContentsOracle(harness.Scene); + var context = new Context + { + oracle = oracle, + brushes = new List(), + byNode = new Dictionary(), + maxReported = maxReported, + budget = budget + }; + unexamined = context.unexamined; + var sceneBrushes = oracle.Brushes; + for (int b = 0; b < sceneBrushes.Count; b++) + { + var node = sceneBrushes[b]; + var nodeID = harness.NodeIDOf(node); + if (!ExactCSGCapture.Brushes.TryGetValue(nodeID, out var capture) || capture.planes == null) + throw new InvalidOperationException($"ExactJudge: nothing was captured for {node.name ?? ("brush" + b)}; " + + "does the harness run with the exact CSG on?"); + var brush = Prepare(node, b, capture); + context.brushes.Add(brush); + context.byNode[node] = brush; + } + context.inMinus = new bool[context.brushes.Count]; + context.inPlus = new bool[context.brushes.Count]; + context.insideMinus = node => context.inMinus[context.byNode[node].index]; + context.insidePlus = node => context.inPlus[context.byNode[node].index]; + foreach (var brush in context.brushes) + { + if (!brush.hasVolume) + continue; + for (int p = 0; p < brush.planes.Length; p++) + { + if (!context.onPlane.TryGetValue(brush.keys[p], out var list)) + context.onPlane[brush.keys[p]] = list = new List<(Brush, int)>(); + if (list.Count == 0 || list[list.Count - 1].brush != brush) + list.Add((brush, p)); + } + } + + // The input as the CSG saw it: the planes it used are the ones the brush has now, and a brush takes part exactly + // when those planes enclose volume + foreach (var brush in context.brushes) + { + CheckInput(context, harness, brush); + bool coreSaysVolume = brush.capture.cornerCount > 0; + if (brush.hasVolume != coreSaysVolume) + Report(context, brush, -1, float3.zero, + $"the exact CSG counted {brush.capture.cornerCount} corners, the judge finds {(brush.hasVolume ? "volume" : "none")}"); + } + + // The triangles, on the planes of the faces they belong to + var trianglesByPlane = new Dictionary>(); + var planeOf = new Dictionary(); + foreach (var brush in context.brushes) + { + for (int p = 0; p < brush.planes.Length; p++) + { + if (!brush.hasVolume) + break; + if (!trianglesByPlane.ContainsKey(brush.keys[p])) + trianglesByPlane[brush.keys[p]] = new List(); + } + if (brush.capture.parts == null) + continue; + foreach (var part in brush.capture.parts) + CheckPart(context, brush, part, trianglesByPlane); + } + + int planeIndex = 0; + foreach (var pair in trianglesByPlane) + { + if (context.Full) + break; + var clock = progress != null ? System.Diagnostics.Stopwatch.StartNew() : null; + if (progress != null) + progress($"plane {planeIndex} of {trianglesByPlane.Count}: {pair.Value.Count} triangle(s), " + + $"{(context.onPlane.TryGetValue(pair.Key, out var faces) ? faces.Count : 0)} face(s) on it, {context.intervals} intervals so far"); + JudgePlane(context, pair.Key, pair.Value); + if (progress != null) + progress($"plane {planeIndex}: {clock.Elapsed.TotalSeconds.ToString("0.000", System.Globalization.CultureInfo.InvariantCulture)} s, " + + $"{context.lastPairs} edge pairs, {context.lastSlab} slab crossings, {context.intervals} intervals so far"); + planeIndex++; + } + + if (!context.Full) + CheckAttributes(context, harness); + if (checkMesh && !context.Full) + CheckMesh(context, harness); + judged = context.intervals; + LastDroppedByTheWeld = context.droppedByTheWeld; + LastFlippedByTheWeld = context.flippedByTheWeld; + LastSplitByTheWeld = context.splitByTheWeld; + LastPairedByTheWeld = context.pairedByTheWeld; + LastCancelledByTheWeld = context.cancelledByTheWeld; + LastCollapsedByTheWeld = context.collapsedByTheWeld; + return context.mismatches; + } + + // How many needles of the last judgement's rounded exact triangles the weld's edge flip took away (with those the weld + // across the model split into a triangle of the same brush: the mesh shows the same) + internal static int LastFlippedByTheWeld { get; private set; } + + // How many needles of the last judgement's rounded exact triangles the weld across the model split into the triangle + // across them, of another brush, or into a half of a triangle it split before + internal static int LastSplitByTheWeld { get; private set; } + + // How many pairs of facing needles of the last judgement's rounded exact triangles the weld across the model dropped + internal static int LastPairedByTheWeld { get; private set; } + + // How many pairs of identical triangles facing opposite ways, of the last judgement's rounded exact triangles or the + // halves of a split, the weld across the model dropped + internal static int LastCancelledByTheWeld { get; private set; } + + // How many of the last judgement's rounded exact triangles the weld's last step across the model moved or dropped + // (edges shorter than OutputModelWeld.kCollapseLength collapsed) + internal static int LastCollapsedByTheWeld { get; private set; } + + // How many of the last judgement's rounded exact triangles the weld at the very end drops, so the mesh never has them + internal static int LastDroppedByTheWeld { get; private set; } + + static void CheckInput(Context context, ContentsTreeHarness harness, Brush brush) + { + var treeBrush = harness.TreeBrushOf(brush.node); + if (!treeBrush.Valid) + { + Report(context, brush, -1, float3.zero, "the brush has no tree brush in the harness"); + return; + } + var expected = new List(); + var instance = treeBrush.BrushMesh; + var meshRef = instance.Valid ? BrushMeshManager.GetBrushMeshBlob(instance) : default; + if (meshRef.IsCreated) + { + var m = treeBrush.NodeToTreeSpaceMatrix; + var affine = ExactAffine.Create(m.c0.x, m.c0.y, m.c0.z, m.c1.x, m.c1.y, m.c1.z, + m.c2.x, m.c2.y, m.c2.z, m.c3.x, m.c3.y, m.c3.z); + ref var mesh = ref meshRef.Value; + for (int p = 0; p < mesh.localPlaneCount; p++) + { + var plane = default(ExactPlane); + if (affine.IsValid) + { + var local = mesh.localPlanes[p]; + affine.TransformPlane(local.x, local.y, local.z, local.w, out double nx, out double ny, out double nz, out double d); + plane = ExactPlane.Quantize(nx, ny, nz, d); + } + expected.Add(plane); + } + } + var captured = brush.capture.planes; + if (captured.Length != expected.Count) + { + Report(context, brush, -1, float3.zero, + $"the exact CSG used {captured.Length} planes, the brush's current mesh has {expected.Count}"); + return; + } + for (int p = 0; p < captured.Length; p++) + { + var c = captured[p]; var e = expected[p]; + if (c.a == e.a && c.b == e.b && c.c == e.c && c.w == e.w) + continue; + Report(context, brush, p, float3.zero, + $"the exact CSG used plane {p} = ({c.a}, {c.b}, {c.c}, {c.w}), which is not the brush's current plane ({e.a}, {e.b}, {e.c}, {e.w})"); + return; + } + } + + static void CheckPart(Context context, Brush brush, ExactCSGCapture.Part part, Dictionary> trianglesByPlane) + { + if (part.surface < 0 || part.surface >= brush.planes.Length) + { + Report(context, brush, part.surface, float3.zero, $"the exact CSG drew on face {part.surface}, which the brush does not have"); + return; + } + var plane = brush.planes[part.surface]; + var key = brush.keys[part.surface]; + Axes(plane, out int dominant, out int u, out int v); + var points = new Point[part.vertices.Length]; + for (int i = 0; i < points.Length; i++) + { + points[i] = ToPoint(part.vertices[i]); + if (Side(plane, points[i]) != 0) + Report(context, brush, part.surface, part.positions[i], "an output vertex does not lie on its face's plane"); + var rounded = new float3(NearestFloat(points[i].X, points[i].W), NearestFloat(points[i].Y, points[i].W), + NearestFloat(points[i].Z, points[i].W)); + if (math.any(math.asint(rounded) != math.asint(part.positions[i]))) + Report(context, brush, part.surface, part.positions[i], + $"an output vertex was emitted as {Format(part.positions[i])}, the nearest float is {Format(rounded)}"); + } + + // the plane's normal along the dominant axis decides which way round (u, v) is seen from it + int handedness = plane.Normal(dominant).Sign; + int drawnAlong = (part.category == CategoryIndex.SelfAligned ? 1 : -1) * brush.orientations[part.surface]; + if (!trianglesByPlane.TryGetValue(key, out var list)) + trianglesByPlane[key] = list = new List(); + for (int t = 0; t + 2 < part.triangles.Length; t += 3) + { + var corners = new[] + { + Project(points[part.triangles[t]], u, v), + Project(points[part.triangles[t + 1]], u, v), + Project(points[part.triangles[t + 2]], u, v) + }; + if (Orientation(corners[0], corners[1], corners[2]) * handedness <= 0) + { + Report(context, brush, part.surface, part.positions[part.triangles[t]], + "an output triangle is not counter-clockwise seen from its face's normal, or has no area"); + continue; + } + list.Add(new Shape { brush = brush, triangle = true, face = part.surface, facing = drawnAlong, corners = corners }); + } + } + + static void CheckAttributes(Context context, ContentsTreeHarness harness) + { + foreach (var triangle in harness.Triangles) + { + string what = null; + if (!Finite(triangle.a) || !Finite(triangle.b) || !Finite(triangle.c)) what = "position"; + else if (!Finite(triangle.normalA) || !Finite(triangle.normalB) || !Finite(triangle.normalC)) what = "normal"; + else if (!Finite(triangle.tangentA) || !Finite(triangle.tangentB) || !Finite(triangle.tangentC)) what = "tangent"; + else if (!Finite(triangle.uvA) || !Finite(triangle.uvB) || !Finite(triangle.uvC)) what = "texture coordinate"; + else if (!Finite(triangle.lightmapA) || !Finite(triangle.lightmapB) || !Finite(triangle.lightmapC)) what = "lightmap coordinate"; + if (what == null) + continue; + var node = harness.NodeOf(triangle.brushID); + if (node == null || !context.byNode.TryGetValue(node, out var brush)) + continue; + Report(context, brush, -1, triangle.Center, + $"the mesh has a vertex whose {what} is not a number, on the triangle {Format(triangle.a)} {Format(triangle.b)} {Format(triangle.c)}"); + if (context.Full) + return; + } + } + + static bool Finite(float2 v) => math.all(math.isfinite(v)); + static bool Finite(float3 v) => math.all(math.isfinite(v)); + static bool Finite(float4 v) => math.all(math.isfinite(v)); + + static void CheckMesh(Context context, ContentsTreeHarness harness) + { + var difference = new Dictionary<(int, int, int, int, int, int, int, int, int, int), int>(); + foreach (var brush in context.brushes) + { + if (brush.capture.parts == null) + continue; + foreach (var part in brush.capture.parts) + { + for (int t = 0; t + 2 < part.triangles.Length; t += 3) + { + var a = part.positions[part.triangles[t]]; + var b = part.positions[part.triangles[t + 1]]; + var c = part.positions[part.triangles[t + 2]]; + if (ContentsTreeHarness.TwoCornersAtOnePosition(a, b, c)) + { + context.droppedByTheWeld++; + continue; + } + var key = part.category == CategoryIndex.SelfAligned ? TriangleKey(brush.index, a, b, c) : TriangleKey(brush.index, a, c, b); + difference.TryGetValue(key, out int count); + difference[key] = count + 1; + } + } + } + foreach (var triangle in harness.Triangles) + { + var node = harness.NodeOf(triangle.brushID); + if (node == null || !context.byNode.TryGetValue(node, out var brush)) + continue; + var key = TriangleKey(brush.index, triangle.a, triangle.b, triangle.c); + difference.TryGetValue(key, out int count); + difference[key] = count - 1; + } + var collapse = new CollapseMap(context, harness); + context.flippedByTheWeld = CancelFlippedNeedles(difference, collapse); + CancelModelWeld(difference, out context.splitByTheWeld, out context.pairedByTheWeld, out context.cancelledByTheWeld); + context.collapsedByTheWeld = CancelCollapse(difference, collapse); + foreach (var pair in difference) + { + if (pair.Value == 0) + continue; + var k = pair.Key; + var a = math.asfloat(new int3(k.Item2, k.Item3, k.Item4)); + var b = math.asfloat(new int3(k.Item5, k.Item6, k.Item7)); + var c = math.asfloat(new int3(k.Item8, k.Item9, k.Item10)); + var what = pair.Value > 0 ? "the exact CSG made a triangle the mesh does not have" : "the mesh has a triangle the exact CSG did not make"; + Report(context, context.brushes[k.Item1], -1, (a + b + c) / 3, + $"{what} ({Math.Abs(pair.Value)}x): {Format(a)} {Format(b)} {Format(c)}"); + if (context.Full) + return; + } + } + + // The needles the weld flipped away (OutputWeld.FlipNeedles): an expected needle and the triangle across its long edge + // become the flip's two triangles. Chains of needles along one edge flip into a fan, of which only the last triangles remain. + static int CancelFlippedNeedles(Dictionary<(int, int, int, int, int, int, int, int, int, int), int> difference, CollapseMap collapse = null) + { + float3 Corner((int, int, int, int, int, int, int, int, int, int) k, int i) => + math.asfloat(i == 0 ? new int3(k.Item2, k.Item3, k.Item4) : (i == 1 ? new int3(k.Item5, k.Item6, k.Item7) : new int3(k.Item8, k.Item9, k.Item10))); + var cancelled = 0; + for (var progress = true; progress; ) + { + progress = false; + // the expected triangles the mesh lacks, by brush and directed edge; the expected needles, by brush and long edge + var byEdge = new Dictionary<(int, int3, int3), List<(int, int, int, int, int, int, int, int, int, int)>>(); + var needles = new Dictionary<(int, int3, int3), int>(); + foreach (var pair in difference) + { + if (pair.Value <= 0) + continue; + var k = pair.Key; + var corners = new[] { new int3(k.Item2, k.Item3, k.Item4), new int3(k.Item5, k.Item6, k.Item7), new int3(k.Item8, k.Item9, k.Item10) }; + for (int e = 0; e < 3; e++) + { + var edge = (k.Item1, corners[e], corners[(e + 1) % 3]); + if (!byEdge.TryGetValue(edge, out var list)) + byEdge[edge] = list = new List<(int, int, int, int, int, int, int, int, int, int)>(); + list.Add(k); + } + if (ExactFloatLine.AsNeedle(Corner(k, 0), Corner(k, 1), Corner(k, 2), out var nu, out _, out var nw)) + { + var longEdge = (k.Item1, math.asint(nu), math.asint(nw)); + needles.TryGetValue(longEdge, out var count); + needles[longEdge] = count + 1; + } + } + // accounted for: in the mesh as the weld's last step left it, left without area by that step, or flipped again + bool Accounted(int brush, float3 x, float3 y, float3 z) + { + var mx = collapse != null ? collapse.Map(x) : x; var my = collapse != null ? collapse.Map(y) : y; var mz = collapse != null ? collapse.Map(z) : z; + if (collapse != null && CollapseMap.WithoutArea(mx, my, mz)) + return true; + if (difference.TryGetValue(TriangleKey(brush, mx, my, mz), out var there) && there < 0) + return true; + // the triangle across a needle's long edge runs u -> w along it (as the neighbours are found) + var ix = math.asint(x); var iy = math.asint(y); var iz = math.asint(z); + return needles.ContainsKey((brush, ix, iy)) || needles.ContainsKey((brush, iy, iz)) || needles.ContainsKey((brush, iz, ix)); + } + foreach (var needle in new List<(int, int, int, int, int, int, int, int, int, int)>(difference.Keys)) + { + if (difference[needle] <= 0) + continue; + if (!ExactFloatLine.AsNeedle(Corner(needle, 0), Corner(needle, 1), Corner(needle, 2), out var u, out var m, out var w)) + continue; + if (!byEdge.TryGetValue((needle.Item1, math.asint(u), math.asint(w)), out var neighbours)) + continue; + foreach (var neighbour in neighbours) + { + if (neighbour.Equals(needle) || difference[neighbour] <= 0) + continue; + var n = new[] { Corner(neighbour, 0), Corner(neighbour, 1), Corner(neighbour, 2) }; + var d = !n[0].Equals(u) && !n[0].Equals(w) ? n[0] : (!n[1].Equals(u) && !n[1].Equals(w) ? n[1] : n[2]); + if (ExactFloatLine.OnOneLine(u, w, d)) + continue; + if (!Accounted(needle.Item1, u, m, d) || !Accounted(needle.Item1, m, w, d)) + continue; + difference[needle] -= 1; + difference[neighbour] -= 1; + var first = TriangleKey(needle.Item1, u, m, d); + var second = TriangleKey(needle.Item1, m, w, d); + difference.TryGetValue(first, out var firstCount); + difference[first] = firstCount + 1; + difference.TryGetValue(second, out var secondCount); + difference[second] = secondCount + 1; + cancelled++; + progress = true; + break; + } + } + } + return cancelled; + } + + // Where OutputModelWeld.CollapseShortEdges put each corner (ContentsTreeHarness.CollapseMoves), checked: no further than + // kCollapseLength, onto a corner that stays, and each cluster onto its least corner (by x, y, z) + sealed class CollapseMap + { + readonly Dictionary moved = new(); + + public CollapseMap(Context context, ContentsTreeHarness harness) + { + var clusters = new Dictionary>(); + foreach (var move in harness.CollapseMoves) + { + var from = move.c0 + 0.0f; var to = move.c1 + 0.0f; + if (!(math.distance(from, to) <= OutputModelWeld.kCollapseLength)) + Problem(context, from, $"the weld moved a corner {math.distance(from, to)} away, further than it collapses ({OutputModelWeld.kCollapseLength}): {Format(from)} -> {Format(to)}"); + moved[from] = to; + if (!clusters.TryGetValue(to, out var list)) + clusters[to] = list = new List(); + list.Add(from); + } + foreach (var pair in clusters) + { + if (moved.ContainsKey(pair.Key)) + Problem(context, pair.Key, $"the weld moved corners onto {Format(pair.Key)}, which it moved too"); + float3 lo = pair.Key, hi = pair.Key; + foreach (var from in pair.Value) + { + lo = math.min(lo, from); hi = math.max(hi, from); + if (Less(from, pair.Key)) + Problem(context, from, $"the weld moved {Format(from)} onto {Format(pair.Key)}, which is not its cluster's least corner"); + } + if (!(math.length(hi - lo) <= OutputModelWeld.kCollapseLength)) + Problem(context, pair.Key, $"the weld collapsed corners {math.length(hi - lo)} across onto {Format(pair.Key)}, further than it collapses"); + } + } + + static void Problem(Context context, float3 at, string problem) + { + if (context.brushes.Count > 0) + Report(context, context.brushes[0], -1, at, problem); + } + + public float3 Map(float3 p) => moved.TryGetValue(p + 0.0f, out var to) ? to : p; + + // as the weld decides it + public static bool WithoutArea(float3 a, float3 b, float3 c) => OutputWeld.WithoutArea(a, b, c); + } + + // The weld's last step on what is left: an expected triangle the mesh lacks, its corners where the collapse put them + // (CollapseMap), is left without area (the weld dropped it) or is one the mesh has. How many it accounts for. + static int CancelCollapse(Dictionary<(int, int, int, int, int, int, int, int, int, int), int> difference, CollapseMap collapse) + { + float3 Corner((int, int, int, int, int, int, int, int, int, int) k, int i) => + math.asfloat(i == 0 ? new int3(k.Item2, k.Item3, k.Item4) : (i == 1 ? new int3(k.Item5, k.Item6, k.Item7) : new int3(k.Item8, k.Item9, k.Item10))); + var accounted = 0; + foreach (var k in new List<(int, int, int, int, int, int, int, int, int, int)>(difference.Keys)) + { + var count = difference[k]; + if (count <= 0) + continue; + var a = Corner(k, 0); var b = Corner(k, 1); var c = Corner(k, 2); + var pa = collapse.Map(a); var pb = collapse.Map(b); var pc = collapse.Map(c); + if (pa.Equals(a) && pb.Equals(b) && pc.Equals(c)) + continue; + if (CollapseMap.WithoutArea(pa, pb, pc)) + { + difference[k] = 0; + accounted += count; + continue; + } + var key = TriangleKey(k.Item1, pa, pb, pc); + difference.TryGetValue(key, out var there); + if (there >= 0) + continue; + var n = Math.Min(count, -there); + difference[k] = count - n; + difference[key] = there + n; + accounted += n; + } + return accounted; + } + + static bool Less(float3 x, float3 y) => x.x != y.x ? x.x < y.x : (x.y != y.y ? x.y < y.y : x.z < y.z); + + static void CancelModelWeld(Dictionary<(int, int, int, int, int, int, int, int, int, int), int> difference, out int split, out int paired, out int cancelled) + { + split = 0; + paired = 0; + cancelled = 0; + for (var progress = true; progress; ) + { + progress = CancelOppositesOnce(difference, ref cancelled); + progress |= CancelModelWeldOnce(difference, ref split, ref paired); + } + } + + // The expected triangles the mesh lacks, by their positions alone (the key's brush -1) + static Dictionary<(int, int, int, int, int, int, int, int, int, int), List<(int, int, int, int, int, int, int, int, int, int)>> LackedByPositions( + Dictionary<(int, int, int, int, int, int, int, int, int, int), int> difference) + { + var byPositions = new Dictionary<(int, int, int, int, int, int, int, int, int, int), List<(int, int, int, int, int, int, int, int, int, int)>>(); + foreach (var pair in difference) + { + if (pair.Value <= 0) + continue; + var k = pair.Key; + var positions = TriangleKey(-1, math.asfloat(new int3(k.Item2, k.Item3, k.Item4)), math.asfloat(new int3(k.Item5, k.Item6, k.Item7)), + math.asfloat(new int3(k.Item8, k.Item9, k.Item10))); + if (!byPositions.TryGetValue(positions, out var list)) + byPositions[positions] = list = new List<(int, int, int, int, int, int, int, int, int, int)>(); + list.Add(k); + } + return byPositions; + } + + // The key with its corners wound the other way round, by positions alone + static (int, int, int, int, int, int, int, int, int, int) Reversed((int, int, int, int, int, int, int, int, int, int) k) + { + return TriangleKey(-1, math.asfloat(new int3(k.Item2, k.Item3, k.Item4)), math.asfloat(new int3(k.Item8, k.Item9, k.Item10)), + math.asfloat(new int3(k.Item5, k.Item6, k.Item7))); + } + + // Expected triangles the mesh lacks, two at a time, at the same three positions wound the other way round. Needles are + // left to CancelModelWeldOnce: two facing needles are a pair there. + static bool CancelOppositesOnce(Dictionary<(int, int, int, int, int, int, int, int, int, int), int> difference, ref int cancelled) + { + var byPositions = LackedByPositions(difference); + var progress = false; + foreach (var k in new List<(int, int, int, int, int, int, int, int, int, int)>(difference.Keys)) + { + if (difference[k] <= 0) + continue; + if (ExactFloatLine.OnOneLine(math.asfloat(new int3(k.Item2, k.Item3, k.Item4)), math.asfloat(new int3(k.Item5, k.Item6, k.Item7)), + math.asfloat(new int3(k.Item8, k.Item9, k.Item10)))) + continue; + if (!byPositions.TryGetValue(Reversed(k), out var twins)) + continue; + foreach (var twin in twins) + { + if (twin.Equals(k) || difference[twin] <= 0 || difference[k] <= 0) + continue; + difference[k] -= 1; + difference[twin] -= 1; + cancelled++; + progress = true; + break; + } + } + return progress; + } + + // A triangle the mesh does not have, which the weld cancelled with an expected triangle at its positions wound the + // other way round + static bool CancelledWithATwin(Dictionary<(int, int, int, int, int, int, int, int, int, int), int> difference, + Dictionary<(int, int, int, int, int, int, int, int, int, int), List<(int, int, int, int, int, int, int, int, int, int)>> lacked, + (int, int, int, int, int, int, int, int, int, int) key) + { + difference.TryGetValue(key, out var count); + return count == 0 && lacked.ContainsKey(Reversed(key)); + } + + // A half the mesh lacks is accounted for when the weld cancelled it with a twin, or split it again: a needle the mesh + // lacks lies across one of its edges, and both halves of that split are accounted for in turn + static bool Accounted(Dictionary<(int, int, int, int, int, int, int, int, int, int), int> difference, + Dictionary<(int, int, int, int, int, int, int, int, int, int), List<(int, int, int, int, int, int, int, int, int, int)>> lacked, + Dictionary<(int3, int3), List<((int, int, int, int, int, int, int, int, int, int) needle, float3 m)>> needles, + (int, int, int, int, int, int, int, int, int, int) key, HashSet<(int, int, int, int, int, int, int, int, int, int)> used) + { + difference.TryGetValue(key, out var count); + if (count < 0 || CancelledWithATwin(difference, lacked, key)) + return true; + var corners = new[] { math.asfloat(new int3(key.Item2, key.Item3, key.Item4)), math.asfloat(new int3(key.Item5, key.Item6, key.Item7)), + math.asfloat(new int3(key.Item8, key.Item9, key.Item10)) }; + for (int e = 0; e < 3; e++) + { + var p = corners[e]; + var q = corners[(e + 1) % 3]; + var d = corners[(e + 2) % 3]; + if (!needles.TryGetValue((math.asint(p), math.asint(q)), out var across) || ExactFloatLine.OnOneLine(p, q, d)) + continue; + foreach (var (needle, m) in across) + { + if (used.Contains(needle) || difference[needle] <= 0) + continue; + used.Add(needle); + var both = Accounted(difference, lacked, needles, TriangleKey(key.Item1, p, m, d), used) && + Accounted(difference, lacked, needles, TriangleKey(key.Item1, m, q, d), used); + used.Remove(needle); + if (both) + return true; + } + } + return false; + } + + static bool CancelModelWeldOnce(Dictionary<(int, int, int, int, int, int, int, int, int, int), int> difference, ref int split, ref int paired) + { + var progress = false; + var lacked = LackedByPositions(difference); + // the needles the mesh lacks, by the edge u -> w of the triangle that would lie across them + var needles = new Dictionary<(int3, int3), List<((int, int, int, int, int, int, int, int, int, int) needle, float3 m)>>(); + foreach (var pair in difference) + { + if (pair.Value <= 0) + continue; + var k = pair.Key; + if (!ExactFloatLine.AsNeedle(math.asfloat(new int3(k.Item2, k.Item3, k.Item4)), math.asfloat(new int3(k.Item5, k.Item6, k.Item7)), + math.asfloat(new int3(k.Item8, k.Item9, k.Item10)), out var nu, out var nm, out var nw)) + continue; + if (!needles.TryGetValue((math.asint(nu), math.asint(nw)), out var list)) + needles[(math.asint(nu), math.asint(nw))] = list = new List<((int, int, int, int, int, int, int, int, int, int) needle, float3 m)>(); + list.Add((k, nm)); + } + // the expected triangles the mesh lacks, by directed edge, of any brush + var byEdge = new Dictionary<(int3, int3), List<(int, int, int, int, int, int, int, int, int, int)>>(); + foreach (var pair in difference) + { + if (pair.Value <= 0) + continue; + var k = pair.Key; + var corners = new[] { new int3(k.Item2, k.Item3, k.Item4), new int3(k.Item5, k.Item6, k.Item7), new int3(k.Item8, k.Item9, k.Item10) }; + for (int e = 0; e < 3; e++) + { + var edge = (corners[e], corners[(e + 1) % 3]); + if (!byEdge.TryGetValue(edge, out var list)) + byEdge[edge] = list = new List<(int, int, int, int, int, int, int, int, int, int)>(); + list.Add(k); + } + } + foreach (var needle in new List<(int, int, int, int, int, int, int, int, int, int)>(difference.Keys)) + { + if (difference[needle] <= 0) + continue; + var a = math.asfloat(new int3(needle.Item2, needle.Item3, needle.Item4)); + var b = math.asfloat(new int3(needle.Item5, needle.Item6, needle.Item7)); + var c = math.asfloat(new int3(needle.Item8, needle.Item9, needle.Item10)); + if (!ExactFloatLine.AsNeedle(a, b, c, out var u, out var m, out var w)) + continue; + if (!byEdge.TryGetValue((math.asint(u), math.asint(w)), out var neighbours)) + continue; + foreach (var neighbour in neighbours) + { + if (neighbour.Equals(needle) || difference[needle] <= 0 || difference[neighbour] <= 0) + continue; + var n = new[] { math.asfloat(new int3(neighbour.Item2, neighbour.Item3, neighbour.Item4)), + math.asfloat(new int3(neighbour.Item5, neighbour.Item6, neighbour.Item7)), + math.asfloat(new int3(neighbour.Item8, neighbour.Item9, neighbour.Item10)) }; + var d = !n[0].Equals(u) && !n[0].Equals(w) ? n[0] : (!n[1].Equals(u) && !n[1].Equals(w) ? n[1] : n[2]); + if (!ExactFloatLine.OnOneLine(u, w, d)) + { + var first = TriangleKey(neighbour.Item1, u, m, d); + var second = TriangleKey(neighbour.Item1, m, w, d); + difference.TryGetValue(first, out var firstCount); + difference.TryGetValue(second, out var secondCount); + // the mesh has neither half, and the weld cancelled neither with a twin: not this split + if (firstCount >= 0 && secondCount >= 0) + { + var used = new HashSet<(int, int, int, int, int, int, int, int, int, int)> { needle }; + if (!Accounted(difference, lacked, needles, first, used) && !Accounted(difference, lacked, needles, second, used)) + continue; + } + difference[needle] -= 1; + difference[neighbour] -= 1; + difference[first] = firstCount + 1; + difference[second] = secondCount + 1; + split++; + progress = true; + break; + } + if (math.all(math.asint(d) == math.asint(m))) + { + difference[needle] -= 1; + difference[neighbour] -= 1; + paired++; + progress = true; + break; + } + } + } + return progress; + } + + // A brush's triangle as its vertices' float bits, turned to the smallest of its three rotations, so the same + // triangle wound the same way always gives the same key. + static (int, int, int, int, int, int, int, int, int, int) TriangleKey(int brush, float3 a, float3 b, float3 c) + { + var ia = math.asint(a); var ib = math.asint(b); var ic = math.asint(c); + var r0 = (ia, ib, ic); + var r1 = (ib, ic, ia); + var r2 = (ic, ia, ib); + var best = r0; + if (Less(r1, best)) best = r1; + if (Less(r2, best)) best = r2; + return (brush, best.Item1.x, best.Item1.y, best.Item1.z, best.Item2.x, best.Item2.y, best.Item2.z, + best.Item3.x, best.Item3.y, best.Item3.z); + } + + static bool Less((int3, int3, int3) p, (int3, int3, int3) q) + { + int c = Order(p.Item1, q.Item1); + if (c == 0) c = Order(p.Item2, q.Item2); + if (c == 0) c = Order(p.Item3, q.Item3); + return c < 0; + } + + static int Order(int3 p, int3 q) => p.x != q.x ? p.x.CompareTo(q.x) : (p.y != q.y ? p.y.CompareTo(q.y) : p.z.CompareTo(q.z)); + + static void Report(Context context, Brush brush, int face, float3 point, string problem) + { + if (context.Full) + return; + context.mismatches.Add(new ContentsComparison.Mismatch + { + brush = brush.node, brushIndex = brush.index, face = face, point = point, problem = problem, nearest = 0 + }); + } + + static string Format(float3 p) => $"({p.x:R}, {p.y:R}, {p.z:R})"; + + + // The float nearest to num / den (den > 0), ties to the even one: what the CSG must emit, found independently. + static float NearestFloat(BigInteger num, BigInteger den) + { + if (num.IsZero) + return 0f; + float estimate = (float)((double)num / (double)den); + float best = estimate; + foreach (var candidate in new[] { NextFloat(estimate, false), NextFloat(estimate, true) }) + { + int closer = CompareDistance(num, den, candidate, best); + if (closer < 0 || (closer == 0 && (math.asint(candidate) & 1) == 0)) + best = candidate; + } + return best; + } + + // sign(|num/den - x| - |num/den - y|) + static int CompareDistance(BigInteger num, BigInteger den, float x, float y) + { + ToRational(x, out var xn, out var xd); + ToRational(y, out var yn, out var yd); + // |num/den - xn/xd| = |num xd - xn den| / (den xd) + var dx = BigInteger.Abs(num * xd - xn * den) * yd; + var dy = BigInteger.Abs(num * yd - yn * den) * xd; + return dx.CompareTo(dy); + } + + // A finite float as an exact fraction with a power-of-two denominator. + static void ToRational(float value, out BigInteger num, out BigInteger den) + { + int bits = math.asint(value); + int exponent = (bits >> 23) & 0xFF; + long mantissa = bits & 0x7FFFFF; + if (exponent == 0) exponent = 1; else mantissa |= 1L << 23; + int power = exponent - 127 - 23; + num = new BigInteger(bits < 0 ? -mantissa : mantissa); + den = BigInteger.One; + if (power >= 0) num <<= power; else den <<= -power; + } + + static float NextFloat(float value, bool up) + { + int bits = math.asint(value); + if (value == 0f) + bits = up ? 1 : unchecked((int)0x80000001); + else if ((value > 0) == up) + bits++; + else + bits--; + return math.asfloat(bits); + } + } +} diff --git a/Core/Tests/Contents/ExactJudge.cs.meta b/Core/Tests/Contents/ExactJudge.cs.meta new file mode 100644 index 00000000..2925235b --- /dev/null +++ b/Core/Tests/Contents/ExactJudge.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cab1b0d45c4f49bca968831f39f94668 diff --git a/Core/Tests/Contents/ExactJudgeCaseTests.cs b/Core/Tests/Contents/ExactJudgeCaseTests.cs new file mode 100644 index 00000000..c0519b80 --- /dev/null +++ b/Core/Tests/Contents/ExactJudgeCaseTests.cs @@ -0,0 +1,209 @@ +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + // Cases where the exact judge disagreed with the mesh, each shrunk to the brushes that still show it + [TestFixture] + [Category("Contents")] + public class ExactJudgeCaseTests + { + // A long needle on the slabs' underside (y -0.915) that the exact CSG doesn't make + static ContentsScene SlabsUnderside() + { + return new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-0.707092941f, -1.85503231E-05f, 0.7071206f, -2.13823056f), + new float4(0.8660391f, 2.87726834E-05f, -0.4999763f, -3.34780431f), + new float4(-0.153352857f, -0.967751443f, -0.199850127f, -0.663077056f), + new float4(0.303984284f, 0.8664019f, 0.396158129f, -0.8297414f), + new float4(-0.562849164f, 0.380996168f, -0.733513951f, -0.301146239f), + new float4(0.561914563f, -0.384699732f, 0.732296467f, -0.301130861f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(35.52286f, -1.53559029f, -7.76104164f, 1f)), name: "brush 1") + .WithMesh(new float3[] { new float3(-1.68482745f, -0.694722235f, 1.3390758f), new float3(-1.29386628f, 0.62059027f, 1.73005617f), new float3(-1.67129755f, 0.9255834f, 1.35264766f), new float3(-2.10266876f, -0.542225659f, 0.9212548f), new float3(2.97385287f, 0.6205925f, -1.54470038f), new float3(2.69743443f, -0.694728136f, -2.02357721f), new float3(2.40199232f, -0.5422313f, -2.53532147f), new float3(2.70698142f, 0.9255865f, -2.0069468f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-0.8659878f, -6.975805E-05f, 0.5000652f, -2.13821f), + new float4(0.9659447f, -5.04837635E-06f, -0.258748651f, -3.347808f), + new float4(-0.09641658f, -0.9677414f, -0.232767045f, -0.663069665f), + new float4(0.191077828f, 0.8664214f, 0.461305976f, -0.82972765f), + new float4(-0.353829622f, 0.3809212f, -0.854226947f, -0.301125467f), + new float4(0.353239059f, -0.3846927f, 0.85278f, -0.301110864f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(40.1942749f, -1.53559029f, -10.6419287f, 1f)), name: "brush 2") + .WithMesh(new float3[] { new float3(-1.97396231f, -0.694722235f, 0.857356966f), new float3(-1.697556f, 0.6205903f, 1.33620691f), new float3(-1.96451163f, 0.9255823f, 0.8739491f), new float3(-2.26942229f, -0.542222261f, 0.3457154f), new float3(3.272328f, 0.620611668f, -0.722411931f), new float3(3.12925434f, -0.6947206f, -1.25649989f), new float3(2.976392f, -0.542232335f, -1.82715952f), new float3(3.134219f, 0.9255817f, -1.237998f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 0f, -0.7625f), + new float4(0.366494775f, 0f, 0.9304201f, -0.813386738f), + new float4(0.2591407f, -0.7071409f, 0.6578737f, -0.251601249f), + new float4(-0.259165049f, 0.707115948f, -0.657891f, -0.395412f), + new float4(0.9696436f, 0f, -0.2445226f, -2.928341f), + new float4(-0.8521493f, -0.000409213768f, 0.523298562f, -2.2675972f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(40.9734764f, -0.1525f, -9.525695f, 1f)), name: "brush 3") + .WithMesh(new float3[] { new float3(-2.35859f, -0.7625f, 0.4919074f), new float3(-2.84483671f, -0.7625f, -0.299906284f), new float3(2.421249f, -0.7625f, -2.37439179f), new float3(2.646853f, -0.7625f, -1.47976887f), new float3(-1.71097183f, 1.37249815f, 1.54817057f), new float3(-1.710618f, 0.457499981f, 1.54803109f), new float3(2.94767284f, 0.457517326f, -0.2868811f), new float3(2.94767284f, 1.372631f, -0.2868811f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 0f, -0.7625f), + new float4(0.608749f, 0f, 0.7933629f, -0.8133965f), + new float4(0.430435836f, -0.707134247f, 0.5609689f, -0.251628131f), + new float4(-0.430420876f, 0.7071393f, -0.560974f, -0.3953991f), + new float4(-0.6300544f, 0.0002936061f, 0.776550949f, -1.86625f), + new float4(0.8521952f, 1.064502E-05f, -0.523224f, -2.46816039f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(36.86753f, -0.1525f, -7.162032f, 1f)), name: "brush 4") + .WithMesh(new float3[] { new float3(1.27345431f, -0.7625f, -2.6431067f), new float3(1.75481343f, -0.7625f, -1.85909843f), new float3(-1.84724009f, -0.7625f, 0.904785633f), new float3(-2.57791686f, -0.7625f, 0.311951131f), new float3(2.39664435f, 0.457499981f, -0.813698649f), new float3(2.39663672f, 1.37243271f, -0.8136927f), new float3(-0.872570336f, 1.37250483f, 1.69477642f), new float3(-0.8727895f, 0.4574848f, 1.69494462f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 1, 4, 7, 2, -1, 5, 0, 3, 6, -1, 6, 3, 2, 7, -1, 0, 5, 4, 1, -1 })); + } + + [Test] + public void TheSlabsUnderside_TheMeshIsWhatTheExactCSGMade() + { + AssertTheMeshIsWhatTheExactCSGMade(SlabsUnderside()); + } + + static void AssertTheMeshIsWhatTheExactCSGMade(ContentsScene scene) + { + if (!CompactHierarchyManager.TreeUpdate.kExactCSG) + Assert.Ignore("judges the exact CSG, which is off"); + using var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + var mismatches = ExactJudge.FindMismatches(harness, 100, out var judged); + Assert.That(judged, Is.GreaterThan(0), "the judge judged nothing"); + Assert.That(mismatches, Is.Empty, string.Join("; ", mismatches.Select(m => m.Format("")))); + } + + // Needles on a face at x -51.24, a corner of which the collapse moves (1 of 2) + static ContentsScene CollapsedCorner1() + { + return new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -1.49111128f), + new float4(0f, -1f, 0f, -0.9488888f), + new float4(1f, 0f, 0f, -0.08472214f), + new float4(-1f, 0f, 0f, -0.0677778646f), + new float4(0f, 0f, 1f, -0.3049998f), + new float4(0f, 0f, -1f, -0.3050002f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-51.1722221f, 4.761389f, -12.2f, 1f)), name: "brush 5") + .WithMesh(new float3[] { new float3(0.08472214f, 1.49111128f, -0.3050002f), new float3(-0.0677778646f, 1.49111128f, -0.3050002f), new float3(-0.0677778646f, 1.49111128f, 0.3049998f), new float3(0.08472214f, 1.49111128f, 0.3049998f), new float3(-0.0677778646f, -0.9488888f, -0.3050002f), new float3(0.08472214f, -0.9488888f, -0.3050002f), new float3(0.08472214f, -0.9488888f, 0.3049998f), new float3(-0.0677778646f, -0.9488888f, 0.3049998f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -1.49111128f), + new float4(0f, -1f, 0f, -0.9488888f), + new float4(1f, 0f, 0f, -0.08472214f), + new float4(-1f, 0f, 0f, -0.0677778646f), + new float4(0f, 0f, 1f, -0.304999322f), + new float4(0f, 0f, -1f, -0.3050007f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-51.1722221f, 4.761389f, -31.72f, 1f)), name: "brush 6") + .WithMesh(new float3[] { new float3(0.08472214f, 1.49111128f, -0.3050007f), new float3(-0.0677778646f, 1.49111128f, -0.3050007f), new float3(-0.0677778646f, 1.49111128f, 0.304999322f), new float3(0.08472214f, 1.49111128f, 0.304999322f), new float3(-0.0677778646f, -0.9488888f, -0.3050007f), new float3(0.08472214f, -0.9488888f, -0.3050007f), new float3(0.08472214f, -0.9488888f, 0.304999322f), new float3(-0.0677778646f, -0.9488888f, 0.304999322f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -1.49111128f), + new float4(0f, -1f, 0f, -0.9488888f), + new float4(1f, 0f, 0f, -0.08472214f), + new float4(-1f, 0f, 0f, -0.0677778646f), + new float4(0f, 0f, 1f, -0.30499953f), + new float4(0f, 0f, -1f, -0.305000454f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-51.1722221f, 4.761389f, -41.48f, 1f)), name: "brush 7") + .WithMesh(new float3[] { new float3(0.08472214f, 1.49111128f, -0.305000454f), new float3(-0.0677778646f, 1.49111128f, -0.305000454f), new float3(-0.0677778646f, 1.49111128f, 0.30499953f), new float3(0.08472214f, 1.49111128f, 0.30499953f), new float3(-0.0677778646f, -0.9488888f, -0.305000454f), new float3(0.08472214f, -0.9488888f, -0.305000454f), new float3(0.08472214f, -0.9488888f, 0.30499953f), new float3(-0.0677778646f, -0.9488888f, 0.30499953f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -0.3408018f), + new float4(-1f, 0f, 0f, -0.609998941f), + new float4(1f, 0f, 0f, -0.6100011f), + new float4(0.707106769f, -0.707106769f, 0f, -0.193048626f), + new float4(0f, 0f, 1f, -16.2666664f), + new float4(0f, 0f, -1f, -13.0133324f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-50.63f, 7.135511f, -28.4666672f, 1f)), name: "brush 8") + .WithMesh(new float3[] { new float3(0.6100011f, 0.3408018f, -13.0133324f), new float3(-0.609998941f, 0.3408018f, -13.0133324f), new float3(-0.609998941f, 0.3408018f, 16.2666664f), new float3(0.6100011f, 0.3408018f, 16.2666664f), new float3(-0.609998941f, -0.8830109f, -13.0133324f), new float3(-0.609998941f, -0.8830109f, 16.2666664f), new float3(0.6100011f, 0.3369891f, -13.0133324f), new float3(0.6100011f, 0.3369891f, 16.2666664f) }, new int[] { 0, 1, 2, 3, -1, 1, 4, 5, 2, -1, 6, 0, 3, 7, -1, 4, 6, 7, 5, -1, 5, 7, 3, 2, -1, 6, 4, 1, 0, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0.705407739f, -0.705407739f, 0.0692811161f, -0.215149924f), + new float4(0f, 1f, 0f, -0.304999977f), + new float4(0f, 0f, -1f, -0.533749759f), + new float4(-0.9039021f, 0f, 0.427739382f, -0.275689542f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-50.6824226f, 7.1675f, -11.66625f, 1f)), name: "brush 9") + .WithMesh(new float3[] { new float3(0.452735126f, 0.304999977f, 1.60125029f), new float3(-0.5575773f, -0.914999962f, -0.533749759f), new float3(0.662422657f, 0.304999977f, -0.533749759f), new float3(-0.5575773f, 0.304999977f, -0.533749759f) }, new int[] { 0, 1, 2, -1, 3, 0, 2, -1, 1, 3, 2, -1, 3, 1, 0, -1 })); + } + + [Test] + public void ACollapsedCorner_1_TheMeshIsWhatTheExactCSGMade() + { + AssertTheMeshIsWhatTheExactCSGMade(CollapsedCorner1()); + } + + // Needles on a face at x -51.24, a corner of which the collapse moves (2 of 2) + static ContentsScene CollapsedCorner2() + { + return new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -1.49111128f), + new float4(0f, -1f, 0f, -0.9488888f), + new float4(1f, 0f, 0f, -0.08472214f), + new float4(-1f, 0f, 0f, -0.0677778646f), + new float4(0f, 0f, 1f, -0.3049998f), + new float4(0f, 0f, -1f, -0.3050002f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-51.1722221f, 4.761389f, -12.2f, 1f)), name: "brush 5") + .WithMesh(new float3[] { new float3(0.08472214f, 1.49111128f, -0.3050002f), new float3(-0.0677778646f, 1.49111128f, -0.3050002f), new float3(-0.0677778646f, 1.49111128f, 0.3049998f), new float3(0.08472214f, 1.49111128f, 0.3049998f), new float3(-0.0677778646f, -0.9488888f, -0.3050002f), new float3(0.08472214f, -0.9488888f, -0.3050002f), new float3(0.08472214f, -0.9488888f, 0.3049998f), new float3(-0.0677778646f, -0.9488888f, 0.3049998f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -1.49111128f), + new float4(0f, -1f, 0f, -0.9488888f), + new float4(1f, 0f, 0f, -0.08472214f), + new float4(-1f, 0f, 0f, -0.0677778646f), + new float4(0f, 0f, 1f, -0.304999083f), + new float4(0f, 0f, -1f, -0.3050009f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-51.1722221f, 4.761389f, -21.96f, 1f)), name: "brush 10") + .WithMesh(new float3[] { new float3(0.08472214f, 1.49111128f, -0.3050009f), new float3(-0.0677778646f, 1.49111128f, -0.3050009f), new float3(-0.0677778646f, 1.49111128f, 0.304999083f), new float3(0.08472214f, 1.49111128f, 0.304999083f), new float3(-0.0677778646f, -0.9488888f, -0.3050009f), new float3(0.08472214f, -0.9488888f, -0.3050009f), new float3(0.08472214f, -0.9488888f, 0.304999083f), new float3(-0.0677778646f, -0.9488888f, 0.304999083f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -1.49111128f), + new float4(0f, -1f, 0f, -0.9488888f), + new float4(1f, 0f, 0f, -0.08472214f), + new float4(-1f, 0f, 0f, -0.0677778646f), + new float4(0f, 0f, 1f, -0.30499953f), + new float4(0f, 0f, -1f, -0.305000454f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-51.1722221f, 4.761389f, -41.48f, 1f)), name: "brush 7") + .WithMesh(new float3[] { new float3(0.08472214f, 1.49111128f, -0.305000454f), new float3(-0.0677778646f, 1.49111128f, -0.305000454f), new float3(-0.0677778646f, 1.49111128f, 0.30499953f), new float3(0.08472214f, 1.49111128f, 0.30499953f), new float3(-0.0677778646f, -0.9488888f, -0.305000454f), new float3(0.08472214f, -0.9488888f, -0.305000454f), new float3(0.08472214f, -0.9488888f, 0.30499953f), new float3(-0.0677778646f, -0.9488888f, 0.30499953f) }, new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 5, 0, 3, 6, -1, 1, 4, 7, 2, -1, 2, 7, 6, 3, -1, 4, 1, 0, 5, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -0.3408018f), + new float4(-1f, 0f, 0f, -0.609998941f), + new float4(1f, 0f, 0f, -0.6100011f), + new float4(0.707106769f, -0.707106769f, 0f, -0.193048626f), + new float4(0f, 0f, 1f, -16.2666664f), + new float4(0f, 0f, -1f, -13.0133324f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-50.63f, 7.135511f, -28.4666672f, 1f)), name: "brush 8") + .WithMesh(new float3[] { new float3(0.6100011f, 0.3408018f, -13.0133324f), new float3(-0.609998941f, 0.3408018f, -13.0133324f), new float3(-0.609998941f, 0.3408018f, 16.2666664f), new float3(0.6100011f, 0.3408018f, 16.2666664f), new float3(-0.609998941f, -0.8830109f, -13.0133324f), new float3(-0.609998941f, -0.8830109f, 16.2666664f), new float3(0.6100011f, 0.3369891f, -13.0133324f), new float3(0.6100011f, 0.3369891f, 16.2666664f) }, new int[] { 0, 1, 2, 3, -1, 1, 4, 5, 2, -1, 6, 0, 3, 7, -1, 4, 6, 7, 5, -1, 5, 7, 3, 2, -1, 6, 4, 1, 0, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0.705407739f, -0.705407739f, 0.0692811161f, -0.215149924f), + new float4(0f, 1f, 0f, -0.304999977f), + new float4(0f, 0f, -1f, -0.533749759f), + new float4(-0.9039021f, 0f, 0.427739382f, -0.275689542f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1f, 0f), new float4(-50.6824226f, 7.1675f, -11.66625f, 1f)), name: "brush 9") + .WithMesh(new float3[] { new float3(0.452735126f, 0.304999977f, 1.60125029f), new float3(-0.5575773f, -0.914999962f, -0.533749759f), new float3(0.662422657f, 0.304999977f, -0.533749759f), new float3(-0.5575773f, 0.304999977f, -0.533749759f) }, new int[] { 0, 1, 2, -1, 3, 0, 2, -1, 1, 3, 2, -1, 3, 1, 0, -1 })); + } + + [Test] + public void ACollapsedCorner_2_TheMeshIsWhatTheExactCSGMade() + { + AssertTheMeshIsWhatTheExactCSGMade(CollapsedCorner2()); + } + } +} diff --git a/Core/Tests/Contents/ExactJudgeCaseTests.cs.meta b/Core/Tests/Contents/ExactJudgeCaseTests.cs.meta new file mode 100644 index 00000000..d1844098 --- /dev/null +++ b/Core/Tests/Contents/ExactJudgeCaseTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: abb09b512eded7f41a50f8de87d26e9c \ No newline at end of file diff --git a/Core/Tests/Contents/ExactJudgeTests.cs b/Core/Tests/Contents/ExactJudgeTests.cs new file mode 100644 index 00000000..042b039b --- /dev/null +++ b/Core/Tests/Contents/ExactJudgeTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class ExactJudgeTests + { + const float kGap = 1f / (1 << 12); + + static ContentsScene GapScene() + { + return new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0, 0, 0), new float3(1, 1, 1)), name: "A")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1 + kGap, 0, 0), new float3(2, 1, 1)), name: "B")); + } + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene) + { + if (!CompactHierarchyManager.TreeUpdate.kExactCSG) + Assert.Ignore("judges the exact CSG, which is off"); + var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + return harness; + } + + static List Judge(ContentsTreeHarness harness) + { + var mismatches = ExactJudge.FindMismatches(harness, 100, out var judged); + Assert.That(judged, Is.GreaterThan(0), "the judge judged nothing"); + return mismatches; + } + + static string Describe(List mismatches) + { + return mismatches.Count == 0 ? "nothing" : string.Join("; ", mismatches.Select(m => m.Format(""))); + } + + static void AssertReports(List mismatches, string fragment, string planted) + { + Assert.That(mismatches.Any(m => m.problem.Contains(fragment)), Is.True, + $"planted {planted}, and the judge did not report \"{fragment}\"; it reported {Describe(mismatches)}"); + } + + static ExactCSGCapture.Brush CaptureOf(ContentsTreeHarness harness, string name) + { + var node = harness.Scene.roots.First(n => n.name == name); + Assert.That(ExactCSGCapture.Brushes.TryGetValue(harness.NodeIDOf(node), out var brush), Is.True, "nothing captured for " + name); + Assert.That(brush.parts, Is.Not.Null, "no triangles captured for " + name); + return brush; + } + + // The part a brush drew on its face whose normal points along `sign` times the axis. + static ExactCSGCapture.Part PartFacing(ExactCSGCapture.Brush brush, int axis, int sign) + { + foreach (var part in brush.parts) + { + var plane = brush.planes[part.surface]; + var normal = new[] { plane.a, plane.b, plane.c }; + bool alongAxis = true; + for (int i = 0; i < 3; i++) + alongAxis &= i == axis ? Math.Sign(normal[i]) == sign : normal[i] == 0; + if (alongAxis) + return part; + } + Assert.Fail($"no part drawn on the face facing {(sign > 0 ? "+" : "-")}{"xyz"[axis]}"); + return null; + } + + [Test] + public void TheControl_TwoBoxesAHairApart_Passes() + { + using (var harness = BuildAndUpdate(GapScene())) + { + var mismatches = Judge(harness); + Assert.That(mismatches, Is.Empty, Describe(mismatches)); + } + } + + [Test] + public void ATriangleMissingFromAFaceAcrossTheGap_IsAHole() + { + using (var harness = BuildAndUpdate(GapScene())) + { + var part = PartFacing(CaptureOf(harness, "B"), 0, -1); + part.triangles = part.triangles.Skip(3).ToArray(); + AssertReports(Judge(harness), "the output draws nothing", "a missing triangle on B's face across the gap"); + } + } + + [Test] + public void AFaceDrawnTheWrongWay_IsReported() + { + using (var harness = BuildAndUpdate(GapScene())) + { + var part = PartFacing(CaptureOf(harness, "A"), 1, 1); + part.category = part.category == CategoryIndex.SelfAligned ? CategoryIndex.SelfReverseAligned : CategoryIndex.SelfAligned; + AssertReports(Judge(harness), "expects one of A+", "A's top drawn facing down"); + } + } + + [Test] + public void ATriangleDrawnTwice_IsReported() + { + using (var harness = BuildAndUpdate(GapScene())) + { + var part = PartFacing(CaptureOf(harness, "A"), 1, 1); + part.triangles = part.triangles.Concat(part.triangles.Take(3)).ToArray(); + AssertReports(Judge(harness), "more than once", "one of A's top triangles twice"); + } + } + + // The strip of floor between the boxes is 2^-12 wide. Stretching A's bottom over it has to be seen. + [Test] + public void AFaceReachingIntoTheGap_IsAnExtraFace() + { + using (var harness = BuildAndUpdate(GapScene())) + { + var part = PartFacing(CaptureOf(harness, "A"), 1, -1); + var gapPlane = ExactPlane.Quantize(1, 0, 0, -(1.0 + kGap)); + int moved = 0; + for (int i = 0; i < part.vertices.Length; i++) + { + var position = part.positions[i]; + if (position.x != 1f) + continue; + part.vertices[i] = ExactVertex.Intersect(gapPlane, ExactPlane.Quantize(0, 1, 0, -position.y), + ExactPlane.Quantize(0, 0, 1, -position.z)); + part.positions[i] = new float3(1 + kGap, position.y, position.z); + moved++; + } + Assert.That(moved, Is.GreaterThan(0), "no vertex of A's bottom lies at x = 1"); + AssertReports(Judge(harness), "the oracle expects nothing here", "A's bottom stretched over the gap"); + } + } + + [Test] + public void AVertexOffItsFacesPlane_IsReported() + { + using (var harness = BuildAndUpdate(GapScene())) + { + var brush = CaptureOf(harness, "A"); + var part = PartFacing(brush, 0, 1); + brush.planes[part.surface].w += 1; + AssertReports(Judge(harness), "does not lie on its face's plane", "A's +x plane moved by 2^-20"); + } + } + + [Test] + public void AVertexRoundedToTheWrongFloat_IsReported() + { + using (var harness = BuildAndUpdate(GapScene())) + { + var part = PartFacing(CaptureOf(harness, "A"), 1, 1); + var position = part.positions[0]; + position.x = math.asfloat(math.asint(position.x) + 1); + part.positions[0] = position; + AssertReports(Judge(harness), "the nearest float is", "a vertex of A's top one float off"); + } + } + + // The concrete pad among its 65 neighbours, 99 m from the origin (ConcretePadTests): the judge has to see its top + // face there too, where brushes rest on it within a float's noise. + [Test] + public void ATriangleMissingFromThePadsTopAmongItsNeighbours_IsAHole() + { + using (var harness = BuildAndUpdate(ConcretePadTests.BuildPadWith(ConcretePadTests.kNeighbourhood))) + { + var control = Judge(harness); + Assert.That(control, Is.Empty, "before anything is planted: " + Describe(control)); + var part = PartFacing(CaptureOf(harness, "pad 4838"), 1, 1); + Assert.That(part.triangles.Length, Is.GreaterThan(3), "the pad's top has one triangle or none"); + part.triangles = part.triangles.Skip(3).ToArray(); + AssertReports(Judge(harness), "the output draws nothing", "a missing triangle on the pad's top"); + } + } + + [Test] + public void ANaNTangentInTheMesh_IsReported() + { + using (var harness = BuildAndUpdate(GapScene())) + { + Assert.That(harness.Triangles.Count, Is.GreaterThan(0), "the mesh is empty"); + var triangle = harness.Triangles[0]; + triangle.tangentB = new float4(float.NaN, float.NaN, float.NaN, -1); + harness.ReplaceTriangleForTest(0, triangle); + AssertReports(Judge(harness), "tangent is not a number", "a NaN tangent in the mesh"); + } + } + + [Test] + public void ATriangleMissingFromTheMesh_IsReported() + { + using (var harness = BuildAndUpdate(GapScene())) + { + Assert.That(harness.Triangles.Count, Is.GreaterThan(0), "the mesh is empty"); + harness.RemoveTriangleForTest(0); + AssertReports(Judge(harness), "the mesh does not have", "a triangle taken out of the mesh"); + } + } + + [Test] + public void APlaneOverTheWorkBudget_IsListedAsNotJudged([Values("edge pairs", "slab crossings", "judgement's edge pairs", + "judgement's slab crossings")] string kind) + { + using (var harness = BuildAndUpdate(GapScene())) + { + ExactJudge.FindMismatches(harness, 100, out int judgedAll, out var none, ExactJudge.WorkBudget.Unlimited); + Assert.That(none, Is.Empty, "no budget, and still planes listed: " + string.Join("; ", none)); + + var budget = ExactJudge.WorkBudget.Unlimited; + switch (kind) + { + case "edge pairs": budget.edgePairs = 0; break; + case "slab crossings": budget.slabCrossings = 0; break; + case "judgement's edge pairs": budget.totalEdgePairs = 0; break; + case "judgement's slab crossings": budget.totalSlabCrossings = 0; break; + } + var mismatches = ExactJudge.FindMismatches(harness, 100, out int judged, out var unexamined, budget); + Assert.That(unexamined, Is.Not.Empty, $"no room for {kind}, and every plane was judged anyway"); + Assert.That(unexamined.All(line => line.Contains("NOT judged") && line.Contains(kind)), Is.True, string.Join("; ", unexamined)); + Assert.That(judged, Is.LessThan(judgedAll), "the planes listed were judged all the same"); + Assert.That(mismatches, Is.Empty, Describe(mismatches)); + } + } + } +} diff --git a/Core/Tests/Contents/ExactJudgeTests.cs.meta b/Core/Tests/Contents/ExactJudgeTests.cs.meta new file mode 100644 index 00000000..91ad1217 --- /dev/null +++ b/Core/Tests/Contents/ExactJudgeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 686ff793f7c44490acb6f9015dd4aff0 diff --git a/Core/Tests/Contents/FlushContactFaceTests.cs b/Core/Tests/Contents/FlushContactFaceTests.cs new file mode 100644 index 00000000..ee2a0334 --- /dev/null +++ b/Core/Tests/Contents/FlushContactFaceTests.cs @@ -0,0 +1,270 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class FlushContactFaceTests + { + static readonly float4[] kSlab = { + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.358750000f), + new(+0.000000000f, -1.000000000f, -0.000000000f, +19.596250000f), + new(-0.866081530f, +0.000124976f, +0.499902759f, -101.658394691f), + new(+0.866003425f, +0.000000000f, -0.500038066f, +100.398516838f), + new(-0.500290606f, -0.000216464f, -0.865857530f, -242.318658487f), + new(+0.500173430f, +0.000000000f, +0.865925251f, +241.699207126f), + }; + static readonly float4[] kBrush1 = { + new(+0.499722453f, +0.000000000f, +0.866185586f, +241.608352902f), + new(+0.865768711f, +0.000000000f, -0.500444342f, +100.646395536f), + new(-0.865768711f, +0.000000000f, +0.500444342f, -100.760834020f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.358750000f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.748750000f), + new(-0.499722453f, -0.000000000f, -0.866185586f, -241.646444038f), + }; + static readonly float4[] kBrush2 = { + new(+0.503088273f, -0.124296091f, +0.855250064f, +243.063754992f), + new(-0.867013944f, +0.000000000f, +0.498283876f, -101.248869699f), + new(+0.864507187f, +0.000000000f, -0.502620457f, +99.962850708f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -19.901250000f), + new(-0.507020127f, -0.000000000f, -0.861934215f, -242.489550973f), + }; + static readonly float4[] kBrush3 = { + new(+0.507020127f, -0.000000000f, +0.861934215f, +242.470423797f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.320625000f), + new(+0.867013944f, +0.000000000f, -0.498283876f, +101.173679285f), + new(-0.867013944f, +0.000000000f, +0.498283876f, -101.248869699f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.901250000f), + new(-0.507020127f, +0.000000000f, -0.861934215f, -242.489550973f), + }; + static readonly float4[] kBrush4 = { + new(+0.499722453f, +0.000000000f, +0.866185586f, +241.608670434f), + new(+0.865768711f, +0.000000000f, -0.500444342f, +100.837676467f), + new(-0.865768711f, +0.000000000f, +0.500444342f, -100.952114951f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.358750000f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.748750000f), + new(-0.499722453f, -0.000000000f, -0.866185586f, -241.646761570f), + }; + static readonly float4[] kBrush5 = { + new(+0.492291101f, -0.124301967f, +0.861509427f, +241.811116845f), + new(-0.867013944f, +0.000000000f, +0.498283876f, -101.440151225f), + new(+0.867013944f, +0.000000000f, -0.498283876f, +101.363308066f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -19.901250000f), + new(-0.496138938f, -0.000000000f, -0.868243142f, -241.227184908f), + }; + static readonly float4[] kBrush6 = { + new(+0.496138938f, -0.000000000f, +0.868243142f, +241.208056814f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.320625000f), + new(+0.867013944f, +0.000000000f, -0.498283876f, +101.363308066f), + new(-0.867013944f, +0.000000000f, +0.498283876f, -101.440151225f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.901250000f), + new(-0.496138938f, +0.000000000f, -0.868243142f, -241.227184908f), + }; + static readonly float4[] kBrush7 = { + new(+0.499722453f, +0.000000000f, +0.866185586f, +241.608987966f), + new(+0.865768711f, +0.000000000f, -0.500444342f, +101.028957398f), + new(-0.865768711f, +0.000000000f, +0.500444342f, -101.143395882f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.358750000f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.748750000f), + new(-0.499722453f, -0.000000000f, -0.866185586f, -241.647079102f), + }; + static readonly float4[] kBrush8 = { + new(+0.492817596f, -0.124455104f, +0.861186242f, +241.872141459f), + new(-0.867013944f, +0.000000000f, +0.498283876f, -101.631432751f), + new(+0.867013944f, +0.000000000f, -0.498283876f, +101.554589591f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -19.901250000f), + new(-0.496138938f, -0.000000000f, -0.868243142f, -241.226712026f), + }; + static readonly float4[] kBrush9 = { + new(+0.496138938f, -0.000000000f, +0.868243142f, +241.207583931f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.320625000f), + new(+0.867013944f, +0.000000000f, -0.498283876f, +101.554589591f), + new(-0.867013944f, +0.000000000f, +0.498283876f, -101.631432751f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.901250000f), + new(-0.496138938f, +0.000000000f, -0.868243142f, -241.226712026f), + }; + static readonly float4[] kBrush10 = { + new(+0.499722453f, +0.000000000f, +0.866185586f, +241.608352902f), + new(+0.865768711f, +0.000000000f, -0.500444342f, +101.218587958f), + new(-0.865768711f, +0.000000000f, +0.500444342f, -101.333026442f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.358750000f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.748750000f), + new(-0.499722453f, -0.000000000f, -0.866185586f, -241.646444038f), + }; + static readonly float4[] kBrush11 = { + new(+0.503078551f, -0.124449057f, +0.855233537f, +243.066945139f), + new(-0.867013944f, +0.000000000f, +0.498283876f, -101.821061532f), + new(+0.867013944f, +0.000000000f, -0.498283876f, +101.745871117f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -19.901250000f), + new(-0.507020127f, -0.000000000f, -0.861934215f, -242.494383508f), + }; + static readonly float4[] kBrush12 = { + new(+0.507020127f, -0.000000000f, +0.861934215f, +242.475256333f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.320625000f), + new(+0.867013944f, +0.000000000f, -0.498283876f, +101.745871117f), + new(-0.867013944f, +0.000000000f, +0.498283876f, -101.821061532f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.901250000f), + new(-0.507020127f, +0.000000000f, -0.861934215f, -242.494383508f), + }; + static readonly float4[] kBrush13 = { + new(+0.499722453f, +0.000000000f, +0.866185586f, +241.608987966f), + new(+0.865768711f, +0.000000000f, -0.500444342f, +100.456764977f), + new(-0.865768711f, +0.000000000f, +0.500444342f, -100.571203461f), + new(-0.000000000f, +1.000000000f, +0.000000000f, -20.358750000f), + new(+0.000000000f, -1.000000000f, +0.000000000f, +19.748750000f), + new(-0.499722453f, -0.000000000f, -0.866185586f, -241.647079102f), + }; + static readonly float4[][] kNeighbours = + { + kBrush1, kBrush2, kBrush3, kBrush4, kBrush5, kBrush6, kBrush7, kBrush8, kBrush9, kBrush10, kBrush11, kBrush12, kBrush13 + }; + + // Surface 6 is the slab's sixth side + static float3 FaceNormal => kSlab[5].xyz; + + static ContentsSceneNode Brush(string name, float4[] planes) => ContentsSceneNode.Brush(planes, name: name); + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene) + { + var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(harness.Delivered, Is.True, "no meshes were delivered: " + harness.LastUpdateReport); + return harness; + } + + // How much of surface 6 the slab draws, in square metres + static double FaceArea(ContentsTreeHarness harness, ContentsSceneNode slab) + { + double area = 0; + foreach (var triangle in harness.Triangles) + { + if (harness.NodeOf(triangle.brushID) != slab) + continue; + if (math.dot(triangle.surfaceNormal, FaceNormal) < 0.999f) + continue; + area += math.length(math.cross((double3)triangle.b - triangle.a, (double3)triangle.c - triangle.a)) * 0.5; + } + return area; + } + + // On its own the slab draws the whole wall: 40 units tall by about 64 long, so roughly 0.76 x 1.22 m + [Test] + public void TheSlabAlone_DrawsSurface6() + { + var slab = Brush("slab", kSlab); + using var harness = BuildAndUpdate(new ContentsScene().Add(slab)); + Assert.That(FaceArea(harness, slab), Is.GreaterThan(0.5), "the slab does not draw surface 6 even by itself"); + } + + // The area the slab keeps when it stands with these neighbours, against the area it keeps alone + static (double kept, double alone) WallAgainst(params int[] neighbours) + { + double alone; + { + var only = Brush("slab", kSlab); + using var harness = BuildAndUpdate(new ContentsScene().Add(only)); + alone = FaceArea(harness, only); + } + + var slab = Brush("slab", kSlab); + var scene = new ContentsScene().Add(slab); + foreach (var i in neighbours) + scene.Add(Brush($"neighbour {i}", kNeighbours[i])); + using var withNeighbours = BuildAndUpdate(scene); + return (FaceArea(withNeighbours, slab), alone); + } + + const double kAreaNoise = 1e-3; + static void TheyTakeNoMoreThanTheyDoSeparately(params int[] neighbours) + { + var report = new System.Text.StringBuilder(); + double alone = 0, budget = 0; + foreach (var i in neighbours) + { + var (keptAlone, aloneArea) = WallAgainst(i); + alone = aloneArea; + budget += aloneArea - keptAlone; + report.AppendLine($" neighbour {i} alone takes {aloneArea - keptAlone:0.0000} m2"); + } + + var (kept, _) = WallAgainst(neighbours); + var taken = alone - kept; + report.AppendLine($" together they take {taken:0.0000} m2, and may take at most {budget:0.0000}"); + Assert.That(taken, Is.LessThanOrEqualTo(budget + (alone * kAreaNoise)), + $"surface 6 keeps {kept:0.0000} m2 of {alone:0.0000}: the neighbours took {taken:0.0000} m2 of wall " + + $"between them, more than the {budget:0.0000} m2 they take one at a time, so the loss is not geometry:" + + System.Environment.NewLine + report); + } + + [Test] + public void EachNeighbourOnItsOwn_TakesOnlyItsOwnSliver() + { + var report = new System.Text.StringBuilder(); + bool failed = false; + for (int i = 0; i < kNeighbours.Length; i++) + { + var (kept, alone) = WallAgainst(i); + report.AppendLine($" slab + neighbour {i}: {kept:0.0000} m2 of {alone:0.0000}"); + failed |= kept <= alone * 0.9; + } + if (failed) + Assert.Fail("a single neighbour took more than a tenth of the wall:" + System.Environment.NewLine + report); + } + + [Test] + public void TwoOfThemTogether_TakeNoMoreThanTheyDoSeparately() + { + TheyTakeNoMoreThanTheyDoSeparately(0, 1); + } + + // The same pair, with the wedge added before the post: the result doesn't depend on which brush is later in the tree + [Test] + public void TwoOfThemTheOtherWayAround_TakeNoMoreThanTheyDoSeparately() + { + TheyTakeNoMoreThanTheyDoSeparately(1, 0); + } + + [Test] + public void TheNeighboursCrossingIt_TakeNoMoreThanTheyDoSeparately() + { + var all = new int[kNeighbours.Length]; + for (int i = 0; i < all.Length; i++) + all[i] = i; + TheyTakeNoMoreThanTheyDoSeparately(all); + } + + [Test] + public void EveryPairOfThem_TakesNoMoreThanItsTwoDoSeparately() + { + var alone = new double[kNeighbours.Length]; + double whole = 0; + for (int i = 0; i < kNeighbours.Length; i++) + { + var (kept, aloneArea) = WallAgainst(i); + alone[i] = aloneArea - kept; + whole = aloneArea; + } + + var report = new System.Text.StringBuilder(); + int broken = 0; + for (int i = 0; i < kNeighbours.Length; i++) + { + for (int j = i + 1; j < kNeighbours.Length; j++) + { + var (kept, _) = WallAgainst(i, j); + var taken = whole - kept; + var budget = alone[i] + alone[j]; + if (taken <= budget + (whole * kAreaNoise)) + continue; + broken++; + report.AppendLine($" {i} and {j} take {taken:0.0000} m2 where {budget:0.0000} is all they account for"); + } + } + if (broken > 0) + Assert.Fail($"{broken} of the 78 pairs took more wall than the two brushes take one at a time:" + + System.Environment.NewLine + report); + } + + } +} diff --git a/Core/Tests/Contents/FlushContactFaceTests.cs.meta b/Core/Tests/Contents/FlushContactFaceTests.cs.meta new file mode 100644 index 00000000..0f830cbe --- /dev/null +++ b/Core/Tests/Contents/FlushContactFaceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e17d8e80501b1c64aae2f5ed195df4c9 \ No newline at end of file diff --git a/Core/Tests/Contents/IntersectingNodeTests.cs b/Core/Tests/Contents/IntersectingNodeTests.cs new file mode 100644 index 00000000..32a77449 --- /dev/null +++ b/Core/Tests/Contents/IntersectingNodeTests.cs @@ -0,0 +1,241 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class IntersectingNodeTests + { + // near overlaps inter; far and away touch nothing + static ContentsSceneNode Near() => Box("near", new float3(0), new float3(2)); + static ContentsSceneNode Far() => Box("far", new float3(10, 0, 0), new float3(12, 2, 2)); + static ContentsSceneNode Away() => Box("away", new float3(20, 0, 0), new float3(22, 2, 2), CSGOperationType.Intersecting); + static ContentsSceneNode Inter(CSGOperationType operation = CSGOperationType.Intersecting) + => Box("inter", new float3(1, -1, -1), new float3(3, 3, 3), operation); + + const int kWholeBox = 12; + + static ContentsSceneNode Box(string name, float3 min, float3 max, CSGOperationType operation = CSGOperationType.Additive) + { + return ContentsSceneNode.Brush(ContentsScene.BoxPlanes(min, max), operation: operation, name: name); + } + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene, string what) + { + var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + return harness; + } + + static void UpdateAndAssert(ContentsTreeHarness harness, string what) + { + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + ContentsComparison.AssertMatchesOracle(harness, what); + } + + #region Full builds + [Test] + public void Intersect_RemovesBrushesItDoesNotTouch() + { + var near = Near(); var far = Far(); var inter = Inter(); + var scene = new ContentsScene().Add(near).Add(far).Add(inter); + using (var harness = BuildAndUpdate(scene, "near, far, inter")) + { + ContentsComparison.AssertMatchesOracle(harness, "near, far, inter"); + Assert.That(harness.TriangleCountOf(far), Is.EqualTo(0), "far is outside inter, so nothing of it survives"); + Assert.That(harness.TriangleCountOf(near), Is.GreaterThan(0)); + Assert.That(harness.TriangleCountOf(inter), Is.GreaterThan(0)); + } + } + + [Test] + public void Intersect_ThatTouchesNothing_RemovesEverythingBeforeIt() + { + var scene = new ContentsScene().Add(Near()).Add(Far()).Add(Away()); + using (var harness = BuildAndUpdate(scene, "near, far, away")) + { + ContentsComparison.AssertMatchesOracle(harness, "near, far, away"); + Assert.That(harness.Triangles.Count, Is.EqualTo(0), "nothing is inside away"); + } + } + + // Its reach ends at its own composite, on either side of it. + [Test] + public void Intersect_OnlyReachesItsOwnComposite([Values] bool farFirst) + { + var far = Far(); + var composite = ContentsSceneNode.Composite(CSGOperationType.Additive, Near(), Inter()); + var scene = farFirst ? new ContentsScene().Add(far).Add(composite) + : new ContentsScene().Add(composite).Add(far); + var what = farFirst ? "far, [near, inter]" : "[near, inter], far"; + using (var harness = BuildAndUpdate(scene, what)) + { + ContentsComparison.AssertMatchesOracle(harness, what); + Assert.That(harness.TriangleCountOf(far), Is.EqualTo(kWholeBox), "far is outside the composite inter works in"); + } + } + + // The same holds for an intersecting composite, whose brushes are additive inside it. + [Test] + public void IntersectingComposite_RemovesBrushesItDoesNotTouch() + { + var far = Far(); + var composite = ContentsSceneNode.Composite(CSGOperationType.Intersecting, Inter(CSGOperationType.Additive)); + var scene = new ContentsScene().Add(Near()).Add(far).Add(composite); + using (var harness = BuildAndUpdate(scene, "near, far, [Int: inter]")) + { + ContentsComparison.AssertMatchesOracle(harness, "near, far, [Int: inter]"); + Assert.That(harness.TriangleCountOf(far), Is.EqualTo(0)); + } + } + + // A brush after the intersect overlaps one the intersect removed, so none of it is cut away + [Test] + public void BrushAfterAnIntersect_IsNotCutByTheBrushItRemoved() + { + var p = Box("p", new float3(0), new float3(2)); + var r = Box("r", new float3(1), new float3(3)); + var scene = new ContentsScene().Add(p).Add(Far().WithOperation(CSGOperationType.Intersecting)).Add(r); + using (var harness = BuildAndUpdate(scene, "p, far(Int), r")) + { + ContentsComparison.AssertMatchesOracle(harness, "p, far(Int), r"); + Assert.That(harness.TriangleCountOf(p), Is.EqualTo(0)); + } + } + + // The same, with the intersect removing p inside a composite and r outside it. + [Test] + public void BrushOutsideTheComposite_IsNotCutByTheBrushItRemoved() + { + var p = Box("p", new float3(0), new float3(2)); + var r = Box("r", new float3(1), new float3(3)); + var composite = ContentsSceneNode.Composite(CSGOperationType.Additive, p, Far().WithOperation(CSGOperationType.Intersecting)); + var scene = new ContentsScene().Add(composite).Add(r); + using (var harness = BuildAndUpdate(scene, "[p, far(Int)], r")) + { + ContentsComparison.AssertMatchesOracle(harness, "[p, far(Int)], r"); + Assert.That(harness.TriangleCountOf(p), Is.EqualTo(0)); + } + } + #endregion + + #region Edits + [Test] + public void AddingAnIntersect_RemovesBrushesItDoesNotTouch() + { + var far = Far(); + var scene = new ContentsScene().Add(Near()).Add(far); + using (var harness = BuildAndUpdate(scene, "near, far")) + { + harness.Insert(null, 2, Inter()); + UpdateAndAssert(harness, "near, far, then inter added"); + Assert.That(harness.TriangleCountOf(far), Is.EqualTo(0)); + } + } + + [Test] + public void RemovingAnIntersect_BringsBackBrushesItDidNotTouch([Values] bool destroy) + { + var far = Far(); var inter = Inter(); + var scene = new ContentsScene().Add(Near()).Add(far).Add(inter); + using (var harness = BuildAndUpdate(scene, "near, far, inter")) + { + harness.Remove(inter, destroy); + UpdateAndAssert(harness, destroy ? "inter destroyed" : "inter detached"); + Assert.That(harness.TriangleCountOf(far), Is.EqualTo(kWholeBox)); + } + } + + [Test] + public void ChangingAnOperation_ToAndFromIntersecting() + { + var far = Far(); var box = Inter(CSGOperationType.Additive); + var scene = new ContentsScene().Add(Near()).Add(far).Add(box); + using (var harness = BuildAndUpdate(scene, "near, far, inter(Add)")) + { + harness.SetOperation(box, CSGOperationType.Intersecting); + UpdateAndAssert(harness, "inter made intersecting"); + Assert.That(harness.TriangleCountOf(far), Is.EqualTo(0)); + + harness.SetOperation(box, CSGOperationType.Additive); + UpdateAndAssert(harness, "inter made additive again"); + Assert.That(harness.TriangleCountOf(far), Is.EqualTo(kWholeBox)); + } + } + + // At the front of its branch an intersect has nothing before it, and is skipped. + [Test] + public void MovingAnIntersectToTheFront_BringsBackBrushesItDidNotTouch() + { + var near = Near(); var far = Far(); var inter = Inter(); + var scene = new ContentsScene().Add(near).Add(far).Add(inter); + using (var harness = BuildAndUpdate(scene, "near, far, inter")) + { + harness.Move(inter, null, 0); + UpdateAndAssert(harness, "inter moved to the front"); + Assert.That(harness.TriangleCountOf(far), Is.EqualTo(kWholeBox)); + } + } + + // The intersect lands in a composite and removes p there; r, outside the composite, touches p + // but not the intersect, and has to be rebuilt through p. + [Test] + public void AddingAnIntersectToAComposite_RebuildsATouchingBrushOutsideIt() + { + var p = Box("p", new float3(0), new float3(2)); + var r = Box("r", new float3(1), new float3(3)); + var composite = ContentsSceneNode.Composite(CSGOperationType.Additive, p); + var scene = new ContentsScene().Add(composite).Add(r); + using (var harness = BuildAndUpdate(scene, "[p], r")) + { + harness.Insert(composite, 1, Far().WithOperation(CSGOperationType.Intersecting)); + UpdateAndAssert(harness, "[p, far(Int)], r"); + Assert.That(harness.TriangleCountOf(p), Is.EqualTo(0)); + } + } + + // A composite's operation is part of the routing table of every brush below it, so changing it dirties them all + [Test] + public void ChangingACompositeOperation_RebuildsItsBrushes() + { + var a = Box("a", new float3(0), new float3(2)); + var b = Box("b", new float3(1), new float3(3)); + var composite = ContentsSceneNode.Composite(CSGOperationType.Additive, b); + var scene = new ContentsScene().Add(a).Add(composite); + using (var harness = BuildAndUpdate(scene, "a, [Add: b]")) + { + harness.SetOperation(composite, CSGOperationType.Subtractive); + UpdateAndAssert(harness, "a, [Sub: b]"); + harness.SetOperation(composite, CSGOperationType.Intersecting); + UpdateAndAssert(harness, "a, [Int: b]"); + harness.SetOperation(composite, CSGOperationType.Additive); + UpdateAndAssert(harness, "a, [Add: b] again"); + } + } + + [Test] + public void MovingAComposite_RebuildsItsBrushes() + { + var a = Box("a", new float3(0), new float3(2)); + var c = Box("c", new float3(1), new float3(3)); + var composite = ContentsSceneNode.Composite(CSGOperationType.Subtractive, c); + var scene = new ContentsScene().Add(a).Add(composite); + using (var harness = BuildAndUpdate(scene, "a, [Sub: c]")) + { + harness.Move(composite, null, 0); + UpdateAndAssert(harness, "[Sub: c], a"); + } + } + #endregion + } + + static class ContentsSceneNodeTestExtensions + { + public static ContentsSceneNode WithOperation(this ContentsSceneNode node, CSGOperationType operation) + { + node.operation = operation; + return node; + } + } +} diff --git a/Core/Tests/Contents/IntersectingNodeTests.cs.meta b/Core/Tests/Contents/IntersectingNodeTests.cs.meta new file mode 100644 index 00000000..a77e757d --- /dev/null +++ b/Core/Tests/Contents/IntersectingNodeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 23360c516b8dea640a1e8739f98a6412 \ No newline at end of file diff --git a/Core/Tests/Contents/IntersectionLoopCapacityTests.cs b/Core/Tests/Contents/IntersectionLoopCapacityTests.cs new file mode 100644 index 00000000..e9fbb25a --- /dev/null +++ b/Core/Tests/Contents/IntersectionLoopCapacityTests.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; +using Unity.Mathematics; +using Random = Unity.Mathematics.Random; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class IntersectionLoopCapacityTests + { + // Unit boxes, each with its own rotation and scale, packed close enough that every one overlaps every + // other. Deterministic: the same seed builds the same scene every run. + static ContentsScene MutuallyOverlappingBrushes(int count) + { + var random = new Random(0x9E3779B9); + var scene = new ContentsScene(); + for (int i = 0; i < count; i++) + { + var node = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(-0.5f), new float3(0.5f)), + name: "b" + i); + node.localToTree = float4x4.TRS(random.NextFloat3(-0.35f, 0.35f), + quaternion.Euler(random.NextFloat3(-math.PI, math.PI)), + random.NextFloat3(0.9f, 1.6f)); + scene.Add(node); + } + return scene; + } + + [Test] + public void AFewOverlappingBrushes_MatchTheOracle() + { + ContentsComparison.BuildAndAssertMatchesOracle(MutuallyOverlappingBrushes(4), "four overlapping brushes"); + } + + // 6N(N-1) loops against 32N reserved: at ten brushes 540 against 320 + [Test] + public void EnoughOverlappingBrushesToOutgrowTheGuessedCapacity_StillMatchTheOracle() + { + ContentsComparison.BuildAndAssertMatchesOracle(MutuallyOverlappingBrushes(10), "ten overlapping brushes"); + } + + // And past it by a margin no constant-per-brush reservation could cover: 1440 against 512. + [Test] + public void FarPastTheGuessedCapacity_StillMatchesTheOracle() + { + ContentsComparison.BuildAndAssertMatchesOracle(MutuallyOverlappingBrushes(16), "sixteen overlapping brushes"); + } + } +} diff --git a/Core/Tests/Contents/IntersectionLoopCapacityTests.cs.meta b/Core/Tests/Contents/IntersectionLoopCapacityTests.cs.meta new file mode 100644 index 00000000..53c1e9bc --- /dev/null +++ b/Core/Tests/Contents/IntersectionLoopCapacityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c871aa158c92f5a106ba09d690c499cc \ No newline at end of file diff --git a/Core/Tests/Contents/LightmapUVPipelineTests.cs b/Core/Tests/Contents/LightmapUVPipelineTests.cs new file mode 100644 index 00000000..4b48b14b --- /dev/null +++ b/Core/Tests/Contents/LightmapUVPipelineTests.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class LightmapUVPipelineTests + { + static ContentsSceneNode Box(string name, float3 min, float3 max, CSGOperationType operation = CSGOperationType.Additive) + { + return ContentsSceneNode.Brush(ContentsScene.BoxPlanes(min, max), operation: operation, name: name); + } + + // A floor with a wall standing on it, a window cut through the wall, and a wedge: surfaces cut into pieces, one with a + // hole, and one in no axis' plane + static ContentsScene Room() + { + return new ContentsScene() + .Add(Box("floor", new float3(-4, -1, -4), new float3(4, 0, 4))) + .Add(Box("wall", new float3(-3, 0, -0.25f), new float3(3, 3, 0.25f))) + .Add(Box("window", new float3(-1, 1, -1), new float3(1, 2, 1), CSGOperationType.Subtractive)) + .Add(ContentsSceneNode.Brush(ContentsScene.WedgePlanes(new float3(1.5f, 0, 2), new float3(3.5f, 2, 3.5f)), name: "wedge")); + } + + static ContentsTreeHarness BuildAndUpdate(ContentsScene scene) + { + var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(harness.Delivered, Is.True, "no meshes were delivered: " + harness.LastUpdateReport); + Assert.That(harness.Triangles.Count, Is.GreaterThan(0), "nothing was drawn"); + return harness; + } + + static double Area(float3 a, float3 b, float3 c) => math.length(math.cross((double3)b - a, (double3)c - a)) * 0.5; + static double Area(float2 a, float2 b, float2 c) => math.abs(((double)b.x - a.x) * ((double)c.y - a.y) - ((double)c.x - a.x) * ((double)b.y - a.y)) * 0.5; + + // The surface a triangle belongs to: its brush and its plane (the normal the pipeline gave it is exact even on a sliver) + static (CompactNodeID, int3, int) SurfaceOf(in ContentsTreeHarness.CapturedTriangle triangle) + { + var normal = triangle.surfaceNormal; + return (triangle.brushID, (int3)math.round(normal * 1000), (int)math.round(math.dot(normal, triangle.a) * 1000)); + } + + [Test] + public void EveryTriangle_HasLightmapCoordinatesInsideTheUnitSquare() + { + using var harness = BuildAndUpdate(Room()); + foreach (var triangle in harness.Triangles) + { + foreach (var uv in new[] { triangle.lightmapA, triangle.lightmapB, triangle.lightmapC }) + Assert.That(math.all(uv >= 0) && math.all(uv <= 1), Is.True, $"lightmap coordinate {uv} of a triangle at {triangle.Center}"); + } + } + + // Two surfaces never share texels: each lies in a cell of its own, so their triangles' boxes in the lightmap are apart + [Test] + public void DifferentSurfaces_DoNotOverlapInTheLightmap() + { + using var harness = BuildAndUpdate(Room()); + var triangles = harness.Triangles; + for (int i = 0; i < triangles.Count; i++) + { + var a = triangles[i]; + var boxA = new float4(math.min(math.min(a.lightmapA, a.lightmapB), a.lightmapC), math.max(math.max(a.lightmapA, a.lightmapB), a.lightmapC)); + for (int j = i + 1; j < triangles.Count; j++) + { + var b = triangles[j]; + if (a.section != b.section || SurfaceOf(a).Equals(SurfaceOf(b))) + continue; + var boxB = new float4(math.min(math.min(b.lightmapA, b.lightmapB), b.lightmapC), math.max(math.max(b.lightmapA, b.lightmapB), b.lightmapC)); + var apart = math.any(boxA.zw <= boxB.xy) || math.any(boxB.zw <= boxA.xy); + Assert.That(apart, Is.True, $"triangles at {a.Center} and {b.Center} share lightmap texels ({boxA} and {boxB})"); + } + } + } + + // Every surface gets the same texels per unit of area, the sloped one included: charts are the surfaces laid flat, + // neither stretched nor scaled apart from each other + [Test] + public void EverySurface_GetsTheSameTexelDensity() + { + using var harness = BuildAndUpdate(Room()); + var densities = new Dictionary(); + foreach (var triangle in harness.Triangles) + { + var area = Area(triangle.a, triangle.b, triangle.c); + if (area < 1e-3) + continue; + var density = Area(triangle.lightmapA, triangle.lightmapB, triangle.lightmapC) / area; + if (!densities.TryGetValue(triangle.section, out var expected)) + { + densities[triangle.section] = density; + continue; + } + Assert.That(density, Is.EqualTo(expected).Within(0.5).Percent, $"the triangle at {triangle.Center}"); + } + } + + // The same brushes get the same lightmap coordinates every time they are built, so a bake stays valid after a reload + [Test] + public void TheSameScene_GetsTheSameLightmapCoordinates() + { + var first = new Dictionary<(int3, int3, int3), (float2, float2, float2)>(); + int firstCount; + using (var harness = BuildAndUpdate(Room())) + { + foreach (var triangle in harness.Triangles) + first[KeyOf(triangle)] = (triangle.lightmapA, triangle.lightmapB, triangle.lightmapC); + firstCount = harness.Triangles.Count; + } + + using var again = BuildAndUpdate(Room()); + Assert.AreEqual(firstCount, again.Triangles.Count, "the same triangles"); + foreach (var triangle in again.Triangles) + { + Assert.That(first.TryGetValue(KeyOf(triangle), out var expected), Is.True, $"the triangle at {triangle.Center} is new"); + Assert.That(expected, Is.EqualTo((triangle.lightmapA, triangle.lightmapB, triangle.lightmapC)), $"the triangle at {triangle.Center}"); + } + } + + static (int3, int3, int3) KeyOf(in ContentsTreeHarness.CapturedTriangle triangle) + { + return ((int3)math.round(triangle.a * 1000), (int3)math.round(triangle.b * 1000), (int3)math.round(triangle.c * 1000)); + } + } +} diff --git a/Core/Tests/Contents/LightmapUVPipelineTests.cs.meta b/Core/Tests/Contents/LightmapUVPipelineTests.cs.meta new file mode 100644 index 00000000..dac74ba2 --- /dev/null +++ b/Core/Tests/Contents/LightmapUVPipelineTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f8bcee008fe4e1899384eb5a9369a96 diff --git a/Core/Tests/Contents/LiveOutputCheck.cs b/Core/Tests/Contents/LiveOutputCheck.cs new file mode 100644 index 00000000..e451afbe --- /dev/null +++ b/Core/Tests/Contents/LiveOutputCheck.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + public static class LiveOutputCheck + { + public static string Run(ContentsScene scene, float[] triangleData, string what, int examplesPerFace = 3) + { + var triangles = new List(triangleData.Length / 9); + for (int t = 0; t + 8 < triangleData.Length; t += 9) + triangles.Add(new float3x3(new float3(triangleData[t], triangleData[t + 1], triangleData[t + 2]), + new float3(triangleData[t + 3], triangleData[t + 4], triangleData[t + 5]), + new float3(triangleData[t + 6], triangleData[t + 7], triangleData[t + 8]))); + + var oracle = new ContentsOracle(scene); + var brushes = oracle.Brushes; + var extent = ContentsHarvest.RequiredExtent(scene); + int checkedSamples = 0, skipped = 0, holes = 0, extras = 0, unexamined = 0; + var perFace = new StringBuilder(); + + var allBounds = new List<(float3 min, float3 max)>(brushes.Count); + foreach (var other in brushes) + allBounds.Add(ContentsFaceSampler.BrushBounds(oracle.TreePlanesOf(other), extent)); + + for (int b = 0; b < brushes.Count; b++) + { + var planes = oracle.TreePlanesOf(brushes[b]); + var otherPlanes = new List(); + var otherBounds = new List<(float3 min, float3 max)>(); + for (int o = 0; o < brushes.Count; o++) + { + if (o == b) continue; + otherPlanes.Add(oracle.TreePlanesOf(brushes[o])); + otherBounds.Add(allBounds[o]); + } + + for (int p = 0; p < planes.Length; p++) + { + var plane = planes[p]; + var samples = ContentsFaceSampler.FaceSamples(planes, p, otherPlanes, otherBounds, extent, + ContentsComparison.kSampleInset, ContentsComparison.kSampleSpacing, + ContentsComparison.kCellClearance); + if (samples.Count == 0) { unexamined++; continue; } + + // only the triangles lying on this plane can cover a sample on it + var onPlane = new List(); + foreach (var triangle in triangles) + if (ContentsComparison.OnPlane(triangle.c0, plane) && ContentsComparison.OnPlane(triangle.c1, plane) && + ContentsComparison.OnPlane(triangle.c2, plane)) + onPlane.Add(triangle); + + int faceHoles = 0, faceExtras = 0; + var examples = new StringBuilder(); + foreach (var point in samples) + { + if (ContentsFaceSampler.NearACrossingPlane(otherPlanes, plane, point, ContentsComparison.kSampleCrossingBand)) { skipped++; continue; } + checkedSamples++; + ContentsComparison.ExpectedFacings(oracle, plane, point, out var expectAlong, out var expectAgainst); + bool foundAlong = false, foundAgainst = false; + foreach (var triangle in onPlane) + { + if (!ContentsComparison.Covers(triangle.c0, triangle.c1, triangle.c2, plane, point, out var facing)) + continue; + if (facing > 0) foundAlong = true; else foundAgainst = true; + } + var hole = (expectAlong && !foundAlong) || (expectAgainst && !foundAgainst); + var extra = (!expectAlong && foundAlong) || (!expectAgainst && foundAgainst); + if (hole) faceHoles++; + if (extra) faceExtras++; + if ((hole || extra) && faceHoles + faceExtras <= examplesPerFace) + examples.Append($" ({F(point.x)}, {F(point.y)}, {F(point.z)}) {(hole ? "HOLE" : "EXTRA")}" + + $" expect{(expectAlong ? "+" : "")}{(expectAgainst ? "-" : "")} found{(foundAlong ? "+" : "")}{(foundAgainst ? "-" : "")};"); + } + holes += faceHoles; + extras += faceExtras; + if (faceHoles + faceExtras > 0) + perFace.AppendLine($" {brushes[b].name} face {p}: {faceHoles} hole(s), {faceExtras} extra(s) of {samples.Count} samples:{examples}"); + } + } + + var text = new StringBuilder(); + text.AppendLine($"# {what}: {holes} hole sample(s), {extras} extra sample(s) over {checkedSamples} checked, {skipped} skipped near crossing planes, " + + $"{unexamined} face(s) unexamined; {triangles.Count} live triangles, extent {F(extent)}"); + text.Append(perFace); + return text.ToString(); + } + + static string F(float value) => value.ToString("0.###", CultureInfo.InvariantCulture); + } +} diff --git a/Core/Tests/Contents/LiveOutputCheck.cs.meta b/Core/Tests/Contents/LiveOutputCheck.cs.meta new file mode 100644 index 00000000..72e3169d --- /dev/null +++ b/Core/Tests/Contents/LiveOutputCheck.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1af1c9d44ecb29f41838882a82235bd9 \ No newline at end of file diff --git a/Core/Tests/Contents/NestedSubtractionRoutingTests.cs b/Core/Tests/Contents/NestedSubtractionRoutingTests.cs new file mode 100644 index 00000000..6582bfd4 --- /dev/null +++ b/Core/Tests/Contents/NestedSubtractionRoutingTests.cs @@ -0,0 +1,49 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class NestedSubtractionRoutingTests + { + static ContentsSceneNode Box(string name, float3 min, float3 max, CSGOperationType operation = CSGOperationType.Additive) + { + return ContentsSceneNode.Brush(ContentsScene.BoxPlanes(min, max), operation: operation, name: name); + } + + /// A block with nested subtract groups, each overlapping the block and every other one + static ContentsScene NestedSubtractions(int depth) + { + var block = Box("block", new float3(-10, -10, -10), new float3(10, 10, 10)); + + // Build from the inside out, so each group ends up holding the next one. + ContentsSceneNode inner = null; + for (int i = depth - 1; i >= 0; i--) + { + // Bites all cross the middle of the block and each other, staggered so no two coincide. + var offset = i * 0.5f; + var bite = Box("bite" + i, + new float3(-5 + offset, -12, -5 - offset), + new float3( 5 + offset, 12, 5 - offset)); + inner = inner == null + ? ContentsSceneNode.Composite(CSGOperationType.Subtractive, bite) + : ContentsSceneNode.Composite(CSGOperationType.Subtractive, bite, inner); + } + + return new ContentsScene().Add(block).Add(inner); + } + + [TestCase(2)] + [TestCase(4)] + [TestCase(8)] + [TestCase(16)] + public void NestedSubtractGroups_Route(int depth) + { + using var harness = ContentsTreeHarness.Build(NestedSubtractions(depth)); + Assert.That(harness.Update(), Is.True, depth + " levels: the CSG update did not run"); + Assert.That(harness.Delivered, Is.True, + depth + " levels: no meshes were delivered (" + harness.LastUpdateReport + ")"); + } + } +} diff --git a/Core/Tests/Contents/NestedSubtractionRoutingTests.cs.meta b/Core/Tests/Contents/NestedSubtractionRoutingTests.cs.meta new file mode 100644 index 00000000..1595d215 --- /dev/null +++ b/Core/Tests/Contents/NestedSubtractionRoutingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eeff751f0f5e8ba4a99a3489f3675472 \ No newline at end of file diff --git a/Core/Tests/Contents/SampleSceneExtraFaceTests.cs b/Core/Tests/Contents/SampleSceneExtraFaceTests.cs new file mode 100644 index 00000000..37fb6e15 --- /dev/null +++ b/Core/Tests/Contents/SampleSceneExtraFaceTests.cs @@ -0,0 +1,852 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + [Category("CSGCorpus")] + public class SampleSceneExtraFaceTests + { + [Test] + public void ExtraFaceOnBox38Top_WithAddBoxAndAddBox9_Model1F03() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -13f), + new float4(0f, -1f, 0f, -3f), + new float4(0f, 0f, -1f, -13f), + new float4(1f, 0f, 0f, -13f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -12f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, -2.98022229E-08f, 1f)), name: "Box #0")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 0.780868769f, 0.6246951f, -1.24939024f), + new float4(0f, -1f, 0f, 0f), + new float4(-1f, 0f, 0f, -0.9999999f), + new float4(1f, 0f, 0f, -0.9999999f), + new float4(0f, 0f, -1f, -0.499999523f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(0f, -2.38418579E-07f, -1f, 0f), new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -2.38418579E-07f, 0f), new float4(11.000001f, -5.75f, 2.98023224E-07f, 1f)), name: "Box (9) #80")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.25f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -13.5f), + new float4(1f, 0f, 0f, -0.25f), + new float4(0f, 1f, 0f, -0.5f), + new float4(0f, 0f, 1f, -1.5f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(9.75f, -6.75f, -6.50000048f, 1f)), name: "Box (38) #141")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 3"); + } + + [Test] + public void ExtraFaceOnBoxBottom_WithAddBox_Model1F05() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -13f), + new float4(0f, -1f, 0f, -3f), + new float4(0f, 0f, -1f, -13f), + new float4(1f, 0f, 0f, -13f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -12f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, -2.98022229E-08f, 1f)), name: "Box #0")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.25f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -5.5f), + new float4(1f, 0f, 0f, -0.25f), + new float4(0f, 1f, 0f, -0.5f), + new float4(0f, 0f, 1f, -1.5f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-9.75f, -6.75f, -6.5f, 1f)), name: "Box #140")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -1.5f), + new float4(0f, -1f, 0f, -0.5f), + new float4(0f, 0f, -1f, -1.5f), + new float4(1f, 0f, 0f, -1.5f), + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, 0f, 1f, -1.5f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-0.9658843f, 0f, -0.258973718f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0.258973837f, 0f, -0.9658848f, 0f), new float4(-9f, -5.75f, -1f, 1f)), name: "Box #146")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 5"); + } + + [Test] + public void ExtraFaceOnBox38Top_WithAddBoxAndAddBox9_Model1F07() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -13f), + new float4(0f, -1f, 0f, -3f), + new float4(0f, 0f, -1f, -13f), + new float4(1f, 0f, 0f, -13f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -12f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, -2.98022229E-08f, 1f)), name: "Box #0")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 0.780868769f, 0.6246951f, -1.24939024f), + new float4(0f, -1f, 0f, 0f), + new float4(-1f, 0f, 0f, -0.9999999f), + new float4(1f, 0f, 0f, -0.9999999f), + new float4(0f, 0f, -1f, -0.499999523f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(0f, -2.38418579E-07f, -1f, 0f), new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -2.38418579E-07f, 0f), new float4(11.000001f, -5.75000048f, -4f, 1f)), name: "Box (9) #84")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.25f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -13.5f), + new float4(1f, 0f, 0f, -0.25f), + new float4(0f, 1f, 0f, -0.5f), + new float4(0f, 0f, 1f, -1.5f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(9.75f, -6.75f, -6.50000048f, 1f)), name: "Box (38) #141")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 7"); + } + + [Test] + public void ExtraFaceOnBox38Top_WithAddBoxAndAddBox9_Model1F09() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -13f), + new float4(0f, -1f, 0f, -3f), + new float4(0f, 0f, -1f, -13f), + new float4(1f, 0f, 0f, -13f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -12f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, -2.98022229E-08f, 1f)), name: "Box #0")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 0.780868769f, 0.6246951f, -1.24939024f), + new float4(0f, -1f, 0f, 0f), + new float4(-1f, 0f, 0f, -0.9999999f), + new float4(1f, 0f, 0f, -0.9999999f), + new float4(0f, 0f, -1f, -0.499999523f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(0f, -2.38418579E-07f, -1f, 0f), new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -2.38418579E-07f, 0f), new float4(11.000001f, -5.75f, 4f, 1f)), name: "Box (9) #76")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.25f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -13.5f), + new float4(1f, 0f, 0f, -0.25f), + new float4(0f, 1f, 0f, -0.5f), + new float4(0f, 0f, 1f, -1.5f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(9.75f, -6.75f, -6.50000048f, 1f)), name: "Box (38) #141")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 9"); + } + + [Test] + public void ExtraFaceOnBox3Bottom_WithAddBox1AndAddBox2_Model4F01() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -1.125f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-19.5f, 0.75f, -5.125f, 1f)), name: "Box 3 #399")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 1"); + } + + [Test] + public void ExtraFaceOnBox4Bottom_WithAddBox1AndAddBox2_Model4F02() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -1.125f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-18.75f, 0.75f, -5.125f, 1f)), name: "Box 4 #400")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 2"); + } + + [Test] + public void ExtraFaceOnBox5Bottom_WithAddBox1AndAddBox2_Model4F03() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -1.125f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-17.9999943f, 0.75f, -5.125f, 1f)), name: "Box 5 #401")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 3"); + } + + [Test] + public void ExtraFaceOnBox6Bottom_WithAddBox1AndAddBox2_Model4F04() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -0.625f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-20.9999943f, 0.75f, -5.125f, 1f)), name: "Box 6 #402")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 4"); + } + + [Test] + public void ExtraFaceOnBox7Bottom_WithAddBox1AndAddBox2_Model4F05() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -0.125f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-21.75f, 0.75f, -5.125f, 1f)), name: "Box 7 #403")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 5"); + } + + [Test] + public void ExtraFaceOnBox8Bottom_WithAddBox1AndAddBox2_Model4F06() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -1.125f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-17.2499943f, 0.75f, -5.125f, 1f)), name: "Box 8 #404")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 6"); + } + + [Test] + public void ExtraFaceOnBox9Bottom_WithAddBox1AndAddBox2_Model4F07() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -0.875f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-20.25f, 0.75f, -5.125f, 1f)), name: "Box 9 #405")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 7"); + } + + [Test] + public void ExtraFaceOnBox10Bottom_WithAddBox1AndAddBox2_Model4F08() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -0.625f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-15.75f, 0.75f, -5.125f, 1f)), name: "Box10 #406")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 8"); + } + + [Test] + public void ExtraFaceOnBox11Bottom_WithAddBox1AndAddBox2_Model4F09() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -0.875f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-16.5f, 0.75f, -5.125f, 1f)), name: "Box11 #407")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 9"); + } + + [Test] + public void ExtraFaceOnBox12Bottom_WithAddBox1AndAddBox2_Model4F10() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -3f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.6f, 0.8f, 0f, -3.6f), + new float4(0.6f, 0.8f, 0f, -3.6f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 1 #397")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(-0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0.5812382f, 0.813733459f, 0f, -3.19681f), + new float4(0f, 0f, -1f, -0.25f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.1920929E-07f, -1.00000012f, 0f), new float4(0f, 1.00000012f, -1.1920929E-07f, 0f), new float4(-18.5f, 0.5f, -4f, 1f)), name: "Box 2 #398")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.125f), + new float4(0f, -1f, 0f, -0.5000004f), + new float4(0f, 0f, -1f, -0.625f), + new float4(1f, 0f, 0f, -0.125f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -0.125f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(-15f, 0.75f, -5.125f, 1f)), name: "Box12 #408")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model4 finding 10"); + } + + [Test] + public void ExtraFaceOnBoxBottom_WithAddBox_Model1F06() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -13f), + new float4(0f, -1f, 0f, -3f), + new float4(0f, 0f, -1f, -13f), + new float4(1f, 0f, 0f, -13f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -12f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, -2.98022229E-08f, 1f)), name: "Box #0")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.5f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -1.00000012f), + new float4(1f, 0f, 0f, -0.5f), + new float4(0f, 1f, 0f, -10f), + new float4(0f, 0f, 1f, -1.00000012f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(-11.5000048f, -6.25f, 0f, 1f)), name: "Box #96")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -1.75f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -0.25f), + new float4(1f, 0f, 0f, -1.75f), + new float4(0f, 1f, 0f, -0.5f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-11.25f, -6.75f, -0.75f, 1f)), name: "Box #144")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.5f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -3f), + new float4(1f, 0f, 0f, -0.5f), + new float4(0f, 1f, 0f, -6f), + new float4(0f, 0f, 1f, -3f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-12.0000057f, -6.25f, 2.000001f, 1f)), name: "Box #165")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 6"); + } + + [Test] + public void ExtraFaceOnBoxTop_WithAddBoxAndAddBox29_Model2F01() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, -1f, 0f, -1f), + new float4(-0.707106769f, 0f, 0.707106769f, -5.656854f), + new float4(0.8944272f, 0f, -0.4472136f, -6.708204f), + new float4(0f, 0f, -1f, -1f), + new float4(0f, 0f, 1f, -1f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(5.00000238f, 0f, -19.00001f, 1f)), name: "Box #170")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1.45915264E-15f, 1f, -2.12873918E-09f, 1.06436939E-08f), + new float4(0f, -1f, 0f, -1f), + new float4(-1f, 4.4822762E-05f, -1.53269343E-06f, -7.99999142f), + new float4(1f, -4.43458557E-05f, 4.72003418E-14f, -7.99999952f), + new float4(-1.37090876E-06f, 4.386908E-05f, -1f, -8.999989f), + new float4(0f, -4.386902E-05f, 1f, -4.999999f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-1.36423772E-12f, -1f, -25f, 1f)), name: "Box #173")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -7f), + new float4(0f, -1f, 0f, -0.5f), + new float4(0f, 0f, -1f, -3.000001f), + new float4(1f, 0f, 0f, -3f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -7f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(11f, -1f, -21f, 1f)), name: "Box #176")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, -1f, 0f, -1f), + new float4(-0.707106769f, 0f, 0.707106769f, -5.303301f), + new float4(0.8944272f, 0f, -0.4472136f, -6.708204f), + new float4(0f, 0f, -1f, -1f), + new float4(0f, 0f, 1f, -0.5f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(5.00000238f, 0f, -19.00001f, 1f)), name: "Box (29) #317")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model2 finding 1"); + } + + [Test] + public void ExtraFaceOnBox1Top_WithAddBoxAndAddExtrudedShapeAdditiveAndSubExtrudedShapeRemove_Model2F02() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, -1f, 0f, -8.5f), + new float4(-0.707106769f, 1.95982852E-06f, 0.707106769f, -4.24263954f), + new float4(0.707106769f, 1.95982852E-06f, 0.707106769f, -4.24263954f), + new float4(0f, -2.771616E-06f, -1f, -2.00000143f), + new float4(0f, 2.771616E-06f, 1f, -1.99999857f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -0.5f, -36f, 1f)), name: "Box (1) #175")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -12f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -3.5f), + new float4(1f, 0f, 0f, -4f), + new float4(0f, 1f, 0f, -1.5f), + new float4(0f, 0f, 1f, -3.5f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000024f, -1.77635726E-15f, -7.450583E-09f, 0f), new float4(-7.450582E-09f, -2.384186E-06f, 1f, 0f), new float4(-1.95399269E-14f, 1f, 2.384186E-06f, 0f), new float4(-4.000001f, -5.49999762f, -25.9999981f, 1f)), name: "Box #238")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 7.40718E-08f, -7f), + new float4(0f, 1f, -7.40718E-08f, 6f), + new float4(1f, 0f, 0f, -5.5f), + new float4(0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(0f, 0f, 1f, -5.49999952f), + new float4(-0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(-1f, 0f, 0f, -5.5f), + new float4(-0.707106769f, 0f, -0.7071068f, -5.656854f), + new float4(0f, 0f, -1f, -5.49999952f), + new float4(0.707106769f, 0f, -0.7071068f, -5.656854f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Additive) #240")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 8.141123E-08f, -7f), + new float4(0f, 1f, -8.141123E-08f, 6f), + new float4(1f, 0f, 0f, -5f), + new float4(0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(0f, 0f, 1f, -4.99999952f), + new float4(-0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(-1f, 0f, 0f, -5f), + new float4(-0.7071067f, 0f, -0.7071068f, -4.949747f), + new float4(0f, 0f, -1f, -4.99999952f), + new float4(0.7071067f, 0f, -0.7071068f, -4.949747f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Remove) #274")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model2 finding 2"); + } + + [Test] + public void ExtraFaceOnBox16Bottom_WithAddBox_Model2F03() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, -1f, 0f, -1f), + new float4(-0.707106769f, 0f, 0.707106769f, -5.656854f), + new float4(0.8944272f, 0f, -0.4472136f, -6.708204f), + new float4(0f, 0f, -1f, -1f), + new float4(0f, 0f, 1f, -1f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(5.00000238f, 0f, -19.00001f, 1f)), name: "Box #170")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1.45915264E-15f, 1f, -2.12873918E-09f, 1.06436939E-08f), + new float4(0f, -1f, 0f, -1f), + new float4(-1f, 4.4822762E-05f, -1.53269343E-06f, -7.99999142f), + new float4(1f, -4.43458557E-05f, 4.72003418E-14f, -7.99999952f), + new float4(-1.37090876E-06f, 4.386908E-05f, -1f, -8.999989f), + new float4(0f, -4.386902E-05f, 1f, -4.999999f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-1.36423772E-12f, -1f, -25f, 1f)), name: "Box #173")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -7f), + new float4(0f, -1f, 0f, -0.5f), + new float4(0f, 0f, -1f, -3.000001f), + new float4(1f, 0f, 0f, -3f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -7f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(11f, -1f, -21f, 1f)), name: "Box #176")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -1f), + new float4(0f, -1f, 0f, -3.5f), + new float4(0f, 0f, -1f, -1f), + new float4(1f, 0f, 0f, -1f), + new float4(0f, 1f, 0f, -6.5f), + new float4(0f, 0f, 1f, -1f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1.00000048f, 0f), new float4(7.5f, 2.5f, -18f, 1f)), name: "Box (16) #261")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model2 finding 3"); + } + + [Test] + public void ExtraFaceOnBoxBottom_WithAddBoxAndAddExtrudedShapeAdditiveAndSubExtrudedShapeRemove_Model2F05() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1.45915264E-15f, 1f, -2.12873918E-09f, 1.06436939E-08f), + new float4(0f, -1f, 0f, -1f), + new float4(-1f, 4.4822762E-05f, -1.53269343E-06f, -7.99999142f), + new float4(1f, -4.43458557E-05f, 4.72003418E-14f, -7.99999952f), + new float4(-1.37090876E-06f, 4.386908E-05f, -1f, -8.999989f), + new float4(0f, -4.386902E-05f, 1f, -4.999999f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-1.36423772E-12f, -1f, -25f, 1f)), name: "Box #173")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -12f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -3.5f), + new float4(1f, 0f, 0f, -4f), + new float4(0f, 1f, 0f, -1.5f), + new float4(0f, 0f, 1f, -3.5f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000024f, -1.77635726E-15f, -7.450583E-09f, 0f), new float4(-7.450582E-09f, -2.384186E-06f, 1f, 0f), new float4(-1.95399269E-14f, 1f, 2.384186E-06f, 0f), new float4(-4.000001f, -5.49999762f, -25.9999981f, 1f)), name: "Box #238")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 7.40718E-08f, -7f), + new float4(0f, 1f, -7.40718E-08f, 6f), + new float4(1f, 0f, 0f, -5.5f), + new float4(0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(0f, 0f, 1f, -5.49999952f), + new float4(-0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(-1f, 0f, 0f, -5.5f), + new float4(-0.707106769f, 0f, -0.7071068f, -5.656854f), + new float4(0f, 0f, -1f, -5.49999952f), + new float4(0.707106769f, 0f, -0.7071068f, -5.656854f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Additive) #240")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 8.141123E-08f, -7f), + new float4(0f, 1f, -8.141123E-08f, 6f), + new float4(1f, 0f, 0f, -5f), + new float4(0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(0f, 0f, 1f, -4.99999952f), + new float4(-0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(-1f, 0f, 0f, -5f), + new float4(-0.7071067f, 0f, -0.7071068f, -4.949747f), + new float4(0f, 0f, -1f, -4.99999952f), + new float4(0.7071067f, 0f, -0.7071068f, -4.949747f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Remove) #274")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model2 finding 5"); + } + + [Test] + public void ExtraFaceOnBoxTop_WithAddBox1AndAddExtrudedShapeAdditiveAndSubExtrudedShapeRemove_Model2F07() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -8f), + new float4(0f, -1f, 0f, -1.5f), + new float4(0f, 0f, -1f, -2f), + new float4(1f, 0f, 0f, -8f), + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, 0f, 1f, -2f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -0.5f, -32f, 1f)), name: "Box #174")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, -1f, 0f, -8.5f), + new float4(-0.707106769f, 1.95982852E-06f, 0.707106769f, -4.24263954f), + new float4(0.707106769f, 1.95982852E-06f, 0.707106769f, -4.24263954f), + new float4(0f, -2.771616E-06f, -1f, -2.00000143f), + new float4(0f, 2.771616E-06f, 1f, -1.99999857f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -0.5f, -36f, 1f)), name: "Box (1) #175")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 7.40718E-08f, -7f), + new float4(0f, 1f, -7.40718E-08f, 6f), + new float4(1f, 0f, 0f, -5.5f), + new float4(0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(0f, 0f, 1f, -5.49999952f), + new float4(-0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(-1f, 0f, 0f, -5.5f), + new float4(-0.707106769f, 0f, -0.7071068f, -5.656854f), + new float4(0f, 0f, -1f, -5.49999952f), + new float4(0.707106769f, 0f, -0.7071068f, -5.656854f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Additive) #240")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 8.141123E-08f, -7f), + new float4(0f, 1f, -8.141123E-08f, 6f), + new float4(1f, 0f, 0f, -5f), + new float4(0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(0f, 0f, 1f, -4.99999952f), + new float4(-0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(-1f, 0f, 0f, -5f), + new float4(-0.7071067f, 0f, -0.7071068f, -4.949747f), + new float4(0f, 0f, -1f, -4.99999952f), + new float4(0.7071067f, 0f, -0.7071068f, -4.949747f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Remove) #274")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model2 finding 7"); + } + } +} diff --git a/Core/Tests/Contents/SampleSceneExtraFaceTests.cs.meta b/Core/Tests/Contents/SampleSceneExtraFaceTests.cs.meta new file mode 100644 index 00000000..fb6cd8c6 --- /dev/null +++ b/Core/Tests/Contents/SampleSceneExtraFaceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5a725d007230bcc4790dc9f3a330aed3 \ No newline at end of file diff --git a/Core/Tests/Contents/SampleSceneExtrudedShapeTests.cs b/Core/Tests/Contents/SampleSceneExtrudedShapeTests.cs new file mode 100644 index 00000000..72ee9574 --- /dev/null +++ b/Core/Tests/Contents/SampleSceneExtrudedShapeTests.cs @@ -0,0 +1,125 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class SampleSceneExtrudedShapeTests + { + const float kSqrtHalf = 0.70710678f; + + static float4[] OctagonalPrism(float flat, float corner, float yMin, float yMax) + { + var diagonal = (flat + corner) * kSqrtHalf; + return new float4[] + { + new float4( 0f, -1f, 0f, yMin), // bottom cap: y >= yMin + new float4( 0f, +1f, 0f, -yMax), // top cap: y <= yMax + new float4(+1f, 0f, 0f, -flat), + new float4(-1f, 0f, 0f, -flat), + new float4( 0f, 0f, +1f, -flat), + new float4( 0f, 0f, -1f, -flat), + new float4(+kSqrtHalf, 0f, +kSqrtHalf, -diagonal), + new float4(+kSqrtHalf, 0f, -kSqrtHalf, -diagonal), + new float4(-kSqrtHalf, 0f, +kSqrtHalf, -diagonal), + new float4(-kSqrtHalf, 0f, -kSqrtHalf, -diagonal), + }; + } + + // The scene's two shapes, in the generator's own local space (before pivotOffset and the transform). + static float4[] AdditivePrism() { return OctagonalPrism(5.5f, 2.5f, -7f, -6f); } + static float4[] RemovePrism() { return OctagonalPrism(5.0f, 2.0f, -7f, -6f); } + + // Both generators sit at exactly this transform in the scene; pivotOffset is a plain translation applied + // between the node and the generated geometry (ChiselGeneratorComponent.LocalTransformation). + static float4x4 SceneTransform() + { + var nodeToTree = float4x4.TRS(new float3(0f, -1.5f, -30f), + quaternion.AxisAngle(new float3(0f, 1f, 0f), math.PI), // 180 about Y + new float3(1f, 1f, 1f)); + return math.mul(nodeToTree, float4x4.Translate(new float3(0f, 6.5f, 0f))); + } + + // Local y = -6 is the top cap. Through pivotOffset (+6.5) it becomes y = 0.5, the 180 degree turn about Y + // leaves Y alone, and the node sits at y = -1.5, so the cap lands on y = -1 facing +Y. + static readonly float4 kTopCap = new float4(0f, 1f, 0f, 1f); + + static ContentsScene ScenePrisms(bool withRemove) + { + var transform = SceneTransform(); + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(AdditivePrism(), localToTree: transform, + name: "Extruded Shape (Additive)")); + if (withRemove) + scene.Add(ContentsSceneNode.Brush(RemovePrism(), operation: CSGOperationType.Subtractive, + localToTree: transform, name: "Extruded Shape (Remove)")); + return scene; + } + + // Area of the output triangles lying on the top cap and facing the same way. Measured on the triangles + // rather than with the oracle's face sampler, matching ConcretePadTests. + static double TopCapArea(ContentsTreeHarness harness) + { + double area = 0; + foreach (var triangle in harness.Triangles) + { + if (math.dot(triangle.Normal, kTopCap.xyz) < 0.999f) + continue; + if (math.abs(math.dot(kTopCap.xyz, triangle.Center) + kTopCap.w) > 1e-3f) + continue; + area += 0.5 * math.length(math.cross(triangle.b - triangle.a, triangle.c - triangle.a)); + } + return area; + } + + static double MeasureTopCap(ContentsScene scene, string what) + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + return TopCapArea(harness); + } + } + + // The control: the larger prism alone shows its whole cap, 11 x 11 with four 3x3 corners cut off + [Test] + public void ThePrismOnItsOwn_ShowsItsWholeTopCap() + { + var area = MeasureTopCap(ScenePrisms(withRemove: false), "the additive prism alone"); + Assert.That(area, Is.EqualTo(103.0).Within(0.5), $"the prism's top cap is {area:0.00}"); + } + + [Test] + public void APrismWithACoaxialPrismSubtracted_LeavesARingOnItsTopCap() + { + var area = MeasureTopCap(ScenePrisms(withRemove: true), "the additive prism with the remove prism"); + Assert.That(area, Is.EqualTo(21.0).Within(0.5), + $"the cap should be a ring of 21 but is {area:0.00} " + + "(103 means the subtraction did nothing to it)"); + } + + // The same subtraction seen from the other end: both caps are coincident, so both become rings + [Test] + public void APrismWithACoaxialPrismSubtracted_LeavesARingOnItsBottomCap() + { + // local y = -7 -> +6.5 -> -0.5, node at -1.5, so the bottom cap lands on y = -2 facing -Y. + var bottomCap = new float4(0f, -1f, 0f, -2f); + using (var harness = ContentsTreeHarness.Build(ScenePrisms(withRemove: true))) + { + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + double area = 0; + foreach (var triangle in harness.Triangles) + { + if (math.dot(triangle.Normal, bottomCap.xyz) < 0.999f) + continue; + if (math.abs(math.dot(bottomCap.xyz, triangle.Center) + bottomCap.w) > 1e-3f) + continue; + area += 0.5 * math.length(math.cross(triangle.b - triangle.a, triangle.c - triangle.a)); + } + Assert.That(area, Is.EqualTo(21.0).Within(0.5), + $"the bottom cap should be a ring of 21 but is {area:0.00}"); + } + } + } +} diff --git a/Core/Tests/Contents/SampleSceneExtrudedShapeTests.cs.meta b/Core/Tests/Contents/SampleSceneExtrudedShapeTests.cs.meta new file mode 100644 index 00000000..9021378a --- /dev/null +++ b/Core/Tests/Contents/SampleSceneExtrudedShapeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2496b92242fdaa646941abc26f6430ea \ No newline at end of file diff --git a/Core/Tests/Contents/SampleSceneHarvestTests.cs b/Core/Tests/Contents/SampleSceneHarvestTests.cs new file mode 100644 index 00000000..e2d4de48 --- /dev/null +++ b/Core/Tests/Contents/SampleSceneHarvestTests.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Harvest")] + [Explicit("reads a dump written by an editor snippet; run it deliberately")] + public class SampleSceneHarvestTests + { + // Assigned by JsonUtility through reflection, which the compiler cannot see. +#pragma warning disable CS0649 + [Serializable] + class DumpEntry + { + public int model; + public int parent; + public int op; + public string name; + public int kind; // 0 branch, 1 brush + public int contents; + public float[] matrix; // 16 floats, float4x4 column by column + public float[] planes; // face planes, 4 floats each + public float[] vertices; // 3 floats each, the brush mesh's own + public int[] loops; // vertex indices per polygon, -1 after each + public int missingMesh; + } + + [Serializable] + class Dump + { + public string[] models; + public DumpEntry[] entries; + } +#pragma warning restore CS0649 + + static string HarvestDirectory => Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Library", "ChiselHarvest")); + + // The sample scene's dump; another scene's is named after that scene. + const string kSampleScene = "sample_scene"; + + // Null, with the reason, when there is no usable dump. + static Dump Load(string dumpName, out string problem) + { + var path = Path.Combine(HarvestDirectory, dumpName + ".json"); + problem = null; + if (!File.Exists(path)) + { + problem = "no dump at " + path + " - run the dump snippet first"; + return null; + } + var dump = JsonUtility.FromJson(File.ReadAllText(path)); + if (dump == null || dump.entries == null || dump.entries.Length == 0) + { + problem = "the dump at " + path + " did not parse or has no entries"; + return null; + } + return dump; + } + + static float4x4 Matrix(float[] m) + { + return new float4x4(new float4(m[0], m[1], m[2], m[3]), + new float4(m[4], m[5], m[6], m[7]), + new float4(m[8], m[9], m[10], m[11]), + new float4(m[12], m[13], m[14], m[15])); + } + + static float3[] Vertices(float[] v) + { + var result = new float3[v.Length / 3]; + for (int i = 0; i < result.Length; i++) + result[i] = new float3(v[i * 3], v[i * 3 + 1], v[i * 3 + 2]); + return result; + } + + static float4[] Planes(float[] p) + { + var result = new float4[p.Length / 4]; + for (int i = 0; i < result.Length; i++) + result[i] = new float4(p[i * 4], p[i * 4 + 1], p[i * 4 + 2], p[i * 4 + 3]); + return result; + } + + static ContentsScene SceneOf(Dump dump, int modelIndex, StringBuilder skipped, out int brushes) + { + var scene = new ContentsScene(); + var nodes = new Dictionary(); + brushes = 0; + for (int i = 0; i < dump.entries.Length; i++) + { + var e = dump.entries[i]; + if (e.model != modelIndex) + continue; + + ContentsSceneNode node; + if (e.kind == 1) + { + if (e.missingMesh != 0 || e.planes == null || e.planes.Length < 16) + { + skipped.AppendLine($" brush '{e.name}' (entry {i}) skipped: " + + (e.missingMesh != 0 ? "no brush mesh" : $"{(e.planes?.Length ?? 0) / 4} planes")); + continue; + } + node = ContentsSceneNode.Brush(Planes(e.planes), e.contents, (CSGOperationType)e.op, + Matrix(e.matrix), $"{e.name} #{i}"); + // its own mesh too, so the harness derives the very planes the scene's CSG was given + if (e.vertices != null && e.vertices.Length >= 12 && e.loops != null && e.loops.Length > 0) + node.WithMesh(Vertices(e.vertices), e.loops); + brushes++; + } + else + { + node = ContentsSceneNode.Composite((CSGOperationType)e.op); + node.name = $"{e.name} #{i}"; + } + + nodes[i] = node; + if (e.parent < 0) + scene.Add(node); + else if (nodes.TryGetValue(e.parent, out var parent)) + parent.children.Add(node); + else + skipped.AppendLine($" '{e.name}' (entry {i}) skipped: its parent {e.parent} was skipped"); + } + return scene; + } + + // Per model. Sites are shrunk cheapest first, and whatever the budget does not reach is listed in the report + // as NOT REACHED rather than dropped, so a second run can be aimed at it. + const double kBudgetSeconds = 600; + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void HarvestModel(int modelIndex) + { + var result = Harvest(modelIndex, kBudgetSeconds); + if (result.StartsWith(kInconclusive, StringComparison.Ordinal)) + Assert.Inconclusive(result); + // A harvester, not an assertion: it passes with a count. The report file is where the cases are. + Assert.Pass(result); + } + + const string kInconclusive = "INCONCLUSIVE: "; + + /// One model of the dump as a scene description, or null with the reason. + public static ContentsScene LoadModel(int modelIndex, out string modelName, out string problem) + { + return LoadModel(kSampleScene, modelIndex, out modelName, out problem); + } + + /// One model of a scene's dump as a scene description, or null with the reason. + public static ContentsScene LoadModel(string dumpName, int modelIndex, out string modelName, out string problem) + { + modelName = null; + var dump = Load(dumpName, out problem); + if (dump == null) + return null; + return LoadModel(dump, modelIndex, out modelName, out problem); + } + + // The same from a dump already read, so a caller that goes through many models parses the file once + static ContentsScene LoadModel(Dump dump, int modelIndex, out string modelName, out string problem) + { + modelName = null; + problem = null; + if (modelIndex >= dump.models.Length) + { + problem = $"the dump has {dump.models.Length} model(s)"; + return null; + } + modelName = dump.models[modelIndex]; + var scene = SceneOf(dump, modelIndex, new StringBuilder(), out var brushCount); + if (brushCount == 0) + { + problem = $"model '{modelName}' has no brushes in the dump - UNBUILT"; + return null; + } + return scene; + } + + /// + /// Judges a live model's meshes against the oracle for the dumped description of the same model. `triangles` is 9 + /// floats per triangle in the model's space; the report goes to Library/ChiselHarvest/live_check_<model>.txt. + /// + public static string CheckLiveModel(int modelIndex, float[] triangles) + { + var scene = LoadModel(modelIndex, out var modelName, out var problem); + if (scene == null) + return kInconclusive + problem; + var report = LiveOutputCheck.Run(scene, triangles, $"live {modelName}"); + Directory.CreateDirectory(HarvestDirectory); + var outPath = Path.Combine(HarvestDirectory, $"live_check_{modelIndex}.txt"); + File.WriteAllText(outPath, report); + var firstLine = report.Split('\n')[0]; + return $"{firstLine} -> {outPath}"; + } + + /// + /// Compares a live model's triangles, bit for bit, with what the harness builds from the dump of the same model. The + /// live triangles are read from Library/ChiselHarvest/<dumpName>_live_<model>.bin (9 little-endian floats + /// per triangle, in the model's space); the report goes to <dumpName>_live_<model>.txt. + /// + public static string CompareLiveModel(string dumpName, int modelIndex) + { + var scene = LoadModel(dumpName, modelIndex, out var modelName, out var problem); + if (scene == null) + return kInconclusive + problem; + using (var harness = ContentsTreeHarness.Build(scene)) + { + harness.JudgeEachUpdate = false; + if (!harness.Update()) + return kInconclusive + "the harness's CSG update did not run"; + return CompareWithLive(harness, scene, dumpName, modelIndex, modelName); + } + } + + // CompareLiveModel's comparison, on a harness whose update has run + static string CompareWithLive(ContentsTreeHarness harness, ContentsScene scene, string dumpName, int modelIndex, string modelName) + { + var livePath = Path.Combine(HarvestDirectory, $"{dumpName}_live_{modelIndex}.bin"); + if (!File.Exists(livePath)) + return kInconclusive + "no live triangles at " + livePath; + var bytes = File.ReadAllBytes(livePath); + var live = new float[bytes.Length / 4]; + Buffer.BlockCopy(bytes, 0, live, 0, live.Length * 4); + + var liveCounts = new Dictionary(); + for (int t = 0; t + 8 < live.Length; t += 9) + { + var key = TriangleKey.Of(new float3(live[t], live[t + 1], live[t + 2]), new float3(live[t + 3], live[t + 4], live[t + 5]), + new float3(live[t + 6], live[t + 7], live[t + 8])); + liveCounts.TryGetValue(key, out var count); + liveCounts[key] = count + 1; + } + + var report = new StringBuilder(); + { + var built = new Dictionary(); + var brushOf = new Dictionary(); + // The harness's triangles too, for an offline comparison that does not depend on how a face is cut into + // triangles: 9 floats, then the brush's index in the dump's order, per triangle. + var brushIndex = new Dictionary(); + foreach (var node in ContentsHarvest.AllBrushes(scene)) + brushIndex[node] = brushIndex.Count; + var harnessData = new List(harness.Triangles.Count * 10); + foreach (var triangle in harness.Triangles) + { + var key = TriangleKey.Of(triangle.a, triangle.b, triangle.c); + built.TryGetValue(key, out var count); + built[key] = count + 1; + var node = harness.NodeOf(triangle.brushID); + brushOf[key] = node?.name ?? "?"; + harnessData.Add(triangle.a.x); harnessData.Add(triangle.a.y); harnessData.Add(triangle.a.z); + harnessData.Add(triangle.b.x); harnessData.Add(triangle.b.y); harnessData.Add(triangle.b.z); + harnessData.Add(triangle.c.x); harnessData.Add(triangle.c.y); harnessData.Add(triangle.c.z); + harnessData.Add(node != null && brushIndex.TryGetValue(node, out var index) ? index : -1); + } + var harnessBytes = new byte[harnessData.Count * 4]; + Buffer.BlockCopy(harnessData.ToArray(), 0, harnessBytes, 0, harnessBytes.Length); + Directory.CreateDirectory(HarvestDirectory); + File.WriteAllBytes(Path.Combine(HarvestDirectory, $"{dumpName}_harness_{modelIndex}.bin"), harnessBytes); + + var missing = new List<(TriangleKey key, int count)>(); + var extra = new List<(TriangleKey key, int count)>(); + foreach (var pair in built) + { + liveCounts.TryGetValue(pair.Key, out var liveCount); + if (liveCount < pair.Value) + missing.Add((pair.Key, pair.Value - liveCount)); + } + foreach (var pair in liveCounts) + { + built.TryGetValue(pair.Key, out var builtCount); + if (builtCount < pair.Value) + extra.Add((pair.Key, pair.Value - builtCount)); + } + double missingArea = 0, extraArea = 0; + foreach (var (key, count) in missing) missingArea += key.Area * count; + foreach (var (key, count) in extra) extraArea += key.Area * count; + missing.Sort((x, y) => y.key.Area.CompareTo(x.key.Area)); + extra.Sort((x, y) => y.key.Area.CompareTo(x.key.Area)); + + report.AppendLine($"# {dumpName} / {modelName}: live {live.Length / 9} triangles, harness {harness.Triangles.Count}; " + + $"{missing.Count} harness triangle(s) missing from the live meshes (area {missingArea:0.####}), " + + $"{extra.Count} live triangle(s) the harness does not make (area {extraArea:0.####}); " + + (harness.PlaneMismatches.Count == 0 ? "every brush replayed with exactly the scene's planes" + : $"{harness.PlaneMismatches.Count} brush(es) replayed with OTHER planes, e.g. {harness.PlaneMismatches[0]}")); + report.AppendLine("## missing from the live meshes, largest first:"); + for (int i = 0; i < missing.Count && i < 40; i++) + report.AppendLine($" {brushOf[missing[i].key]}: {missing[i].key} area {missing[i].key.Area:0.######}" + + (missing[i].count > 1 ? $" x{missing[i].count}" : "")); + report.AppendLine("## in the live meshes only, largest first:"); + for (int i = 0; i < extra.Count && i < 40; i++) + report.AppendLine($" {extra[i].key} area {extra[i].key.Area:0.######}" + (extra[i].count > 1 ? $" x{extra[i].count}" : "")); + } + Directory.CreateDirectory(HarvestDirectory); + var outPath = Path.Combine(HarvestDirectory, $"{dumpName}_live_{modelIndex}.txt"); + File.WriteAllText(outPath, report.ToString()); + return report.ToString().Split('\n')[0] + " -> " + outPath; + } + + const char Tab = (char)9; + static string OneLine(string text) => text.Replace(Tab, ' ').Replace((char)13, ' ').Replace((char)10, ' '); + + /// + /// Checks many dumped models in one call: one replay per model, compared with the live triangles + /// () and swept by the exact judge. `modelIndices` is "0,1,2". Returns one + /// tab-separated line per model: index, name, live comparison, judge, seconds. + /// + public static string CheckModels(string dumpName, string modelIndices) + { + var dump = Load(dumpName, out var problem); + if (dump == null) + return kInconclusive + problem; + var lines = new StringBuilder(); + var progressPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Library", "ChiselHarvest", $"check_{dumpName}.txt")); + foreach (var part in modelIndices.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + var modelIndex = int.Parse(part.Trim()); + string live, judge; + var scene = LoadModel(dump, modelIndex, out var modelName, out var loadProblem); + if (scene == null) + { + live = judge = kInconclusive + loadProblem; + } else + { + try + { + using var harness = ContentsTreeHarness.Build(scene); + harness.JudgeEachUpdate = false; // the sweep below reports every disagreement instead of throwing on the first + if (!harness.Update()) + { + live = judge = kInconclusive + "the harness's CSG update did not run"; + } else + { + live = CompareWithLive(harness, scene, dumpName, modelIndex, modelName); + var mismatches = ContentsComparison.FindMismatchRecords(harness, out var checkedSamples, out var skipped, + out var unexamined, ContentsHarvest.RequiredExtent(scene), 8, + budgeted: true); + judge = $"{mismatches.Count} mismatch(es) over {checkedSamples} judged ({skipped} skipped)" + + (unexamined.Count > 0 ? $"; {unexamined.Count} face(s) NOT examined, first: {unexamined[0]}" : "") + + (harness.PlaneMismatches.Count > 0 ? $"; REPLAY IS NOT THE SCENE: {harness.PlaneMismatches.Count} brush(es) with other planes" : "") + + (ExactJudge.LastDroppedByTheWeld > 0 ? $"; {ExactJudge.LastDroppedByTheWeld} exact triangle(s) the weld drops" : "") + + (ExactJudge.LastFlippedByTheWeld > 0 ? $"; {ExactJudge.LastFlippedByTheWeld} needle(s) the weld flips away" : "") + + (ExactJudge.LastSplitByTheWeld > 0 ? $"; {ExactJudge.LastSplitByTheWeld} needle(s) split into another brush's triangle" : "") + + (ExactJudge.LastPairedByTheWeld > 0 ? $"; {ExactJudge.LastPairedByTheWeld} pair(s) of facing needles dropped" : "") + + (ExactJudge.LastCancelledByTheWeld > 0 ? $"; {ExactJudge.LastCancelledByTheWeld} pair(s) of opposite triangles cancelled" : "") + + (mismatches.Count > 0 ? "; first: " + mismatches[0].Format("exact") : ""); + } + } + catch (Exception exception) + { + live = judge = "THREW " + exception.GetType().Name + ": " + exception.Message; + } + } + var line = new StringBuilder(); + line.Append(modelIndex).Append(Tab).Append(modelName).Append(Tab).Append(OneLine(live)) + .Append(Tab).Append(OneLine(judge)).Append(Tab) + .Append(clock.Elapsed.TotalSeconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)).Append('\n'); + lines.Append(line); + File.AppendAllText(progressPath, line.ToString()); + } + return lines.ToString(); + } + + public static string ProfileModel(string dumpName, int modelIndex) + { + var path = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Library", "ChiselHarvest", + $"profile_{dumpName}_{modelIndex}.txt")); + var clock = System.Diagnostics.Stopwatch.StartNew(); + using var writer = new StreamWriter(path, false) { AutoFlush = true }; + void Note(string text) => writer.WriteLine(clock.Elapsed.TotalSeconds.ToString("0.000", System.Globalization.CultureInfo.InvariantCulture) + " " + text); + + var dump = Load(dumpName, out var problem); + if (dump == null) + { + Note("no dump: " + problem); + return kInconclusive + problem; + } + var scene = LoadModel(dump, modelIndex, out var modelName, out var loadProblem); + if (scene == null) + { + Note("no model: " + loadProblem); + return kInconclusive + loadProblem; + } + Note($"loaded {modelName}"); + using var harness = ContentsTreeHarness.Build(scene); + harness.JudgeEachUpdate = false; + Note("harness built"); + if (!harness.Update()) + { + Note("the CSG update did not run"); + return kInconclusive + "the harness's CSG update did not run"; + } + Note("CSG update done"); + var live = CompareWithLive(harness, scene, dumpName, modelIndex, modelName); + Note("live compared: " + OneLine(live)); + ExactJudge.progress = Note; + try + { + var mismatches = ContentsComparison.FindMismatchRecords(harness, out var checkedSamples, out var skipped, + out var unexamined, ContentsHarvest.RequiredExtent(scene), 8, budgeted: true); + Note($"judged: {mismatches.Count} mismatch(es) over {checkedSamples}; {unexamined.Count} plane(s) NOT judged" + (unexamined.Count > 0 ? ", first: " + unexamined[0] : "")); + } + finally + { + ExactJudge.progress = null; + } + return path; + } + + // A triangle as its vertices' float bits, turned to the smallest of its three rotations: the same triangle wound + // the same way always gives the same key, and the other winding a different one. + readonly struct TriangleKey : IEquatable + { + readonly int3 a, b, c; + + TriangleKey(int3 a, int3 b, int3 c) { this.a = a; this.b = b; this.c = c; } + + public static TriangleKey Of(float3 a, float3 b, float3 c) + { + var ia = math.asint(a); var ib = math.asint(b); var ic = math.asint(c); + var best = new TriangleKey(ia, ib, ic); + var r1 = new TriangleKey(ib, ic, ia); + var r2 = new TriangleKey(ic, ia, ib); + if (r1.Less(best)) best = r1; + if (r2.Less(best)) best = r2; + return best; + } + + bool Less(TriangleKey other) + { + int order = Order(a, other.a); + if (order == 0) order = Order(b, other.b); + if (order == 0) order = Order(c, other.c); + return order < 0; + } + + static int Order(int3 p, int3 q) => p.x != q.x ? p.x.CompareTo(q.x) : (p.y != q.y ? p.y.CompareTo(q.y) : p.z.CompareTo(q.z)); + + public double Area + { + get + { + var pa = (double3)math.asfloat(a); var pb = (double3)math.asfloat(b); var pc = (double3)math.asfloat(c); + return math.length(math.cross(pb - pa, pc - pa)) * 0.5; + } + } + + public bool Equals(TriangleKey other) => a.Equals(other.a) && b.Equals(other.b) && c.Equals(other.c); + public override bool Equals(object obj) => obj is TriangleKey other && Equals(other); + public override int GetHashCode() => (a.GetHashCode() * 31 + b.GetHashCode()) * 31 + c.GetHashCode(); + + public override string ToString() + { + string P(int3 p) { var f = math.asfloat(p); return $"({f.x.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}, " + + $"{f.y.ToString("R", System.Globalization.CultureInfo.InvariantCulture)}, {f.z.ToString("R", System.Globalization.CultureInfo.InvariantCulture)})"; } + return $"{P(a)} {P(b)} {P(c)}"; + } + } + + /// + /// The harvest of one model as a plain call, so it can run without the test runner (which saves and reloads the + /// open scene). Returns a one-line summary; the cases are in the report file. + /// + public static string Harvest(int modelIndex, double budgetSeconds) + { + return Harvest(kSampleScene, modelIndex, budgetSeconds); + } + + /// + /// The same for a scene dumped to Library/ChiselHarvest/<dumpName>.json; the report goes to + /// <dumpName>_harvest_<model>.txt. A budget of 0 lists every failing spot without shrinking it. + /// + public static string Harvest(string dumpName, int modelIndex, double budgetSeconds) + { + var dump = Load(dumpName, out var problem); + if (dump == null) + return kInconclusive + problem; + if (modelIndex >= dump.models.Length) + return kInconclusive + $"the dump has {dump.models.Length} model(s)"; + + var modelName = dump.models[modelIndex]; + var skipped = new StringBuilder(); + var scene = SceneOf(dump, modelIndex, skipped, out var brushCount); + if (brushCount == 0) + return kInconclusive + $"model '{modelName}' has no brushes in the dump - UNBUILT, not clean"; + + // The report is written as the harvest goes, so a stopped run keeps what it found + Directory.CreateDirectory(HarvestDirectory); + var outPath = Path.Combine(HarvestDirectory, $"{dumpName}_harvest_{modelIndex}.txt"); + var label = $"{dumpName} / {modelName}"; + var header = new StringBuilder(); + header.AppendLine($"# {label}: {brushCount} brush(es), budget {budgetSeconds} s"); + if (skipped.Length > 0) + { + header.AppendLine(" NOT replayed (so not examined):"); + header.Append(skipped); + } + File.WriteAllText(outPath, header.ToString()); + + // One minimal scene per failing spot, not one per model + var findings = ContentsHarvest.HarvestSites(scene, label, budgetSeconds, chunk => File.AppendAllText(outPath, chunk)); + return $"{modelName}: {brushCount} brushes, {findings.Count} distinct minimal failing scene(s); report at {outPath}"; + } + } +} diff --git a/Core/Tests/Contents/SampleSceneHarvestTests.cs.meta b/Core/Tests/Contents/SampleSceneHarvestTests.cs.meta new file mode 100644 index 00000000..7fe64e69 --- /dev/null +++ b/Core/Tests/Contents/SampleSceneHarvestTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0d36aefd6ebeb217f6ae49fee11ced54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Core/Tests/Contents/SampleSceneHoleTests.cs b/Core/Tests/Contents/SampleSceneHoleTests.cs new file mode 100644 index 00000000..7a62fcab --- /dev/null +++ b/Core/Tests/Contents/SampleSceneHoleTests.cs @@ -0,0 +1,277 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + [Category("CSGCorpus")] + public class SampleSceneHoleTests + { + [Test] + public void HoleOnBoxTop_WithSubBoxAndAddBox39_Model1F01() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -13f), + new float4(0f, -1f, 0f, -3f), + new float4(0f, 0f, -1f, -13f), + new float4(1f, 0f, 0f, -13f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -12f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, -2.98022229E-08f, 1f)), name: "Box #0")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -3f), + new float4(0f, -1f, 0f, -1f), + new float4(0f, 0f, -1f, -3f), + new float4(1f, 0f, 0f, -3f), + new float4(0f, 1f, 0f, 0f), + new float4(0f, 0f, 1f, -3f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(0f, 2.38418636E-07f, 1.00000024f, 0f), new float4(1.00000024f, 0f, 0f, 0f), new float4(0f, 1.00000024f, -2.38418636E-07f, 0f), new float4(-11.0000067f, -3.249999f, 1.99999976f, 1f)), name: "Box #142")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -1.75f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -0.25f), + new float4(1f, 0f, 0f, -1.75f), + new float4(0f, 1f, 0f, -0.5f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-11.25f, -6.75f, 4.75f, 1f)), name: "Box (39) #145")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 1"); + } + + [Test] + public void HoleOnBoxNegZ_WithAddBox9AndAddBox_Model2F06() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.5f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -5.75f), + new float4(1f, 0f, 0f, -0.5f), + new float4(0f, 1f, 0f, -15f), + new float4(0f, 0f, 1f, -2.25f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 5.617607E-07f, 0f), new float4(5.61760658E-07f, -1.192093E-07f, 1.00000024f, 0f), new float4(0f, 1.00000024f, -1.192093E-07f, 0f), new float4(18.5f, -3.24999762f, -33.0000038f, 1f)), name: "Box (9) #251")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -34f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -4f), + new float4(1f, 0f, 0f, -3.5f), + new float4(0f, 1f, 0f, -0.9999989f), + new float4(0f, 0f, 1f, -17f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000024f, 0f, 0f, 0f), new float4(0f, 3.57627925E-07f, 1f, 0f), new float4(0f, 1f, -3.57627925E-07f, 0f), new float4(15.5000038f, -4.999997f, -17.9999981f, 1f)), name: "Box #262")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1.4850649E-06f, 1f, -4.95021624E-08f, -0.4999974f), + new float4(1.50021856E-06f, -1f, 5.00072872E-08f, 3.7505464E-07f), + new float4(-1f, 0f, 0f, -0.25f), + new float4(1f, 0f, 0f, -0.25f), + new float4(0f, 0f, -1f, -7.5f), + new float4(0.707106769f, 0f, 0.707106769f, -5.126524f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, -1.685874E-07f, 0f), new float4(0f, -1.685874E-07f, -1f, 0f), new float4(18.75f, -1.5f, -25.5f, 1f)), name: "Box #341")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model2 finding 6"); + } + + [Test] + public void HoleOnExtrudedShapeTop_WithAddBox34AndAddBoxAndAddBox46_Model1F02() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 6.953876E-08f, 0.5f), + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, 0f, -1f, -1.49999988f), + new float4(0.9486831f, 0f, -0.31622833f, -10.9098549f), + new float4(0f, 0f, 1f, -1.49999988f), + new float4(-0.948683262f, 0f, -0.3162278f, -10.9098577f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, -6.75f, 13.4999981f, 1f)), name: "Extruded Shape #7")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -3.5f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -1f), + new float4(1f, 0f, 0f, -3.5f), + new float4(0f, 1f, 0f, -2f), + new float4(0f, 0f, 1f, -1f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, 12f, 1f)), name: "Box (34) #135")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -1.25f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -0.249999523f), + new float4(1f, 0f, 0f, -1.25f), + new float4(0f, 1f, 0f, -0.500000238f), + new float4(0f, 0f, 1f, -0.249999523f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-4.75f, -4.75f, 13.25f, 1f)), name: "Box #136")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -0.75f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -0.7500001f), + new float4(1f, 0f, 0f, -0.75f), + new float4(0f, 1f, 0f, -1.5f), + new float4(0f, 0f, 1f, -0.7500001f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(0.05914691f, 0f, 0.9982498f, 0f), new float4(0f, 1f, 0f, 0f), new float4(-0.9982498f, 0f, 0.05914691f, 0f), new float4(-3f, -4.25f, 12.5f, 1f)), name: "Box (46) #155")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 2"); + } + + [Test] + public void HoleOnExtrudedShapeNegZ_WithSubBoxAndAddBox_Model1F04() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 6.953876E-08f, 0.5f), + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, 0f, -1f, -1.49999988f), + new float4(0.9486831f, 0f, -0.31622833f, -10.9098549f), + new float4(0f, 0f, 1f, -1.49999988f), + new float4(-0.948683262f, 0f, -0.3162278f, -10.9098577f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, -6.75f, 13.4999981f, 1f)), name: "Extruded Shape #7")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -2.5f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -0.25f), + new float4(1f, 0f, 0f, -1.5f), + new float4(0f, 1f, 0f, -1.5f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(-8.5f, -5.75f, 12.125f, 1f)), name: "Box #22")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 3.32604832E-05f, -1.66302416E-05f), + new float4(0f, -0.7071071f, -0.7071064f, -0.353506476f), + new float4(-0.5773246f, -0.5773634f, -0.5773628f, -6.639214f), + new float4(0.5773246f, -0.5773634f, -0.5773628f, -6.639214f), + new float4(0f, 3.36181474E-05f, 1f, -0.5f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -4.25f, 12.5f, 1f)), name: "Box #29")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -4.5f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -1f), + new float4(1f, 0f, 0f, -4.5f), + new float4(0f, 1f, 0f, -2f), + new float4(0f, 0f, 1f, -1f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, 12f, 1f)), name: "Box #30")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 4"); + } + + [Test] + public void HoleOnExtrudedShapeNegZ_WithSubBoxAndAddBox_Model1F08() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 6.953876E-08f, 0.5f), + new float4(0f, 1f, 0f, -2.5f), + new float4(0f, 0f, -1f, -1.49999988f), + new float4(0.9486831f, 0f, -0.31622833f, -10.9098549f), + new float4(0f, 0f, 1f, -1.49999988f), + new float4(-0.948683262f, 0f, -0.3162278f, -10.9098577f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, -6.75f, 13.4999981f, 1f)), name: "Extruded Shape #7")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -1.5f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -0.25f), + new float4(1f, 0f, 0f, -2.5f), + new float4(0f, 1f, 0f, -1.5f), + new float4(0f, 0f, 1f, -0.25f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(8.5f, -5.75f, 12.125f, 1f)), name: "Box #26")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 3.32604832E-05f, -1.66302416E-05f), + new float4(0f, -0.7071071f, -0.7071064f, -0.353506476f), + new float4(-0.5773246f, -0.5773634f, -0.5773628f, -6.639214f), + new float4(0.5773246f, -0.5773634f, -0.5773628f, -6.639214f), + new float4(0f, 3.36181474E-05f, 1f, -0.5f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -4.25f, 12.5f, 1f)), name: "Box #29")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -4.5f), + new float4(0f, -1f, 0f, 0f), + new float4(0f, 0f, -1f, -1f), + new float4(1f, 0f, 0f, -4.5f), + new float4(0f, 1f, 0f, -2f), + new float4(0f, 0f, 1f, -1f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -6.25f, 12f, 1f)), name: "Box #30")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model1 finding 8"); + } + + [Test] + public void HoleOnExtrudedShapeBottom_WithAddBoxAndAddBox15AndAddExtrudedShape2_Model2F04() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1.74902783E-14f, -1f, 5.9604627E-08f, -1f), + new float4(1.74902783E-14f, 1f, -5.9604627E-08f, 0.5f), + new float4(-0.8944274f, 0f, 0.4472131f, -5.813776f), + new float4(-0.5144956f, 0f, -0.857493f, -1.11474156f), + new float4(0.8944272f, 0f, -0.44721362f, -5.81377649f), + new float4(0.51449573f, 0f, 0.8574929f, -1.11474061f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-7.500004f, 0f, -16.0000076f, 1f)), name: "Extruded Shape #172")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1.45915264E-15f, 1f, -2.12873918E-09f, 1.06436939E-08f), + new float4(0f, -1f, 0f, -1f), + new float4(-1f, 4.4822762E-05f, -1.53269343E-06f, -7.99999142f), + new float4(1f, -4.43458557E-05f, 4.72003418E-14f, -7.99999952f), + new float4(-1.37090876E-06f, 4.386908E-05f, -1f, -8.999989f), + new float4(0f, -4.386902E-05f, 1f, -4.999999f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-1.36423772E-12f, -1f, -25f, 1f)), name: "Box #173")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -1f), + new float4(0f, -1f, 0f, -3.5f), + new float4(0f, 0f, -1f, -1f), + new float4(1f, 0f, 0f, -1f), + new float4(0f, 1f, 0f, -6.5f), + new float4(0f, 0f, 1f, -1f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, 1.00000048f, 0f), new float4(-7.5f, 2.5f, -18f, 1f)), name: "Box (15) #260")) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(9.169917E-09f, -1f, 7.488786E-08f, -1f), + new float4(-5.73119063E-09f, 1f, -6.667312E-08f, 0.5f), + new float4(-0.8944274f, 0f, 0.447213173f, -5.813776f), + new float4(-0.5144956f, 0f, -0.857493f, -1.11474156f), + new float4(0.8944272f, 0f, -0.4472136f, -5.81377649f), + new float4(0.51449573f, 0f, 0.8574929f, -0.5573704f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(-7.500004f, 0f, -16.0000076f, 1f)), name: "Extruded Shape (2) #319")) + ; + ContentsHarvest.AssertMatchesOracle(scene, "sample scene Model2 finding 4"); + } + } +} diff --git a/Core/Tests/Contents/SampleSceneHoleTests.cs.meta b/Core/Tests/Contents/SampleSceneHoleTests.cs.meta new file mode 100644 index 00000000..3393f3fa --- /dev/null +++ b/Core/Tests/Contents/SampleSceneHoleTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 29afecfdaa06c6b4a8a1c40b35fb00e9 \ No newline at end of file diff --git a/Core/Tests/Contents/SampleSceneOpeningTests.cs b/Core/Tests/Contents/SampleSceneOpeningTests.cs new file mode 100644 index 00000000..80892430 --- /dev/null +++ b/Core/Tests/Contents/SampleSceneOpeningTests.cs @@ -0,0 +1,204 @@ +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class SampleSceneOpeningTests + { + // Nothing may be drawn with its centre strictly inside the box: the opening's inside, kept clear of its walls + static void AssertNothingDrawnIn(ContentsScene scene, float3 min, float3 max, string what) + { + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + Assert.That(harness.PlaneMismatches, Is.Empty, what + ": the brushes were not given the scene's planes"); + Assert.That(harness.Triangles.Count, Is.GreaterThan(0), what + ": nothing was drawn at all"); + var inside = harness.Triangles.Where(t => math.all(t.Center > min) && math.all(t.Center < max)).ToList(); + double area = inside.Sum(t => (double)math.length(math.cross(t.b - t.a, t.c - t.a)) * 0.5); + Assert.That(inside, Is.Empty, $"{what}: {inside.Count} triangle(s) drawn inside the opening, {area:0.####} in all, e.g. " + + string.Join("; ", inside.Take(4).Select(t => $"({t.a.x:R}, {t.a.y:R}, {t.a.z:R}) ({t.b.x:R}, {t.b.y:R}, {t.b.z:R}) ({t.c.x:R}, {t.c.y:R}, {t.c.z:R}) facing {t.Normal}"))); + } + } + + // "Box (Door)" (additive, the wall) and the subtractive "Box" that cuts the doorway through it: x -2..2, + // y -8.5..-4.5, through the wall's whole thickness z -38..-34. + [Test] + public void TheDoorway_ThroughBoxDoor_IsOpen() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, -1f, 0f, -8.5f), + new float4(-0.707106769f, 1.95982852e-06f, 0.707106769f, -4.24263954f), + new float4(0.707106769f, 1.95982852e-06f, 0.707106769f, -4.24263954f), + new float4(0f, -2.771616e-06f, -1f, -2.00000143f), + new float4(0f, 2.771616e-06f, 1f, -1.99999857f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -0.5f, -36f, 1f)), name: "Box (Door) #175") + .WithMesh(new float3[] { new float3(-8f, -0.5f, -2f), new float3(-4f, -0.5f, 2f), new float3(4f, -0.5f, 2f), new float3(8f, -0.5f, -2f), new float3(-8f, -8.5f, -1.99997783f), new float3(8f, -8.5f, -1.99997783f), new float3(4f, -8.5f, 2.00002217f), new float3(-4f, -8.5f, 2.00002217f) }, + new int[] { 0, 1, 2, 3, -1, 4, 5, 6, 7, -1, 0, 4, 7, 1, -1, 3, 2, 6, 5, -1, 0, 3, 5, 4, -1, 1, 7, 6, 2, -1 })) + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -2f), + new float4(0f, -1f, 0f, -4f), + new float4(0f, 0f, -1f, -1.99999988f), + new float4(1f, 0f, 0f, -2f), + new float4(0f, 1f, 0f, -1f), + new float4(0f, 0f, 1f, -1.99999988f), + }, contents: 0, operation: CSGOperationType.Subtractive, + localToTree: new float4x4(new float4(1f, 0f, 0f, 0f), new float4(0f, -1.192093e-07f, -1.00000024f, 0f), new float4(0f, 1.00000024f, -1.192093e-07f, 0f), new float4(0f, -6.5f, -38f, 1f)), name: "Box #236") + .WithMesh(new float3[] { new float3(-2f, -4f, 1.99999988f), new float3(-2f, -4f, -1.99999988f), new float3(2f, -4f, -1.99999988f), new float3(2f, -4f, 1.99999988f), new float3(-2f, 1f, 1.99999988f), new float3(-2f, 1f, -1.99999988f), new float3(2f, 1f, -1.99999988f), new float3(2f, 1f, 1.99999988f) }, + new int[] { 1, 0, 4, 5, -1, 3, 0, 1, 2, -1, 6, 2, 1, 5, -1, 3, 2, 6, 7, -1, 4, 7, 6, 5, -1, 4, 0, 3, 7, -1 })) + ; + AssertNothingDrawnIn(scene, new float3(-1.9f, -8.4f, -38.1f), new float3(1.9f, -4.6f, -33.9f), "the doorway through Box (Door)"); + } + + [Test] + public void ThePitUnderExtrudedShapeRemove_IsOpen() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -8f), + new float4(0f, -1f, 0f, -1.5f), + new float4(0f, 0f, -1f, -2f), + new float4(1f, 0f, 0f, -8f), + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, 0f, 1f, -2f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -0.5f, -32f, 1f)), name: "Box #174") + .WithMesh(new float3[] { new float3(-8f, -1.5f, 2f), new float3(-8f, -1.5f, -2f), new float3(8f, -1.5f, -2f), new float3(8f, -1.5f, 2f), new float3(-8f, -0.5f, 2f), new float3(-8f, -0.5f, -2f), new float3(8f, -0.5f, -2f), new float3(8f, -0.5f, 2f) }, + new int[] { 1, 0, 4, 5, -1, 3, 0, 1, 2, -1, 6, 2, 1, 5, -1, 3, 2, 6, 7, -1, 4, 7, 6, 5, -1, 4, 0, 3, 7, -1 })) + .Add(ContentsSceneNode.Composite(CSGOperationType.Additive, + ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 7.40718e-08f, -7f), + new float4(0f, 1f, -7.40718e-08f, 6f), + new float4(1f, 0f, 0f, -5.5f), + new float4(0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(0f, 0f, 1f, -5.49999952f), + new float4(-0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(-1f, 0f, 0f, -5.5f), + new float4(-0.707106769f, 0f, -0.7071068f, -5.656854f), + new float4(0f, 0f, -1f, -5.49999952f), + new float4(0.707106769f, 0f, -0.7071068f, -5.656854f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Additive) #240") + .WithMesh(new float3[] { new float3(2.5f, -6.99999952f, 5.49999952f), new float3(-2.5f, -6.99999952f, 5.49999952f), new float3(-5.5f, -7f, 2.49999976f), new float3(-5.5f, -7f, -2.49999976f), new float3(-2.5f, -7.00000048f, -5.49999952f), new float3(2.5f, -7.00000048f, -5.49999952f), new float3(5.5f, -7f, -2.49999976f), new float3(5.5f, -7f, 2.49999976f), new float3(2.5f, -5.99999952f, 5.49999952f), new float3(-2.5f, -5.99999952f, 5.49999952f), new float3(-5.5f, -6f, 2.49999976f), new float3(-5.5f, -6f, -2.49999976f), new float3(-2.5f, -6.00000048f, -5.49999952f), new float3(2.5f, -6.00000048f, -5.49999952f), new float3(5.5f, -6f, -2.49999976f), new float3(5.5f, -6f, 2.49999976f) }, + new int[] { 0, 1, 2, 3, 4, 5, 6, 7, -1, 14, 13, 12, 11, 10, 9, 8, 15, -1, 6, 14, 15, 7, -1, 7, 15, 8, 0, -1, 0, 8, 9, 1, -1, 1, 9, 10, 2, -1, 2, 10, 11, 3, -1, 3, 11, 12, 4, -1, 4, 12, 13, 5, -1, 5, 13, 14, 6, -1 }))) + .Add(ContentsSceneNode.Composite(CSGOperationType.Subtractive, + ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 8.141123e-08f, -7.25f), + new float4(0f, 1f, -5.960465e-08f, 0.5f), + new float4(1f, 0f, 0f, -5f), + new float4(0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(0f, 0f, 1f, -4.99999952f), + new float4(-0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(-1f, 0f, 0f, -5f), + new float4(-0.7071067f, 0f, -0.7071068f, -4.949747f), + new float4(0f, 0f, -1f, -4.99999952f), + new float4(0.7071067f, 0f, -0.7071068f, -4.949747f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, -1.5f, -30f, 1f)), name: "Extruded Shape One #242") + .WithMesh(new float3[] { new float3(2f, -7.24999952f, 4.99999952f), new float3(-2f, -7.24999952f, 4.99999952f), new float3(-5f, -7.25f, 1.99999988f), new float3(-5f, -7.25f, -1.99999988f), new float3(-2f, -7.25000048f, -4.99999952f), new float3(2f, -7.25000048f, -4.99999952f), new float3(5f, -7.25f, -1.99999988f), new float3(5f, -7.25f, 1.99999988f), new float3(2f, -0.4999997f, 4.99999952f), new float3(-2f, -0.4999997f, 4.99999952f), new float3(-5f, -0.499999881f, 1.99999988f), new float3(-5f, -0.5000001f, -1.99999988f), new float3(-2f, -0.5000003f, -4.99999952f), new float3(2f, -0.5000003f, -4.99999952f), new float3(5f, -0.5000001f, -1.99999988f), new float3(5f, -0.499999881f, 1.99999988f) }, + new int[] { 0, 1, 2, 3, 4, 5, 6, 7, -1, 14, 13, 12, 11, 10, 9, 8, 15, -1, 6, 14, 15, 7, -1, 7, 15, 8, 0, -1, 0, 8, 9, 1, -1, 1, 9, 10, 2, -1, 2, 10, 11, 3, -1, 3, 11, 12, 4, -1, 4, 12, 13, 5, -1, 5, 13, 14, 6, -1 }))) + .Add(ContentsSceneNode.Composite(CSGOperationType.Subtractive, + ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 8.141123e-08f, -7.5f), + new float4(0f, 1f, -8.141123e-08f, 5.5f), + new float4(1f, 0f, 0f, -5f), + new float4(0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(0f, 0f, 1f, -4.99999952f), + new float4(-0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(-1f, 0f, 0f, -5f), + new float4(-0.7071067f, 0f, -0.7071068f, -4.949747f), + new float4(0f, 0f, -1f, -4.99999952f), + new float4(0.7071067f, 0f, -0.7071068f, -4.949747f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Remove) #274") + .WithMesh(new float3[] { new float3(2f, -7.49999952f, 4.99999952f), new float3(-2f, -7.49999952f, 4.99999952f), new float3(-5f, -7.5f, 1.99999988f), new float3(-5f, -7.5f, -1.99999988f), new float3(-2f, -7.50000048f, -4.99999952f), new float3(2f, -7.50000048f, -4.99999952f), new float3(5f, -7.5f, -1.99999988f), new float3(5f, -7.5f, 1.99999988f), new float3(2f, -5.49999952f, 4.99999952f), new float3(-2f, -5.49999952f, 4.99999952f), new float3(-5f, -5.5f, 1.99999988f), new float3(-5f, -5.5f, -1.99999988f), new float3(-2f, -5.50000048f, -4.99999952f), new float3(2f, -5.50000048f, -4.99999952f), new float3(5f, -5.5f, -1.99999988f), new float3(5f, -5.5f, 1.99999988f) }, + new int[] { 0, 1, 2, 3, 4, 5, 6, 7, -1, 14, 13, 12, 11, 10, 9, 8, 15, -1, 6, 14, 15, 7, -1, 7, 15, 8, 0, -1, 0, 8, 9, 1, -1, 1, 9, 10, 2, -1, 2, 10, 11, 3, -1, 3, 11, 12, 4, -1, 4, 12, 13, 5, -1, 5, 13, 14, 6, -1 }))) + ; + AssertNothingDrawnIn(scene, new float3(-3.5f, -2.5f, -33.5f), new float3(3.5f, -1.5f, -26.5f), "the pit under Extruded Shape (Remove)"); + } + + + [Test] + public void ThePitUnderExtrudedShapeRemove_AsFirstBuilt_IsOpenOnceThePlanesAreSnapped() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(new float4[] + { + new float4(-1f, 0f, 0f, -8f), + new float4(0f, -1f, 0f, -1.5f), + new float4(0f, 0f, -1f, -2f), + new float4(1f, 0f, 0f, -8f), + new float4(0f, 1f, 0f, 0.5f), + new float4(0f, 0f, 1f, -2f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1.00000048f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1.00000048f, 0f), new float4(0f, -0.5f, -32f, 1f)), name: "Box #174") + .WithMesh(new float3[] { new float3(-8f, -1.5f, 2f), new float3(-8f, -1.5f, -2f), new float3(8f, -1.5f, -2f), new float3(8f, -1.5f, 2f), new float3(-8f, -0.5f, 2f), new float3(-8f, -0.5f, -2f), new float3(8f, -0.5f, -2f), new float3(8f, -0.5f, 2f) }, + new int[] { 1, 0, 4, 5, -1, 3, 0, 1, 2, -1, 6, 2, 1, 5, -1, 3, 2, 6, 7, -1, 4, 7, 6, 5, -1, 4, 0, 3, 7, -1 })) + .Add(ContentsSceneNode.Composite(CSGOperationType.Additive, + ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 7.40718e-08f, -7f), + new float4(0f, 1f, -7.40718e-08f, 6f), + new float4(1f, 0f, 0f, -5.5f), + new float4(0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(0f, 0f, 1f, -5.49999952f), + new float4(-0.707106769f, 0f, 0.7071068f, -5.656854f), + new float4(-1f, 0f, 0f, -5.5f), + new float4(-0.707106769f, 0f, -0.7071068f, -5.656854f), + new float4(0f, 0f, -1f, -5.49999952f), + new float4(0.707106769f, 0f, -0.7071068f, -5.656854f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Additive) #240") + .WithMesh(new float3[] { new float3(2.5f, -6.99999952f, 5.49999952f), new float3(-2.5f, -6.99999952f, 5.49999952f), new float3(-5.5f, -7f, 2.49999976f), new float3(-5.5f, -7f, -2.49999976f), new float3(-2.5f, -7.00000048f, -5.49999952f), new float3(2.5f, -7.00000048f, -5.49999952f), new float3(5.5f, -7f, -2.49999976f), new float3(5.5f, -7f, 2.49999976f), new float3(2.5f, -5.99999952f, 5.49999952f), new float3(-2.5f, -5.99999952f, 5.49999952f), new float3(-5.5f, -6f, 2.49999976f), new float3(-5.5f, -6f, -2.49999976f), new float3(-2.5f, -6.00000048f, -5.49999952f), new float3(2.5f, -6.00000048f, -5.49999952f), new float3(5.5f, -6f, -2.49999976f), new float3(5.5f, -6f, 2.49999976f) }, + new int[] { 0, 1, 2, 3, 4, 5, 6, 7, -1, 14, 13, 12, 11, 10, 9, 8, 15, -1, 6, 14, 15, 7, -1, 7, 15, 8, 0, -1, 0, 8, 9, 1, -1, 1, 9, 10, 2, -1, 2, 10, 11, 3, -1, 3, 11, 12, 4, -1, 4, 12, 13, 5, -1, 5, 13, 14, 6, -1 }))) + .Add(ContentsSceneNode.Composite(CSGOperationType.Subtractive, + ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 8.141123e-08f, -7.25f), + new float4(0f, 1f, -5.960465e-08f, 0.5f), + new float4(1f, 0f, 0f, -5f), + new float4(0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(0f, 0f, 1f, -4.99999952f), + new float4(-0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(-1f, 0f, 0f, -5f), + new float4(-0.7071067f, 0f, -0.7071068f, -4.949747f), + new float4(0f, 0f, -1f, -4.99999952f), + new float4(0.7071067f, 0f, -0.7071068f, -4.949747f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, -1.5f, -30f, 1f)), name: "Extruded Shape One #242") + .WithMesh(new float3[] { new float3(2f, -7.24999952f, 4.99999952f), new float3(-2f, -7.24999952f, 4.99999952f), new float3(-5f, -7.25f, 1.99999988f), new float3(-5f, -7.25f, -1.99999988f), new float3(-2f, -7.25000048f, -4.99999952f), new float3(2f, -7.25000048f, -4.99999952f), new float3(5f, -7.25f, -1.99999988f), new float3(5f, -7.25f, 1.99999988f), new float3(2f, -0.4999997f, 4.99999952f), new float3(-2f, -0.4999997f, 4.99999952f), new float3(-5f, -0.499999881f, 1.99999988f), new float3(-5f, -0.5000001f, -1.99999988f), new float3(-2f, -0.5000003f, -4.99999952f), new float3(2f, -0.5000003f, -4.99999952f), new float3(5f, -0.5000001f, -1.99999988f), new float3(5f, -0.499999881f, 1.99999988f) }, + new int[] { 0, 1, 2, 3, 4, 5, 6, 7, -1, 14, 13, 12, 11, 10, 9, 8, 15, -1, 6, 14, 15, 7, -1, 7, 15, 8, 0, -1, 0, 8, 9, 1, -1, 1, 9, 10, 2, -1, 2, 10, 11, 3, -1, 3, 11, 12, 4, -1, 4, 12, 13, 5, -1, 5, 13, 14, 6, -1 }))) + .Add(ContentsSceneNode.Composite(CSGOperationType.Subtractive, + ContentsSceneNode.Brush(new float4[] + { + new float4(0f, -1f, 8.141123e-08f, -7f), + new float4(0f, 1f, -8.141123e-08f, 6f), + new float4(1f, 0f, 0f, -5f), + new float4(0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(0f, 0f, 1f, -4.99999952f), + new float4(-0.7071067f, 0f, 0.7071068f, -4.949747f), + new float4(-1f, 0f, 0f, -5f), + new float4(-0.7071067f, 0f, -0.7071068f, -4.949747f), + new float4(0f, 0f, -1f, -4.99999952f), + new float4(0.7071067f, 0f, -0.7071068f, -4.949747f), + }, contents: 0, operation: CSGOperationType.Additive, + localToTree: new float4x4(new float4(-1f, 0f, 0f, 0f), new float4(0f, 1f, 0f, 0f), new float4(0f, 0f, -1f, 0f), new float4(0f, 5f, -30f, 1f)), name: "Extruded Shape (Remove) #274") + .WithMesh(new float3[] { new float3(2f, -6.99999952f, 4.99999952f), new float3(-2f, -6.99999952f, 4.99999952f), new float3(-5f, -7f, 1.99999988f), new float3(-5f, -7f, -1.99999988f), new float3(-2f, -7.00000048f, -4.99999952f), new float3(2f, -7.00000048f, -4.99999952f), new float3(5f, -7f, -1.99999988f), new float3(5f, -7f, 1.99999988f), new float3(2f, -5.99999952f, 4.99999952f), new float3(-2f, -5.99999952f, 4.99999952f), new float3(-5f, -6f, 1.99999988f), new float3(-5f, -6f, -1.99999988f), new float3(-2f, -6.00000048f, -4.99999952f), new float3(2f, -6.00000048f, -4.99999952f), new float3(5f, -6f, -1.99999988f), new float3(5f, -6f, 1.99999988f) }, + new int[] { 0, 1, 2, 3, 4, 5, 6, 7, -1, 14, 13, 12, 11, 10, 9, 8, 15, -1, 6, 14, 15, 7, -1, 7, 15, 8, 0, -1, 0, 8, 9, 1, -1, 1, 9, 10, 2, -1, 2, 10, 11, 3, -1, 3, 11, 12, 4, -1, 4, 12, 13, 5, -1, 5, 13, 14, 6, -1 }))) + ; + + AssertNothingDrawnIn(scene, new float3(-3.5f, -2.5f, -33.5f), new float3(3.5f, -1.5f, -26.5f), "the pit as first built"); + } + } +} diff --git a/Core/Tests/Contents/SampleSceneOpeningTests.cs.meta b/Core/Tests/Contents/SampleSceneOpeningTests.cs.meta new file mode 100644 index 00000000..7ac5fc14 --- /dev/null +++ b/Core/Tests/Contents/SampleSceneOpeningTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f659db585aedeaf448513153163d4594 \ No newline at end of file diff --git a/Core/Tests/Contents/SetStateTests.cs b/Core/Tests/Contents/SetStateTests.cs new file mode 100644 index 00000000..832f2779 --- /dev/null +++ b/Core/Tests/Contents/SetStateTests.cs @@ -0,0 +1,77 @@ +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class SetStateTests + { + static ContentsTreeHarness BuildAndUpdate() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0), new float3(2)), name: "a")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1), new float3(3)), name: "b")); + var harness = ContentsTreeHarness.Build(scene); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + return harness; + } + + static CompactNodeID FirstBrush(ContentsTreeHarness harness) => ((CSGTreeBrush)harness.Tree[0]).CompactNodeID; + + static void SetState(ref CompactHierarchy hierarchy, CompactNodeID brushID, float4x4 transformation) + { + hierarchy.SetState(brushID, ChiselMeshLookup.Value.brushMeshBlobCache, hierarchy.GetBrushMeshID(brushID), + hierarchy.GetOperation(brushID), hierarchy.GetContents(brushID), transformation); + } + + [Test] + public void SetState_WithNothingChanged_FlagsNothing() + { + using (var harness = BuildAndUpdate()) + { + var brushID = FirstBrush(harness); + ref var hierarchy = ref CompactHierarchyManager.GetHierarchy(brushID); + Assert.That(hierarchy.IsAnyStatusFlagSet(brushID), Is.False, "the update left flags on the brush"); + + SetState(ref hierarchy, brushID, hierarchy.GetLocalTransformation(brushID)); + + Assert.That(hierarchy.IsAnyStatusFlagSet(brushID), Is.False, "an unchanged state flagged the brush"); + } + } + + [Test] + public void SetBrushMeshID_WithTheSameID_FlagsNothing() + { + using (var harness = BuildAndUpdate()) + { + var brushID = FirstBrush(harness); + ref var hierarchy = ref CompactHierarchyManager.GetHierarchy(brushID); + Assert.That(hierarchy.IsAnyStatusFlagSet(brushID), Is.False, "the update left flags on the brush"); + + var changed = hierarchy.SetBrushMeshID(brushID, hierarchy.GetBrushMeshID(brushID)); + + Assert.That(changed, Is.False); + Assert.That(hierarchy.IsAnyStatusFlagSet(brushID), Is.False, + "reassigning the same mesh made the editor rebuild the tree again next tick"); + } + } + + [Test] + public void SetState_WithAMovedBrush_FlagsItAndWhatItTouches() + { + using (var harness = BuildAndUpdate()) + { + var brushID = FirstBrush(harness); + ref var hierarchy = ref CompactHierarchyManager.GetHierarchy(brushID); + var moved = math.mul(float4x4.Translate(new float3(0.25f, 0, 0)), hierarchy.GetLocalTransformation(brushID)); + + SetState(ref hierarchy, brushID, moved); + + Assert.That(hierarchy.IsStatusFlagSet(brushID, NodeStatusFlags.TransformationModified), Is.True); + Assert.That(hierarchy.IsStatusFlagSet(brushID, NodeStatusFlags.NeedAllTouchingUpdated), Is.True); + Assert.That(hierarchy.GetLocalTransformation(brushID), Is.EqualTo(moved)); + } + } + } +} diff --git a/Core/Tests/Contents/SetStateTests.cs.meta b/Core/Tests/Contents/SetStateTests.cs.meta new file mode 100644 index 00000000..2a3fa966 --- /dev/null +++ b/Core/Tests/Contents/SetStateTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c434f2791b6bf37478299bfba03c9d0d \ No newline at end of file diff --git a/Core/Tests/Contents/SharedCornerExpectationTests.cs b/Core/Tests/Contents/SharedCornerExpectationTests.cs new file mode 100644 index 00000000..1269a461 --- /dev/null +++ b/Core/Tests/Contents/SharedCornerExpectationTests.cs @@ -0,0 +1,133 @@ +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class SharedCornerExpectationTests + { + const int kSectors = 720; + + static bool?[] Pattern(System.Func sector) => Enumerable.Range(0, kSectors).Select(sector).ToArray(); + + #region Sector patterns + [Test] + public void ExpectFace_NothingDrawn_IsNoFace() + { + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => false)), Is.EqualTo(SharedCornerSurvey.Expectation.NoFace)); + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => k < 360 ? (bool?)null : false)), Is.EqualTo(SharedCornerSurvey.Expectation.NoFace)); + } + + [Test] + public void ExpectFace_DrawnAllAround_IsDrawnThrough() + { + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => true)), Is.EqualTo(SharedCornerSurvey.Expectation.DrawnThrough)); + // The edge brush's face only covers half the circle + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => k < 360 ? (bool?)null : true)), Is.EqualTo(SharedCornerSurvey.Expectation.DrawnThrough)); + } + + [Test] + public void ExpectFace_CutByAStraightLine_IsAStraightBoundary() + { + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => k < 360)), Is.EqualTo(SharedCornerSurvey.Expectation.StraightBoundary)); + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => k >= 100 && k < 460)), Is.EqualTo(SharedCornerSurvey.Expectation.StraightBoundary)); + // One sector either way is sampling, not a bend + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => k < 361)), Is.EqualTo(SharedCornerSurvey.Expectation.StraightBoundary)); + } + + [Test] + public void ExpectFace_BentOutline_IsABoundary() + { + // A quarter cut out, and a 9 degree bend + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => k >= 180)), Is.EqualTo(SharedCornerSurvey.Expectation.Boundary)); + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => k < 378)), Is.EqualTo(SharedCornerSurvey.Expectation.Boundary)); + // Two separate wedges + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => (k / 180) % 2 == 0)), Is.EqualTo(SharedCornerSurvey.Expectation.Boundary)); + } + + [Test] + public void ExpectFace_OutlineEndingOnTheEdge_IsABoundary() + { + // Half the circle is off the face; the drawn part ends on the edge at the corner, even when the + // line it ends along is straight + Assert.That(SharedCornerSurvey.ExpectFace(Pattern(k => k < 360 ? (bool?)null : k < 540)), Is.EqualTo(SharedCornerSurvey.Expectation.Boundary)); + } + #endregion + + #region Judging a side + [Test] + public void JudgeSide_OnlyFlagsAFaceThatShouldBeGone_OrACornerThatShouldBeThere() + { + Assert.That(SharedCornerSurvey.JudgeSide(SharedCornerSurvey.Expectation.NoFace, drewNearCorner: false), Is.Null); + Assert.That(SharedCornerSurvey.JudgeSide(SharedCornerSurvey.Expectation.NoFace, drewNearCorner: true), Is.Not.Null); + Assert.That(SharedCornerSurvey.JudgeSide(SharedCornerSurvey.Expectation.Boundary, drewNearCorner: true), Is.Null); + Assert.That(SharedCornerSurvey.JudgeSide(SharedCornerSurvey.Expectation.Boundary, drewNearCorner: false), Is.Not.Null); + // Either is fine where a vertex is allowed but not needed + foreach (var optional in new[] { SharedCornerSurvey.Expectation.DrawnThrough, SharedCornerSurvey.Expectation.StraightBoundary }) + { + Assert.That(SharedCornerSurvey.JudgeSide(optional, drewNearCorner: false), Is.Null, optional.ToString()); + Assert.That(SharedCornerSurvey.JudgeSide(optional, drewNearCorner: true), Is.Null, optional.ToString()); + } + } + #endregion + + #region From the oracle + // Two unit-offset cubes: at every corner where their surfaces cross, both outlines bend. + [Test] + public void Expect_OverlappingBoxes_HaveABoundaryOnBothSides() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0), new float3(2)), name: "a")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1), new float3(3)), name: "b")); + var oracle = new ContentsOracle(scene); + var brushes = oracle.Brushes; + var corners = SharedCornerSurvey.SharedCorners(brushes.Select(oracle.TreePlanesOf).ToList()); + Assert.That(corners.Count, Is.EqualTo(6)); + foreach (var corner in corners) + { + Assert.That(SharedCornerSurvey.Expect(oracle, brushes[corner.brushA], corner.planesA, corner.position), + Is.EqualTo(SharedCornerSurvey.Expectation.Boundary), "a at " + corner.position); + Assert.That(SharedCornerSurvey.Expect(oracle, brushes[corner.brushB], corner.planesB, corner.position), + Is.EqualTo(SharedCornerSurvey.Expectation.Boundary), "b at " + corner.position); + } + } + + // The same crossing buried deep inside a third solid brush: nothing may be drawn there. + [Test] + public void Expect_CrossingInsideASolid_HasNoFaceOnEitherSide() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0), new float3(2)), name: "a")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1), new float3(3)), name: "b")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1.5f, 0.5f, 0.5f), new float3(2.5f, 1.5f, 1.5f)), name: "cover")); + var oracle = new ContentsOracle(scene); + var brushes = oracle.Brushes; + var corners = SharedCornerSurvey.SharedCorners(brushes.Select(oracle.TreePlanesOf).ToList()); + var corner = corners.First(c => c.brushA == 0 && c.brushB == 1 && math.distance(c.position, new float3(2, 1, 1)) < 0.00001f); + Assert.That(corner.isolated, Is.True, "the cover's faces are half a unit away"); + Assert.That(SharedCornerSurvey.Expect(oracle, brushes[0], corner.planesA, corner.position), Is.EqualTo(SharedCornerSurvey.Expectation.NoFace)); + Assert.That(SharedCornerSurvey.Expect(oracle, brushes[1], corner.planesB, corner.position), Is.EqualTo(SharedCornerSurvey.Expectation.NoFace)); + } + + // A carve before the brush it crosses carves nothing of it: the later brush's face is drawn straight through + [Test] + public void Expect_CarveBeforeTheBrushItCrosses_LeavesTheLaterFaceDrawnThrough() + { + var scene = new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(-5), new float3(-4)), name: "base")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0), new float3(2)), operation: CSGOperationType.Subtractive, name: "carve")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1), new float3(3)), name: "fill")); + var oracle = new ContentsOracle(scene); + var brushes = oracle.Brushes; + var corners = SharedCornerSurvey.SharedCorners(brushes.Select(oracle.TreePlanesOf).ToList()); + // (1, 1, 2): the carve's top face against the fill's -x and -y faces, where the fill's edge + // crosses the carve's face + var corner = corners.First(c => c.brushA == 1 && c.brushB == 2 && math.distance(c.position, new float3(1, 1, 2)) < 0.00001f); + Assert.That(SharedCornerSurvey.Expect(oracle, brushes[1], corner.planesA, corner.position), Is.EqualTo(SharedCornerSurvey.Expectation.NoFace), "carve"); + Assert.That(SharedCornerSurvey.Expect(oracle, brushes[2], corner.planesB, corner.position), Is.EqualTo(SharedCornerSurvey.Expectation.DrawnThrough), "fill"); + } + #endregion + } +} diff --git a/Core/Tests/Contents/SharedCornerExpectationTests.cs.meta b/Core/Tests/Contents/SharedCornerExpectationTests.cs.meta new file mode 100644 index 00000000..6ba37a33 --- /dev/null +++ b/Core/Tests/Contents/SharedCornerExpectationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2dff24e8efc358640864a99d02b12344 \ No newline at end of file diff --git a/Core/Tests/Contents/SharedCornerSurvey.cs b/Core/Tests/Contents/SharedCornerSurvey.cs new file mode 100644 index 00000000..a3b2a574 --- /dev/null +++ b/Core/Tests/Contents/SharedCornerSurvey.cs @@ -0,0 +1,456 @@ +using System.Collections.Generic; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + public static class SharedCornerSurvey + { + // Output vertices are collected within the weld distance of a corner: that is as far as any + // decision about it can move it. + public const float kWeldRadius = CSGConstants.kVertexEqualEpsilon; + // Two decisions closer than this (0.05 mm) count as the same: far above float noise at these sizes + public const float kSamePosition = 0.00005f; + // A corner lies on a brush's boundary when it is within this of the brush's planes. + const double kOnBoundary = 0.0001; + // A plane passes through a corner when it is within this of it; any other plane closer than + // the weld radius means the weld has something to decide there. + const double kThroughCorner = 0.00001; + // Three planes that are this close to dependent don't define a usable point. + const double kMinDeterminant = 0.000001; + // Corners closer than this are one corner. + const float kSameCorner = 0.00001f; + // Output vertices closer than this are one vertex. Well below kSamePosition, so a pair of + // vertices a weld left apart is never folded into one here. + const float kSameVertex = 0.000001f; + + public struct Corner + { + public float3 position; + public int brushA, brushB; + // The planes of each brush that pass through the corner: its faces there. + public int[] planesA, planesB; + // No plane of any brush near this corner comes within the weld radius, other than the ones + // passing through it: the weld has nothing to decide here, so both sides must agree. + public bool isolated; + } + + // In rising order of how bad it is; a corner takes the worst verdict of its face pairs. + public enum Verdict + { + // Neither brush drew anything near the corner on its faces through it: something else covers it. + Hidden, + // Both put their nearest vertex at the same place. + Same, + // Each emitted one vertex, at measurably different places. + Apart, + // One side emitted one vertex, the other two, and their nearest ones differ. + OneVsTwo, + // Any other disagreement about where the corner is. + Other, + // One side drew faces through the corner and the other drew nothing near it: a hole. + Missing + } + + public struct Result + { + public Verdict verdict; + // Between the two sides' nearest vertices, the largest over the face pairs that disagree; + // NaN when there is nothing to measure. + public float distance; + public int countA, countB; + // Whether each side drew any vertex near the corner on its faces through it + public bool drewA, drewB; + public bool isDisagreement => verdict >= Verdict.Apart; + // The two sides put the corner in different places. Unlike Missing, which can come from a + // face lost anywhere along its outline, this is about the corner itself. + public bool isPositionDisagreement => verdict == Verdict.Apart || verdict == Verdict.OneVsTwo || verdict == Verdict.Other; + } + + public enum Expectation + { + // None of the side's faces through the corner is drawn around it: the side must draw nothing + // near the corner. + NoFace, + // A face is drawn all the way around the corner: a vertex there is allowed, not needed. + DrawnThrough, + // A face the corner lies inside of is cut by one straight line through it: a vertex there is + // allowed, not needed. + StraightBoundary, + // A face's drawn region bends or ends at the corner: the side must have a vertex there. + Boundary + } + + // Well inside the weld radius, so the circle only meets planes through the corner, and far enough + // out that float error at scene coordinates (a few micrometres) can't flip a sample. + public const float kExpectationRadius = 0.008f; + // The probes on either side of a face. Much smaller than the radius, so a probe beside a sharp edge + // doesn't step out through the neighbouring face. + const float kExpectationOffset = 0.00004f; + const int kExpectationSectors = 720; + + public static Expectation Expect(ContentsOracle oracle, ContentsSceneNode brush, int[] facePlanes, float3 corner) + { + var planes = oracle.TreePlanesOf(brush); + var result = Expectation.NoFace; + foreach (var plane in facePlanes) + { + var expectation = ExpectFace(SampleAround(oracle, brush, planes, plane, corner)); + if (expectation > result) + result = expectation; + } + return result; + } + + // Per direction around the corner on one face: true drawn, false not drawn, null off the face. + static bool?[] SampleAround(ContentsOracle oracle, ContentsSceneNode brush, float4[] planes, int plane, float3 corner) + { + var normal = planes[plane].xyz; + var u = math.normalize(math.cross(normal, math.abs(normal.x) < 0.9f ? new float3(1, 0, 0) : new float3(0, 1, 0))); + var v = math.cross(normal, u); + var sectors = new bool?[kExpectationSectors]; + for (int k = 0; k < sectors.Length; k++) + { + // Off the sector boundaries, so a plane through the corner at a round angle isn't sampled + var angle = (k + 0.37f) * 2 * math.PI / sectors.Length; + var point = corner + kExpectationRadius * ((math.cos(angle) * u) + (math.sin(angle) * v)); + point -= normal * (math.dot(normal, point) + planes[plane].w); + var onFace = true; + for (int q = 0; q < planes.Length && onFace; q++) + onFace = q == plane || math.dot(planes[q].xyz, point) + planes[q].w <= 0; + if (onFace) + sectors[k] = oracle.JudgeFace(brush, plane, point, kExpectationOffset) != ContentsFaceVerdict.None; + } + return sectors; + } + + // Classifies one face from its samples around the corner. + public static Expectation ExpectFace(bool?[] sectors) + { + var anyDrawn = false; + var anyUndrawn = false; + var offFace = false; + foreach (var sector in sectors) + { + if (sector == null) offFace = true; + else if (sector.Value) anyDrawn = true; + else anyUndrawn = true; + } + if (!anyDrawn) return Expectation.NoFace; + if (!anyUndrawn) return Expectation.DrawnThrough; + + // Where drawn turns into undrawn or back. On a face the corner only bounds (the edge brush's), any + // change means an outline ends at the edge there. + var transitions = new List(); + for (int k = 0; k < sectors.Length; k++) + { + var current = sectors[k]; + var next = sectors[(k + 1) % sectors.Length]; + if (current != null && next != null && current != next) + transitions.Add(k); + } + if (!offFace && transitions.Count == 2 && + math.abs(math.abs(transitions[1] - transitions[0]) - (sectors.Length / 2)) <= 1) + return Expectation.StraightBoundary; + return Expectation.Boundary; + } + + // Whether what one side drew near an isolated corner agrees with the oracle: null when it does, + // otherwise what is wrong. + public static string JudgeSide(Expectation expectation, bool drewNearCorner) + { + if (expectation == Expectation.NoFace && drewNearCorner) + return "draws where the oracle draws nothing"; + if (expectation == Expectation.Boundary && !drewNearCorner) + return "has no vertex where the oracle's outline has a corner"; + return null; + } + + public static List SharedCorners(IReadOnlyList brushPlanes) + { + var corners = new List(); + for (int a = 0; a < brushPlanes.Count; a++) + { + for (int b = a + 1; b < brushPlanes.Count; b++) + { + if (!BoundsNear(brushPlanes[a], brushPlanes[b])) + continue; + + var found = new List(); + AddCrossings(brushPlanes, a, b, edgeBrush: a, faceBrush: b, found, corners); + AddCrossings(brushPlanes, a, b, edgeBrush: b, faceBrush: a, found, corners); + } + } + return corners; + } + + static void AddCrossings(IReadOnlyList brushPlanes, int a, int b, int edgeBrush, int faceBrush, + List found, List corners) + { + var edgePlanes = brushPlanes[edgeBrush]; + var facePlanes = brushPlanes[faceBrush]; + for (int i = 0; i < edgePlanes.Length; i++) + { + for (int j = i + 1; j < edgePlanes.Length; j++) + { + for (int k = 0; k < facePlanes.Length; k++) + { + if (!TryIntersect(edgePlanes[i], edgePlanes[j], facePlanes[k], out var point)) + continue; + if (!OnBoundary(edgePlanes, point) || !OnBoundary(facePlanes, point)) + continue; + // Strictly inside the edge, and strictly inside the face + var throughEdgeBrush = PlanesThrough(edgePlanes, point); + var throughFaceBrush = PlanesThrough(facePlanes, point); + if (throughEdgeBrush.Length != 2 || throughFaceBrush.Length != 1) + continue; + var position = (float3)point; + if (Contains(found, position, kSameCorner)) + continue; + found.Add(position); + corners.Add(new Corner + { + position = position, + brushA = a, + brushB = b, + planesA = edgeBrush == a ? throughEdgeBrush : throughFaceBrush, + planesB = edgeBrush == a ? throughFaceBrush : throughEdgeBrush, + isolated = IsIsolated(brushPlanes, point) + }); + } + } + } + } + + // Compares one corner across its two brushes, face against face. verticesByPlane[p] holds the + // distinct output vertices a brush drew on its plane p. + public static Result ClassifyCorner(Corner corner, IReadOnlyList> verticesByPlaneA, + IReadOnlyList> verticesByPlaneB) + { + var facesA = NearFaces(corner.position, corner.planesA, verticesByPlaneA); + var facesB = NearFaces(corner.position, corner.planesB, verticesByPlaneB); + var result = new Result { distance = float.NaN, countA = Total(facesA), countB = Total(facesB), + drewA = facesA.Count > 0, drewB = facesB.Count > 0 }; + + if (facesA.Count == 0 && facesB.Count == 0) { result.verdict = Verdict.Hidden; return result; } + if (facesA.Count == 0 || facesB.Count == 0) { result.verdict = Verdict.Missing; return result; } + + result.verdict = Verdict.Same; + foreach (var faceA in facesA) + { + foreach (var faceB in facesB) + { + var pair = Classify(corner.position, faceA, faceB); + if (pair.isDisagreement) + result.distance = float.IsNaN(result.distance) ? pair.distance : math.max(result.distance, pair.distance); + if (pair.verdict > result.verdict) + { + result.verdict = pair.verdict; + result.countA = pair.countA; + result.countB = pair.countB; + } + } + } + return result; + } + + // Compares the vertices two faces emitted near one corner. + public static Result Classify(float3 corner, IReadOnlyList verticesA, IReadOnlyList verticesB) + { + var nearA = Near(corner, verticesA); + var nearB = Near(corner, verticesB); + var result = new Result { countA = nearA.Count, countB = nearB.Count, distance = float.NaN }; + + if (nearA.Count == 0 && nearB.Count == 0) { result.verdict = Verdict.Hidden; return result; } + if (nearA.Count == 0 || nearB.Count == 0) { result.verdict = Verdict.Missing; return result; } + + result.distance = math.distance(Nearest(corner, nearA), Nearest(corner, nearB)); + if (result.distance <= kSamePosition) + result.verdict = Verdict.Same; + else if (nearA.Count == 1 && nearB.Count == 1) + result.verdict = Verdict.Apart; + else if (nearA.Count + nearB.Count == 3 && math.min(nearA.Count, nearB.Count) == 1) + result.verdict = Verdict.OneVsTwo; + else + result.verdict = Verdict.Other; + return result; + } + + public static int PlaneOf(float3 normal, float3 center, float4[] planes) + { + var best = -1; + var bestDistance = float.MaxValue; + for (int p = 0; p < planes.Length; p++) + { + if (math.abs(math.abs(math.dot(normal, planes[p].xyz)) - 1) > 0.001f) + continue; + var distance = math.abs(math.dot(planes[p].xyz, center) + planes[p].w); + if (distance < bestDistance) + { + best = p; + bestDistance = distance; + } + } + return bestDistance <= 2 * kWeldRadius ? best : -1; + } + + // The distinct positions in a list of vertices; the render buffer repeats a position for every + // surface that uses it. + public static List Distinct(IEnumerable positions) + { + var result = new List(); + foreach (var position in positions) + { + if (!Contains(result, position, kSameVertex)) + result.Add(position); + } + return result; + } + + static List> NearFaces(float3 corner, int[] planes, IReadOnlyList> verticesByPlane) + { + var faces = new List>(); + foreach (var plane in planes) + { + var near = Near(corner, verticesByPlane[plane]); + if (near.Count > 0) + faces.Add(near); + } + return faces; + } + + static int Total(List> faces) + { + var all = new List(); + foreach (var face in faces) + all.AddRange(face); + return Distinct(all).Count; + } + + static List Near(float3 corner, IReadOnlyList vertices) + { + var result = new List(); + for (int i = 0; i < vertices.Count; i++) + { + if (math.distance(corner, vertices[i]) <= kWeldRadius) + result.Add(vertices[i]); + } + return result; + } + + static float3 Nearest(float3 corner, List vertices) + { + var best = vertices[0]; + for (int i = 1; i < vertices.Count; i++) + { + if (math.distancesq(corner, vertices[i]) < math.distancesq(corner, best)) + best = vertices[i]; + } + return best; + } + + static bool Contains(List positions, float3 position, float distance) + { + for (int i = 0; i < positions.Count; i++) + { + if (math.distance(positions[i], position) <= distance) + return true; + } + return false; + } + + static bool TryIntersect(float4 p0, float4 p1, float4 p2, out double3 point) + { + double3 n0 = p0.xyz, n1 = p1.xyz, n2 = p2.xyz; + var determinant = math.dot(n0, math.cross(n1, n2)); + if (math.abs(determinant) < kMinDeterminant) + { + point = default; + return false; + } + // Cramer's rule for dot(n, x) = -w on all three planes + point = (-(double)p0.w * math.cross(n1, n2) + -(double)p1.w * math.cross(n2, n0) + -(double)p2.w * math.cross(n0, n1)) / determinant; + return true; + } + + static double Distance(float4 plane, double3 point) => math.dot((double3)plane.xyz, point) + plane.w; + + // Inside or on every plane, and on at least one + static bool OnBoundary(float4[] planes, double3 point) + { + var onOne = false; + for (int i = 0; i < planes.Length; i++) + { + var distance = Distance(planes[i], point); + if (distance > kOnBoundary) + return false; + if (distance >= -kOnBoundary) + onOne = true; + } + return onOne; + } + + static int[] PlanesThrough(float4[] planes, double3 point) + { + var result = new List(); + for (int i = 0; i < planes.Length; i++) + { + if (math.abs(Distance(planes[i], point)) <= kThroughCorner) + result.Add(i); + } + return result.ToArray(); + } + + static bool IsIsolated(IReadOnlyList brushPlanes, double3 point) + { + foreach (var planes in brushPlanes) + { + // A brush whose planes all keep the corner further out than the weld radius can't + // reach it: the largest plane distance is a lower bound on the distance to the brush. + var furthest = double.MinValue; + for (int i = 0; i < planes.Length; i++) + furthest = math.max(furthest, Distance(planes[i], point)); + if (furthest > kWeldRadius) + continue; + + for (int i = 0; i < planes.Length; i++) + { + var distance = math.abs(Distance(planes[i], point)); + if (distance > kThroughCorner && distance <= kWeldRadius) + return false; + } + } + return true; + } + + static bool BoundsNear(float4[] planesA, float4[] planesB) + { + var boundsA = BoundsOf(planesA); + var boundsB = BoundsOf(planesB); + return math.all(boundsA.min - kWeldRadius <= boundsB.max) && + math.all(boundsB.min - kWeldRadius <= boundsA.max); + } + + // The bounds of a brush's vertices, found the same way as the corners + static (float3 min, float3 max) BoundsOf(float4[] planes) + { + var min = new float3(float.MaxValue); + var max = new float3(float.MinValue); + for (int i = 0; i < planes.Length; i++) + { + for (int j = i + 1; j < planes.Length; j++) + { + for (int k = j + 1; k < planes.Length; k++) + { + if (!TryIntersect(planes[i], planes[j], planes[k], out var point) || !OnBoundary(planes, point)) + continue; + min = math.min(min, (float3)point); + max = math.max(max, (float3)point); + } + } + } + return (min, max); + } + } +} diff --git a/Core/Tests/Contents/SharedCornerSurvey.cs.meta b/Core/Tests/Contents/SharedCornerSurvey.cs.meta new file mode 100644 index 00000000..2d821a2f --- /dev/null +++ b/Core/Tests/Contents/SharedCornerSurvey.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7fde3e7955cb2d74893e567c5b353071 \ No newline at end of file diff --git a/Core/Tests/Contents/SharedCornerSurveyTests.cs b/Core/Tests/Contents/SharedCornerSurveyTests.cs new file mode 100644 index 00000000..77069363 --- /dev/null +++ b/Core/Tests/Contents/SharedCornerSurveyTests.cs @@ -0,0 +1,609 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + [Category("Contents")] + public class SharedCornerSurveyTests + { + static readonly float3 kCorner = new float3(1, 2, 3); + static readonly float3 kTwoMillimetres = new float3(0.002f, 0, 0); + + #region Classifier + [Test] + public void Classify_BothAtTheCorner_IsSame() + { + var result = SharedCornerSurvey.Classify(kCorner, new[] { kCorner }, new[] { kCorner + new float3(0.000001f) }); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.Same)); + } + + [Test] + public void Classify_OneEachTwoMillimetresApart_IsApart() + { + var result = SharedCornerSurvey.Classify(kCorner, new[] { kCorner }, new[] { kCorner + kTwoMillimetres }); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.Apart)); + Assert.That(result.distance, Is.EqualTo(0.002f).Within(0.000001f)); + } + + [Test] + public void Classify_OneAgainstTwo_IsOneVsTwo() + { + // One side welded its two nearby vertices into one that sits between them + var result = SharedCornerSurvey.Classify(kCorner, new[] { kCorner + (kTwoMillimetres * 0.5f) }, + new[] { kCorner, kCorner + kTwoMillimetres }); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.OneVsTwo)); + Assert.That(result.countA, Is.EqualTo(1)); + Assert.That(result.countB, Is.EqualTo(2)); + } + + [Test] + public void Classify_OneSideDrawsNothingNearIt_IsMissing() + { + var result = SharedCornerSurvey.Classify(kCorner, new[] { kCorner }, new[] { kCorner + new float3(1, 0, 0) }); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.Missing)); + Assert.That(result.isDisagreement, Is.True); + } + + [Test] + public void Classify_NeitherSideDrawsNearIt_IsHidden() + { + var result = SharedCornerSurvey.Classify(kCorner, new float3[0], new[] { kCorner + new float3(1, 0, 0) }); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.Hidden)); + Assert.That(result.isDisagreement, Is.False); + } + + // A brush keeping a vertex of its own next to the shared corner is not a disagreement about + // the corner. + [Test] + public void Classify_SameCornerWithAnOwnVertexNearby_IsSame() + { + var result = SharedCornerSurvey.Classify(kCorner, new[] { kCorner, kCorner + kTwoMillimetres }, new[] { kCorner }); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.Same)); + } + + [Test] + public void ClassifyCorner_OnlyComparesFacesThroughTheCorner() + { + var corner = new SharedCornerSurvey.Corner { position = kCorner, planesA = new[] { 0 }, planesB = new[] { 0 } }; + var verticesA = new List> { new List { kCorner } }; + var verticesB = new List> + { + new List { kCorner + new float3(0.0019f, 0, 0) }, + new List { kCorner } + }; + var result = SharedCornerSurvey.ClassifyCorner(corner, verticesA, verticesB); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.Apart)); + Assert.That(result.distance, Is.EqualTo(0.0019f).Within(0.000001f)); + } + + [Test] + public void ClassifyCorner_TakesTheWorstFacePair() + { + var corner = new SharedCornerSurvey.Corner { position = kCorner, planesA = new[] { 0, 1 }, planesB = new[] { 0 } }; + var verticesA = new List> + { + new List { kCorner }, + new List { kCorner + kTwoMillimetres } + }; + var verticesB = new List> { new List { kCorner } }; + var result = SharedCornerSurvey.ClassifyCorner(corner, verticesA, verticesB); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.Apart)); + Assert.That(result.distance, Is.EqualTo(0.002f).Within(0.000001f)); + } + + [Test] + public void ClassifyCorner_OneSideWithNoFaceNearIt_IsMissing() + { + var corner = new SharedCornerSurvey.Corner { position = kCorner, planesA = new[] { 0 }, planesB = new[] { 0 } }; + var verticesA = new List> { new List { kCorner } }; + var verticesB = new List> { new List() }; + var result = SharedCornerSurvey.ClassifyCorner(corner, verticesA, verticesB); + Assert.That(result.verdict, Is.EqualTo(SharedCornerSurvey.Verdict.Missing)); + } + + [Test] + public void PlaneOf_FindsTheFaceATriangleLiesOn_InEitherOrientation() + { + var planes = ContentsScene.BoxPlanes(new float3(0), new float3(2)); + Assert.That(SharedCornerSurvey.PlaneOf(new float3(1, 0, 0), new float3(2.01f, 1, 1), planes), Is.EqualTo(1)); + Assert.That(SharedCornerSurvey.PlaneOf(new float3(-1, 0, 0), new float3(2, 1, 1), planes), Is.EqualTo(1), "a carved wall faces inwards"); + Assert.That(SharedCornerSurvey.PlaneOf(new float3(1, 0, 0), new float3(1, 1, 1), planes), Is.EqualTo(-1), "not on any face"); + } + #endregion + + #region Corners + // Two unit-offset cubes: their surfaces cross on a skew hexagon, whose six corners are the + // corners of the overlap that lie on both surfaces - all of them except (1,1,1) and (2,2,2). + [Test] + public void SharedCorners_OfTwoOverlappingBoxes_AreTheCornersOfTheirCrossing() + { + var planes = new List + { + ContentsScene.BoxPlanes(new float3(0), new float3(2)), + ContentsScene.BoxPlanes(new float3(1), new float3(3)) + }; + var corners = SharedCornerSurvey.SharedCorners(planes); + + var expected = new[] + { + new float3(2, 1, 1), new float3(1, 2, 1), new float3(1, 1, 2), + new float3(1, 2, 2), new float3(2, 1, 2), new float3(2, 2, 1) + }; + Assert.That(corners.Count, Is.EqualTo(expected.Length), string.Join(", ", corners.Select(c => c.position))); + foreach (var position in expected) + Assert.That(corners.Any(c => math.distance(c.position, position) < 0.00001f), Is.True, "missing " + position); + Assert.That(corners.All(c => c.isolated), Is.True, "nothing else comes near these corners"); + + // (2, 1, 1) lies on A's +x face, and on B's -y and -z faces + var corner = corners.First(c => math.distance(c.position, new float3(2, 1, 1)) < 0.00001f); + Assert.That(corner.planesA, Is.EqualTo(new[] { 1 })); + Assert.That(corner.planesB, Is.EqualTo(new[] { 2, 4 })); + } + + [Test] + public void SharedCorners_WithAnotherFaceWithinTheWeldRadius_AreNotIsolated() + { + var planes = new List + { + ContentsScene.BoxPlanes(new float3(0), new float3(2)), + ContentsScene.BoxPlanes(new float3(1), new float3(3)), + // Its -x face lies 2 mm beyond the corner (2, 1, 1) + ContentsScene.BoxPlanes(new float3(2.002f, 0.5f, 0.5f), new float3(3, 1.5f, 1.5f)) + }; + var corners = SharedCornerSurvey.SharedCorners(planes); + var corner = corners.First(c => c.brushA == 0 && c.brushB == 1 && math.distance(c.position, new float3(2, 1, 1)) < 0.00001f); + Assert.That(corner.isolated, Is.False); + } + + [Test] + public void NearMissPopulations_HaveCornersThatAreNotIsolated([Values] bool slivers) + { + var notIsolated = 0; + var pairsCrossingTheirBox = 0; + var pairs = 0; + for (int seed = 1; seed <= 20; seed++) + { + var scene = slivers ? ContentsSceneGenerators.NearMissSlivers(seed, rotated: false) + : ContentsSceneGenerators.NearMissLedges(seed, rotated: false); + var oracle = new ContentsOracle(scene); + var brushes = oracle.Brushes; + var planes = brushes.Select(oracle.TreePlanesOf).ToList(); + var corners = SharedCornerSurvey.SharedCorners(planes); + notIsolated += corners.Count(c => !c.isolated); + for (int w = 0; w + 1 < brushes.Count; w += 2) + { + pairs++; + if (corners.Count(c => c.brushA == w && c.brushB == w + 1 && !c.isolated) >= 2) + pairsCrossingTheirBox++; + } + } + Assert.That(notIsolated, Is.GreaterThan(0)); + if (slivers) + Assert.That(pairsCrossingTheirBox, Is.EqualTo(pairs), "every sliver pair has its two near crossings"); + } + #endregion + + #region Survey + const int kSeeds = 50; + + static readonly (string name, Func make)[] kPopulations = + { + ("near-miss slivers", seed => ContentsSceneGenerators.NearMissSlivers(seed, rotated: false)), + ("near-miss slivers, turned",seed => ContentsSceneGenerators.NearMissSlivers(seed, rotated: true)), + ("near-miss ledges", seed => ContentsSceneGenerators.NearMissLedges(seed, rotated: false)), + ("near-miss boxes", seed => ContentsSceneGenerators.NearMissBoxes(seed, rotated: false)), + ("near-miss boxes, turned", seed => ContentsSceneGenerators.NearMissBoxes(seed, rotated: true)), + ("grid boxes", seed => ContentsSceneGenerators.AllSolid(ContentsSceneGenerators.GridBoxes(seed))), + ("cut brushes", seed => ContentsSceneGenerators.AllSolid(ContentsSceneGenerators.CutBrushes(seed))), + }; + + static readonly SharedCornerSurvey.Verdict[] kReported = + { + SharedCornerSurvey.Verdict.Same, SharedCornerSurvey.Verdict.Apart, SharedCornerSurvey.Verdict.OneVsTwo, + SharedCornerSurvey.Verdict.Other, SharedCornerSurvey.Verdict.Missing + }; + + sealed class Tally + { + public int scenes, corners, hidden; + // Every isolated corner, hidden or not, and how many of them the output gets wrong by the oracle + public int isolatedCorners, isolatedWrong; + public readonly int[] isolated = new int[Enum.GetValues(typeof(SharedCornerSurvey.Verdict)).Length]; + public readonly int[] notIsolated = new int[Enum.GetValues(typeof(SharedCornerSurvey.Verdict)).Length]; + public readonly List distances = new List(); + } + + sealed class Case + { + public int setting; + public string population; + public int seed; + public string pair; + public float3 position; + public SharedCornerSurvey.Result result; + public bool isolated; + // Isolated corners only: which one, and what the oracle says is wrong with the output there + public int isolatedIndex; + public string wrong; + } + + // An isolated corner as one setting drew it + readonly struct IsolatedOutcome + { + public readonly SharedCornerSurvey.Verdict verdict; + // Null where the output agrees with the oracle + public readonly string wrong; + public IsolatedOutcome(SharedCornerSurvey.Verdict verdict, string wrong) { this.verdict = verdict; this.wrong = wrong; } + public string Describe() => $"{verdict} ({wrong ?? "right"})"; + } + + readonly struct Setting + { + public readonly string title; + public readonly Action apply; + public Setting(string title, Action apply) { this.title = title; this.apply = apply; } + } + + [Test, Explicit] + public void Survey_GateOffThenOn() + { + var original = CompactHierarchyManager.TreeUpdate.kUseIncidenceWeld; + RunSurvey("SharedCornerSurvey", "the gate", + new[] + { + new Setting("GATE OFF (shipped; the control)", () => CompactHierarchyManager.TreeUpdate.kUseIncidenceWeld = false), + new Setting("GATE ON (kUseIncidenceWeld = true)", () => CompactHierarchyManager.TreeUpdate.kUseIncidenceWeld = true), + }, + () => CompactHierarchyManager.TreeUpdate.kUseIncidenceWeld = original); + } + + // The shipped state against itself. Every control above assumes the pipeline gives the same output for the same + // scene twice; any difference reported here is nondeterminism, not a setting. + [Test, Explicit] + public void Survey_ControlAgainstItself() + { + var originalGate = CompactHierarchyManager.TreeUpdate.kUseIncidenceWeld; + var originalStage = CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage; + Action shipped = () => + { + CompactHierarchyManager.TreeUpdate.kUseIncidenceWeld = false; + CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage = (int)CanonicalVertexStage.Off; + }; + RunSurvey("SharedCornerSurveyDeterminism", "a second run of the same build", + new[] { new Setting("SHIPPED (first run)", shipped), new Setting("SHIPPED (second run)", shipped) }, + () => + { + CompactHierarchyManager.TreeUpdate.kUseIncidenceWeld = originalGate; + CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage = originalStage; + }); + } + + // Canonical vertices (Documentation~/Design/CanonicalVertices.md): every stage against the shipped state. The bar + // is two-sided splits at the control's level and one-sided misses down by at least 90%. + [Test, Explicit] + public void Survey_CanonicalVertexStages() + { + var original = CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage; + Action Stage(CanonicalVertexStage value) => () => CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage = (int)value; + RunSurvey("CanonicalVertexSurvey", "canonical vertices", + new[] + { + new Setting("CANONICAL OFF (shipped; the control)", Stage(CanonicalVertexStage.Off)), + new Setting("CANONICAL POSITIONS (stage 2)", Stage(CanonicalVertexStage.Positions)), + new Setting("CANONICAL LOOP IDENTITY (stage 3)", Stage(CanonicalVertexStage.LoopIdentity)), + new Setting("CANONICAL EVERYWHERE (stage 4)", Stage(CanonicalVertexStage.Everywhere)), + }, + () => CompactHierarchyManager.TreeUpdate.kCanonicalVertexStage = original); + } + + static void RunSurvey(string reportName, string changedBy, Setting[] settings, Action restore) + { + var cases = new List(); + // Isolated corners where the output contradicts the oracle, and where the two sides put the corner in + // different places + var wrongCases = new List(); + var positionLeaks = new List(); + var outputs = new Dictionary<(int, string, int), List>(); + var tallies = new Dictionary<(int, string), Tally>(); + var stats = new string[settings.Length]; + // Every isolated corner's outcome, per setting, in corner order + var isolatedOutcomes = new Dictionary<(int, string, int), List>(); + // What the oracle expects of each side of every isolated corner, in corner order; the same for every setting + var expectations = new Dictionary<(string, int), List<(SharedCornerSurvey.Expectation a, SharedCornerSurvey.Expectation b)>>(); + (SharedCornerSurvey.Corner corner, List> a, List> b)? isolatedSample = null; + + var wasExactCSG = CompactHierarchyManager.TreeUpdate.kExactCSG; + try + { + CompactHierarchyManager.TreeUpdate.kExactCSG = false; + for (int setting = 0; setting < settings.Length; setting++) + { + settings[setting].apply(); + CanonicalVertexStats.Reset(); + foreach (var (name, make) in kPopulations) + { + var tally = new Tally(); + tallies[(setting, name)] = tally; + for (int seed = 1; seed <= kSeeds; seed++) + { + var scene = make(seed); + var oracle = new ContentsOracle(scene); + var brushes = oracle.Brushes; + var planes = brushes.Select(oracle.TreePlanesOf).ToList(); + + // Per brush, per plane: the distinct vertices it drew on that face + var byPlane = planes.Select(p => p.Select(_ => new List()).ToList()).ToList(); + var all = new List(); + using (var harness = ContentsTreeHarness.Build(scene)) + { + Assert.That(harness.Update(), Is.True, $"{name} seed {seed}: the CSG update did not run"); + var index = new Dictionary(); + for (int i = 0; i < brushes.Count; i++) + index[brushes[i]] = i; + foreach (var triangle in harness.Triangles) + { + var node = harness.NodeOf(triangle.brushID); + if (node == null) + continue; + var brush = index[node]; + all.Add(triangle.a); all.Add(triangle.b); all.Add(triangle.c); + var plane = SharedCornerSurvey.PlaneOf(triangle.surfaceNormal, triangle.Center, planes[brush]); + if (plane < 0) + continue; + byPlane[brush][plane].Add(triangle.a); + byPlane[brush][plane].Add(triangle.b); + byPlane[brush][plane].Add(triangle.c); + } + } + byPlane = byPlane.Select(faces => faces.Select(SharedCornerSurvey.Distinct).ToList()).ToList(); + outputs[(setting, name, seed)] = SharedCornerSurvey.Distinct(all); + tally.scenes++; + + var corners = SharedCornerSurvey.SharedCorners(planes); + if (!expectations.TryGetValue((name, seed), out var expected)) + { + expected = corners.Where(c => c.isolated) + .Select(c => (SharedCornerSurvey.Expect(oracle, brushes[c.brushA], c.planesA, c.position), + SharedCornerSurvey.Expect(oracle, brushes[c.brushB], c.planesB, c.position))) + .ToList(); + expectations[(name, seed)] = expected; + } + + var outcomes = new List(); + isolatedOutcomes[(setting, name, seed)] = outcomes; + foreach (var corner in corners) + { + var result = SharedCornerSurvey.ClassifyCorner(corner, byPlane[corner.brushA], byPlane[corner.brushB]); + var pair = brushes[corner.brushA].name + "/" + brushes[corner.brushB].name; + if (corner.isolated) + { + var isolatedIndex = outcomes.Count; + var wrong = Judge(expected[isolatedIndex], result); + outcomes.Add(new IsolatedOutcome(result.verdict, wrong)); + tally.isolatedCorners++; + if (wrong != null) + { + tally.isolatedWrong++; + wrongCases.Add(new Case + { + setting = setting, population = name, seed = seed, position = corner.position, pair = pair, + result = result, isolated = true, isolatedIndex = isolatedIndex, wrong = wrong + }); + } + if (result.isPositionDisagreement) + { + positionLeaks.Add($"[{setting}] {name} seed {seed}, isolated corner {isolatedIndex}, {pair} at {Format(corner.position)}: " + + $"{result.verdict}, {result.distance * 1000:0.###} mm"); + } + } + if (result.verdict == SharedCornerSurvey.Verdict.Hidden) + { + tally.hidden++; + continue; + } + tally.corners++; + (corner.isolated ? tally.isolated : tally.notIsolated)[(int)result.verdict]++; + if (result.isDisagreement) + { + if (!float.IsNaN(result.distance)) + tally.distances.Add(result.distance); + cases.Add(new Case + { + setting = setting, population = name, seed = seed, position = corner.position, + pair = pair, result = result, isolated = corner.isolated + }); + } + else if (setting == 0 && corner.isolated && isolatedSample == null && + result.verdict == SharedCornerSurvey.Verdict.Same) + { + isolatedSample = (corner, byPlane[corner.brushA], byPlane[corner.brushB]); + } + } + } + } + stats[setting] = CanonicalVertexStats.Describe(); + } + } + finally + { + restore(); + CompactHierarchyManager.TreeUpdate.kExactCSG = wasExactCSG; + } + + var controlPassed = false; + var controlText = "no isolated corner where both sides agree was found"; + if (isolatedSample.HasValue) + { + var (corner, a, b) = isolatedSample.Value; + var moved = a.Select(face => face.Select(v => math.distance(v, corner.position) <= SharedCornerSurvey.kWeldRadius ? v + kTwoMillimetres : v).ToList()).ToList(); + var control = SharedCornerSurvey.ClassifyCorner(corner, moved, b); + controlPassed = control.isDisagreement && math.abs(control.distance - 0.002f) < 0.00005f; + controlText = $"moving one side's vertices 2 mm at the isolated corner {Format(corner.position)} gives {control.verdict} " + + $"at {control.distance * 1000:0.###} mm: {(controlPassed ? "PASS" : "FAIL")}"; + } + + var changes = new List(); + var changedVerdicts = new int[settings.Length]; + var fixes = new int[settings.Length]; + var breaks = new int[settings.Length]; + for (int setting = 1; setting < settings.Length; setting++) + { + foreach (var (name, _) in kPopulations) + { + for (int seed = 1; seed <= kSeeds; seed++) + { + var control = isolatedOutcomes[(0, name, seed)]; + var other = isolatedOutcomes[(setting, name, seed)]; + for (int i = 0; i < control.Count && i < other.Count; i++) + { + if (control[i].verdict == other[i].verdict) + continue; + changedVerdicts[setting]++; + if (control[i].wrong != null && other[i].wrong == null) fixes[setting]++; + if (control[i].wrong == null && other[i].wrong != null) breaks[setting]++; + changes.Add($"[{setting}] {name} seed {seed}, isolated corner {i}: {control[i].Describe()} in the control, {other[i].Describe()} here"); + } + } + } + } + + var report = new StringBuilder(); + report.AppendLine($"Shared-corner survey of {changedBy}, {DateTime.Now:yyyy-MM-dd HH:mm}, seeds 1..{kSeeds} per population"); + report.AppendLine("A corner is a point on the surfaces of two brushes. Per pair of faces through it (one of each brush), the"); + report.AppendLine($"vertices each face drew within {SharedCornerSurvey.kWeldRadius * 1000:0.#} mm are compared by their nearest one: 'same' within {SharedCornerSurvey.kSamePosition * 1000:0.##} mm."); + report.AppendLine("A corner takes its worst face pair. 'missing' = one brush drew nothing near the corner on its faces through it."); + report.AppendLine("Isolated corners have no other face within the weld radius, so the two sides can't put them in different"); + report.AppendLine("places (negative control). Whether each side draws there is judged against the oracle instead: a face lost or"); + report.AppendLine("kept anywhere along its outline shows up at its isolated corners too, whatever the weld did there."); + report.AppendLine(); + for (int setting = 0; setting < settings.Length; setting++) + { + report.AppendLine($"[{setting}] {settings[setting].title}"); + foreach (var (name, _) in kPopulations) + { + var tally = tallies[(setting, name)]; + report.AppendLine($" {name}: {tally.scenes} scenes, {tally.corners} corners drawn, {tally.hidden} hidden"); + report.AppendLine($" isolated: {Counts(tally.isolated)}; the oracle contradicts {tally.isolatedWrong} of {tally.isolatedCorners}"); + report.AppendLine($" not isolated: {Counts(tally.notIsolated)}"); + report.AppendLine($" disagreement distances: {Distances(tally.distances)}"); + } + report.AppendLine($" canonical vertices: {stats[setting]}"); + report.AppendLine(); + } + + report.AppendLine("Scenes whose output a setting changed from the control's (any vertex added, removed or moved):"); + for (int setting = 1; setting < settings.Length; setting++) + { + report.AppendLine($" [{setting}] {settings[setting].title}"); + foreach (var (name, _) in kPopulations) + { + var changed = 0; + for (int seed = 1; seed <= kSeeds; seed++) + { + if (!SamePositions(outputs[(0, name, seed)], outputs[(setting, name, seed)])) + changed++; + } + report.AppendLine($" {name}: {changed} of {kSeeds}"); + } + } + report.AppendLine(); + report.AppendLine("Positive control: " + controlText); + report.AppendLine($"Negative control, isolated corners where the two sides put the corner in different places: {positionLeaks.Count}"); + foreach (var leak in positionLeaks.Take(10)) + report.AppendLine(" " + leak); + report.AppendLine(); + report.AppendLine("Isolated corners where the output contradicts the oracle, first 10 per setting:"); + for (int setting = 0; setting < settings.Length; setting++) + { + foreach (var item in wrongCases.Where(c => c.setting == setting).Take(10)) + { + report.AppendLine($" [{setting}] {item.population} seed {item.seed}, isolated corner {item.isolatedIndex}, {item.pair} (A/B) " + + $"at {Format(item.position)}: {item.wrong} ({item.result.verdict})"); + } + } + report.AppendLine(); + report.AppendLine("Isolated corners whose verdict a setting changed from the control's (information, judged by the oracle):"); + for (int setting = 1; setting < settings.Length; setting++) + report.AppendLine($" [{setting}] {changedVerdicts[setting]} changed: {fixes[setting]} were wrong and are now right, {breaks[setting]} were right and are now wrong"); + foreach (var change in changes.Take(25)) + report.AppendLine(" " + change); + report.AppendLine(); + report.AppendLine("Disagreements, first 25 per setting:"); + for (int setting = 0; setting < settings.Length; setting++) + { + foreach (var item in cases.Where(c => c.setting == setting).Take(25)) + { + report.AppendLine($" [{setting}] {item.population} seed {item.seed}, {item.pair} at {Format(item.position)}" + + $"{(item.isolated ? " (isolated)" : "")}: {item.result.verdict}, " + + $"{(float.IsNaN(item.result.distance) ? "nothing to measure" : $"{item.result.distance * 1000:0.###} mm")}" + + $" ({item.result.countA} vs {item.result.countB} vertices)"); + } + } + + var text = report.ToString(); + var path = Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, "..", "Logs", reportName + ".txt")); + File.WriteAllText(path, text); + UnityEngine.Debug.Log($"Shared-corner survey written to {path}\n{text}"); + + var examined = tallies.Values.Sum(t => t.corners); + Assert.That(examined, Is.GreaterThan(0), "the survey found no drawn corners at all"); + Assert.That(controlPassed, Is.True, controlText); + Assert.That(positionLeaks, Is.Empty, "the two sides put an isolated corner in different places, where nothing near it could move it"); + Assert.Pass($"{examined} corners examined; report at {path}"); + } + + // What is wrong with the output at an isolated corner by the oracle, or null when nothing is + static string Judge((SharedCornerSurvey.Expectation a, SharedCornerSurvey.Expectation b) expected, SharedCornerSurvey.Result result) + { + var wrongA = SharedCornerSurvey.JudgeSide(expected.a, result.drewA); + var wrongB = SharedCornerSurvey.JudgeSide(expected.b, result.drewB); + if (wrongA == null && wrongB == null) + return null; + if (wrongA != null && wrongB != null) + return $"A {wrongA}, B {wrongB}"; + return wrongA != null ? "A " + wrongA : "B " + wrongB; + } + + static string Counts(int[] counts) + { + return string.Join(", ", kReported.Select(v => $"{v.ToString().ToLowerInvariant()} {counts[(int)v]}")); + } + + // The bands of the welds the gate refused, so the two can be compared + static string Distances(List distances) + { + if (distances.Count == 0) + return "none"; + var sorted = distances.Select(d => d * 1000).OrderBy(d => d).ToList(); + var median = sorted[sorted.Count / 2]; + int Bin(float lo, float hi) => sorted.Count(d => d >= lo && d < hi); + return $"n={sorted.Count}, min {sorted[0]:0.###} mm, median {median:0.###} mm, max {sorted[sorted.Count - 1]:0.###} mm; " + + $"0.05-0.6: {Bin(0.05f, 0.6f)}, 0.6-1.2: {Bin(0.6f, 1.2f)}, 1.2-3: {Bin(1.2f, 3)}, 3-6: {Bin(3, 6)}, " + + $"6-12.5: {Bin(6, 12.5f)}, 12.5+: {Bin(12.5f, float.MaxValue)}"; + } + + static bool SamePositions(List a, List b) + { + if (a.Count != b.Count) + return false; + var remaining = new List(b); + foreach (var position in a) + { + var index = remaining.FindIndex(p => math.distance(p, position) <= 0.0000001f); + if (index < 0) + return false; + remaining.RemoveAt(index); + } + return true; + } + + static string Format(float3 position) => $"({position.x:0.####}, {position.y:0.####}, {position.z:0.####})"; + #endregion + } +} diff --git a/Core/Tests/Contents/SharedCornerSurveyTests.cs.meta b/Core/Tests/Contents/SharedCornerSurveyTests.cs.meta new file mode 100644 index 00000000..4f5fa4b0 --- /dev/null +++ b/Core/Tests/Contents/SharedCornerSurveyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 87a6a7647a18b9346a648a43c20ddcee \ No newline at end of file diff --git a/Core/Tests/ConvertToPlaneSpaceTests.cs b/Core/Tests/ConvertToPlaneSpaceTests.cs new file mode 100644 index 00000000..c55fe0e9 --- /dev/null +++ b/Core/Tests/ConvertToPlaneSpaceTests.cs @@ -0,0 +1,97 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class ConvertToPlaneSpaceTests + { + static UnsafeList Verts(params float3[] p) + { + var list = new UnsafeList(p.Length, Allocator.Temp); + for (int i = 0; i < p.Length; i++) list.Add(p[i]); + return list; + } + + static UnsafeList Edges(params (int a, int b)[] e) + { + var list = new UnsafeList(e.Length, Allocator.Temp); + for (int i = 0; i < e.Length; i++) + list.Add(new Edge { index1 = (ushort)e[i].a, index2 = (ushort)e[i].b }); + return list; + } + + static Vertex2DRemapper NewRemapper() => new() + { + lookup = new NativeList(16, Allocator.Temp), + positions2D = new NativeList(16, Allocator.Temp), + edgeIndices = new NativeList(32, Allocator.Temp) + }; + + // A 2x2 square in the z=0 plane whose bottom edge 0->1 is already split at midpoint v4=(1,0,0). + // 0=(0,0) 1=(2,0) 2=(2,2) 3=(0,2) 4=(1,0). Edges: 0-4 4-1 1-2 2-3 3-0. + static UnsafeList SquareWithMidpoint() => + Verts(new float3(0, 0, 0), new float3(2, 0, 0), new float3(2, 2, 0), + new float3(0, 2, 0), new float3(1, 0, 0)); + static UnsafeList SquareEdgesWithMidpoint() => + Edges((0, 4), (4, 1), (1, 2), (2, 3), (3, 0)); + + [Test] + public void CollinearMidpoint_IsRemoved_WhenUnprotected() + { + var verts = SquareWithMidpoint(); + var edges = SquareEdgesWithMidpoint(); + var map = new Map3DTo2D(new float3(0, 0, 1)); + var r = NewRemapper(); + + r.ConvertToPlaneSpace(verts, edges, map); + + // v4 is degree-2 and collinear on 0-1 -> collapsed away, leaving the 4 corners / 4 edges. + Assert.That(r.positions2D.Length, Is.EqualTo(4), "midpoint should have been collapsed"); + Assert.That(r.edgeIndices.Length / 2, Is.EqualTo(4)); + + r.Dispose(); verts.Dispose(); edges.Dispose(); + } + + [Test] + public void CollinearMidpoint_IsKept_WhenProtected() + { + var verts = SquareWithMidpoint(); + var edges = SquareEdgesWithMidpoint(); + var map = new Map3DTo2D(new float3(0, 0, 1)); + var r = NewRemapper(); + + var protectedVertices = new NativeArray(verts.Length, Allocator.Temp); + protectedVertices[4] = true; // the inserted T-junction vertex + + r.ConvertToPlaneSpace(verts, edges, map, protectedVertices); + + // Protected -> v4 survives: 5 vertices, the bottom edge stays split into two. + Assert.That(r.positions2D.Length, Is.EqualTo(5), "protected midpoint must be kept"); + Assert.That(r.edgeIndices.Length / 2, Is.EqualTo(5)); + + protectedVertices.Dispose(); + r.Dispose(); verts.Dispose(); edges.Dispose(); + } + + [Test] + public void SquareCorners_AreNeverCollapsed() + { + // Plain square, no midpoint: every vertex is a real (non-collinear) corner; none collapse, + // with or without a (here empty) protection mask. + var verts = Verts(new float3(0, 0, 0), new float3(2, 0, 0), new float3(2, 2, 0), new float3(0, 2, 0)); + var edges = Edges((0, 1), (1, 2), (2, 3), (3, 0)); + var map = new Map3DTo2D(new float3(0, 0, 1)); + var r = NewRemapper(); + + r.ConvertToPlaneSpace(verts, edges, map); + + Assert.That(r.positions2D.Length, Is.EqualTo(4)); + Assert.That(r.edgeIndices.Length / 2, Is.EqualTo(4)); + + r.Dispose(); verts.Dispose(); edges.Dispose(); + } + } +} diff --git a/Core/Tests/ConvertToPlaneSpaceTests.cs.meta b/Core/Tests/ConvertToPlaneSpaceTests.cs.meta new file mode 100644 index 00000000..7e59351e --- /dev/null +++ b/Core/Tests/ConvertToPlaneSpaceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3741bb1fc66778c4aaeab2bc3f5a116e \ No newline at end of file diff --git a/Core/Tests/Decals.meta b/Core/Tests/Decals.meta new file mode 100644 index 00000000..b7cf2876 --- /dev/null +++ b/Core/Tests/Decals.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1940c68cbf951894cb1ac08e3c5ea1a1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Core/Tests/Decals/DecalClippingTests.cs b/Core/Tests/Decals/DecalClippingTests.cs new file mode 100644 index 00000000..97a4fc59 --- /dev/null +++ b/Core/Tests/Decals/DecalClippingTests.cs @@ -0,0 +1,484 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + // The geometry decals are made of (Documentation~/Design/Decals.md): the volume, the projection, and cutting + // surface triangles by the volume. Pure functions, no CSG. + public sealed class DecalClippingTests + { + const double kAreaTolerance = 1e-9; + + struct ListSink : IDecalPieceSink + { + public List<(DecalPiece piece, bool inside)> pieces; + public void Add(in DecalPiece piece, bool inside) => pieces.Add((piece, inside)); + } + + static ChiselDecalInstance Decal(float3 size, float4x4 decalToTree, bool transparent = true, + ChiselDecalProjection projection = ChiselDecalProjection.Orthographic, + float fieldOfView = 30, float maxAngle = 180) + { + var settings = ChiselDecalSettings.Default; + settings.size = size; + settings.transparent = transparent; + settings.projection = projection; + settings.fieldOfView = fieldOfView; + settings.maxAngle = maxAngle; + return new ChiselDecalInstance + { + entityID = 1, + decalToTree = decalToTree, + settings = settings, + renderMaterial = 42, + destinationFlags = SurfaceDestinationFlags.Default + }; + } + + static DecalVolume Volume(ChiselDecalInstance instance) + { + Assert.That(DecalVolume.TryCreate(instance, out var volume), Is.True, "the decal should make a volume"); + return volume; + } + + static DecalVolume UnitBox(bool transparent = true) => Volume(Decal(new float3(1, 1, 1), float4x4.identity, transparent)); + + static DecalPiece Triangle(double3 a, double3 b, double3 c) + { + return new DecalPiece + { + v0 = new DecalClipVertex { position = a, barycentric = new double3(1, 0, 0) }, + v1 = new DecalClipVertex { position = b, barycentric = new double3(0, 1, 0) }, + v2 = new DecalClipVertex { position = c, barycentric = new double3(0, 0, 1) }, + owner = -1 + }; + } + + static double Area(in DecalPiece piece) + { + return math.length(math.cross(piece.v1.position - piece.v0.position, piece.v2.position - piece.v0.position)) * 0.5; + } + + static double3 Normal(in DecalPiece piece) + { + return math.cross(piece.v1.position - piece.v0.position, piece.v2.position - piece.v0.position); + } + + static List<(DecalPiece piece, bool inside)> Split(in DecalVolume volume, in DecalPiece triangle) + { + var sink = new ListSink { pieces = new List<(DecalPiece, bool)>() }; + var split = DecalClipping.Split(volume, triangle, ref sink, out var failure); + var corners = $"{triangle.v0.position} {triangle.v1.position} {triangle.v2.position}"; + Assert.That(split, Is.True, () => $"the split failed ({failure}) for the triangle {corners}"); + return sink.pieces; + } + + // The pieces must cover the triangle exactly, keep its winding, and lie on the side of the volume they say + static void AssertValidSplit(in DecalVolume volume, in DecalPiece triangle, List<(DecalPiece piece, bool inside)> pieces, + out double insideArea) + { + insideArea = 0; + var total = 0.0; + var normal = Normal(triangle); + foreach (var (piece, inside) in pieces) + { + var area = Area(piece); + total += area; + Assert.That(math.dot(Normal(piece), normal), Is.GreaterThan(0), "a piece is turned over"); + var center = (piece.v0.position + piece.v1.position + piece.v2.position) / 3.0; + if (inside) + { + insideArea += area; + Assert.That(volume.Contains(piece.v0.position) && volume.Contains(piece.v1.position) && volume.Contains(piece.v2.position), + Is.True, "an inside piece reaches outside the volume"); + } else + Assert.That(volume.Contains(center) && !OnBoundary(volume, center), Is.False, "an outside piece has its center inside the volume"); + + // Every point keeps the barycentric of where it is in the triangle + foreach (var vertex in new[] { piece.v0, piece.v1, piece.v2 }) + { + var b = vertex.barycentric; + var expected = b.x * triangle.v0.position + b.y * triangle.v1.position + b.z * triangle.v2.position; + Assert.That(math.distance(expected, vertex.position), Is.LessThan(1e-9), "a point lost its barycentric"); + } + } + Assert.That(total, Is.EqualTo(Area(triangle)).Within(kAreaTolerance + 1e-9 * Area(triangle)), "the pieces don't cover the triangle"); + } + + static bool OnBoundary(in DecalVolume volume, double3 point) + { + for (int i = 0; i < DecalPlanes.kCount; i++) + { + if (math.abs(volume.Distance(i, point)) <= DecalVolume.kPlaneEpsilon) + return true; + } + return false; + } + + #region Volume + [Test] + public void UnitBox_ContainsWhatIsInsideIt() + { + var volume = UnitBox(); + Assert.That(volume.Contains(new double3(0, 0, 0)), Is.True); + Assert.That(volume.Contains(new double3(0.49, -0.49, 0.49)), Is.True); + Assert.That(volume.Contains(new double3(0.5, 0.5, 0.5)), Is.True, "the boundary is inside"); + Assert.That(volume.Contains(new double3(0.51, 0, 0)), Is.False); + Assert.That(volume.Contains(new double3(0, 0, -0.51)), Is.False); + for (int i = 0; i < DecalPlanes.kCount; i++) + Assert.That(math.length(volume.planes[i].xyz), Is.EqualTo(1.0).Within(1e-12)); + Assert.That(volume.boundsMin, Is.EqualTo(new float3(-0.5f) - (float)DecalVolume.kPlaneEpsilon)); + } + + [Test] + public void Decal_WithoutMaterialOrSizeOrInverse_MakesNoVolume() + { + var noMaterial = Decal(new float3(1), float4x4.identity); + noMaterial.renderMaterial = 0; + Assert.That(DecalVolume.TryCreate(noMaterial, out _), Is.False); + + var flat = Decal(new float3(1, 1, 0), float4x4.identity); + Assert.That(DecalVolume.TryCreate(flat, out _), Is.False); + + var squashed = Decal(new float3(1), float4x4.Scale(1, 1, 0)); + Assert.That(DecalVolume.TryCreate(squashed, out _), Is.False); + + var hidden = Decal(new float3(1), float4x4.identity); + hidden.destinationFlags = SurfaceDestinationFlags.Collidable; + Assert.That(DecalVolume.TryCreate(hidden, out _), Is.False, "a material that isn't rendered draws nothing"); + } + + [Test] + public void OrthographicImage_SpansTheBox_AtEveryDepth() + { + var volume = Volume(Decal(new float3(2, 4, 1), float4x4.identity)); + Assert.That(volume.ProjectImage(new double3(0, 0, 0)), Is.EqualTo(new double2(0.5, 0.5))); + Assert.That(volume.ProjectImage(new double3(1, 2, 0.4)), Is.EqualTo(new double2(1, 1))); + Assert.That(volume.ProjectImage(new double3(-1, -2, -0.4)), Is.EqualTo(new double2(0, 0))); + Assert.That(volume.ProjectImage(new double3(0.5, -1, 0.3)), Is.EqualTo(new double2(0.75, 0.25))); + } + + [Test] + public void UVScaleAndOffset_MapTheImage() + { + var instance = Decal(new float3(1), float4x4.identity); + instance.settings.uvScale = new float2(-1, 2); + instance.settings.uvOffset = new float2(1, 0.5f); + var volume = Volume(instance); + Assert.That(volume.ProjectUV(new double3(-0.5, -0.5, 0)), Is.EqualTo(new float2(1, 0.5f))); + Assert.That(volume.ProjectUV(new double3(0.5, 0.5, 0)), Is.EqualTo(new float2(0, 2.5f))); + } + + [Test] + public void TransformedDecal_MatchesItsTransformation() + { + var decalToTree = float4x4.TRS(new float3(3, -2, 5), quaternion.EulerXYZ(0.3f, 1.1f, -0.7f), new float3(2, 0.5f, 3)); + var volume = Volume(Decal(new float3(1, 1, 1), decalToTree)); + var random = new Random(7); + for (int i = 0; i < 200; i++) + { + var local = random.NextFloat3(-0.7f, 0.7f); + var tree = math.transform(decalToTree, local); + var inside = math.all(math.abs(local) <= 0.5f); + var nearBoundary = math.any(math.abs(math.abs(local) - 0.5f) < 1e-3f); + if (!nearBoundary) + Assert.That(volume.Contains(tree), Is.EqualTo(inside), $"local {local}"); + var image = volume.ProjectImage(tree); + Assert.That(image.x, Is.EqualTo(local.x + 0.5).Within(1e-5)); + Assert.That(image.y, Is.EqualTo(local.y + 0.5).Within(1e-5)); + } + } + + [Test] + public void PerspectiveImage_HasTheBoxSizeAtItsCenter_AndGrowsWithDepth() + { + var volume = Volume(Decal(new float3(2, 2, 2), float4x4.identity, projection: ChiselDecalProjection.Perspective, fieldOfView: 90)); + // fov 90 with a half height of 1: the apex is 1 behind the center plane, at z = -1 + Assert.That(volume.focal, Is.EqualTo(1.0).Within(1e-12)); + Assert.That(volume.ProjectImage(new double3(1, 1, 0)).x, Is.EqualTo(1.0).Within(1e-12)); + // Twice as far from the apex, the image is twice as large + Assert.That(volume.ProjectImage(new double3(2, 0, 1)).x, Is.EqualTo(1.0).Within(1e-12)); + Assert.That(volume.Contains(new double3(1.9, 1.9, 1)), Is.True, "the far face is wider"); + Assert.That(volume.Contains(new double3(1.1, 0, 0)), Is.False); + // The near face (z = -1) would sit on the apex; it is moved just in front of it + Assert.That(volume.Contains(new double3(0, 0, -0.9)), Is.True); + Assert.That(volume.Contains(new double3(0.2, 0, -0.9)), Is.False, "near the apex the frustum is narrow"); + } + + [Test] + public void Surface_FacingAwayFromTheCenter_DoesNotTakeTheDecal() + { + var volume = UnitBox(); + // A floor at the bottom of the box, facing up at the center + Assert.That(volume.Accepts(new double3(0, 1, 0), new double3(0, -0.4, 0)), Is.True); + // Its underside + Assert.That(volume.Accepts(new double3(0, -1, 0), new double3(0, -0.4, 0)), Is.False); + // A surface through the center faces it either way + Assert.That(volume.Accepts(new double3(0, -1, 0), new double3(0, 0, 0)), Is.True); + // Without an angle limit a wall at the side takes it too + Assert.That(volume.Accepts(new double3(-1, 0, 0), new double3(0.4, 0, 0)), Is.True); + } + + [Test] + public void AngleLimit_SkipsSteepSurfaces() + { + // Projecting along +Z: a surface facing -Z faces the projector + var volume = Volume(Decal(new float3(1), float4x4.identity, maxAngle: 45)); + Assert.That(volume.Accepts(new double3(0, 0, -1), new double3(0, 0, 0.4)), Is.True); + Assert.That(volume.Accepts(math.normalize(new double3(0, 0.5, -1)), new double3(0, 0, 0)), Is.True); + Assert.That(volume.Accepts(math.normalize(new double3(0, 1.5, -1)), new double3(0, 0, 0)), Is.False); + Assert.That(volume.Accepts(new double3(-1, 0, 0), new double3(0.4, 0, 0)), Is.False); + } + + [Test] + public void DestinationFlags_FollowTheSurface() + { + var transparent = UnitBox(transparent: true); + var opaque = UnitBox(transparent: false); + var surface = SurfaceDestinationFlags.Default; + Assert.That(transparent.GetDestinationFlags(surface), + Is.EqualTo(SurfaceDestinationFlags.RenderShadowsReceiving | SurfaceDestinationFlags.ExcludedFromGlobalIllumination), + "a transparent decal neither casts shadows nor collides, so it is out of the baked lighting too"); + Assert.That(opaque.GetDestinationFlags(surface), Is.EqualTo(SurfaceDestinationFlags.Default & ~SurfaceDestinationFlags.Collidable), + "an opaque decal stands in for the surface it replaces, baked lighting and all, but the collider keeps the surface"); + Assert.That(opaque.GetDestinationFlags(SurfaceDestinationFlags.Renderable), + Is.EqualTo(SurfaceDestinationFlags.Renderable | SurfaceDestinationFlags.ExcludedFromGlobalIllumination), + "a surface that casts no shadows keeps the decal out of the bake as well"); + Assert.That(opaque.GetDestinationFlags(SurfaceDestinationFlags.Collidable), Is.EqualTo(SurfaceDestinationFlags.None), + "a surface that isn't rendered gets no decal"); + } + #endregion + + #region Edge points + [Test] + public void EdgeThroughTheBox_GetsItsEntryAndExit_InOrder() + { + var volume = UnitBox(); + var a = new DecalClipVertex { position = new double3(-2, 0, 0), barycentric = new double3(1, 0, 0) }; + var b = new DecalClipVertex { position = new double3( 2, 0, 0), barycentric = new double3(0, 1, 0) }; + Assert.That(DecalClipping.GetEdgePoints(volume, a, b, out var first, out var second), Is.EqualTo(2)); + Assert.That(first.position.x, Is.EqualTo(-0.5).Within(1e-12)); + Assert.That(second.position.x, Is.EqualTo(0.5).Within(1e-12)); + Assert.That(first.barycentric, Is.EqualTo(new double3(0.625, 0.375, 0))); + + Assert.That(DecalClipping.GetEdgePoints(volume, b, a, out var backFirst, out var backSecond), Is.EqualTo(2)); + Assert.That(backFirst.position.x, Is.EqualTo(0.5).Within(1e-12), "walked backwards, the exit comes first"); + Assert.That(backSecond.position.x, Is.EqualTo(-0.5).Within(1e-12)); + } + + [Test] + public void EdgePoints_AreTheSame_WhicheverWayTheEdgeIsWalked() + { + var volume = Volume(Decal(new float3(1.3f, 0.7f, 0.9f), + float4x4.TRS(new float3(0.1f, 0.2f, -0.3f), quaternion.EulerXYZ(0.2f, 0.4f, 0.8f), new float3(1)))); + var random = new Random(11); + var checkedCount = 0; + for (int i = 0; i < 500; i++) + { + var a = new DecalClipVertex { position = random.NextFloat3(-2, 2), barycentric = new double3(1, 0, 0) }; + var b = new DecalClipVertex { position = random.NextFloat3(-2, 2), barycentric = new double3(0, 1, 0) }; + var forward = DecalClipping.GetEdgePoints(volume, a, b, out var f0, out var f1); + var backward = DecalClipping.GetEdgePoints(volume, b, a, out var b0, out var b1); + Assert.That(backward, Is.EqualTo(forward)); + if (forward == 1) + Assert.That(f0.position.Equals(b0.position), Is.True, "bit for bit"); + if (forward == 2) + { + Assert.That(f0.position.Equals(b1.position), Is.True, "bit for bit"); + Assert.That(f1.position.Equals(b0.position), Is.True, "bit for bit"); + checkedCount++; + } + } + Assert.That(checkedCount, Is.GreaterThan(10), "too few edges crossed the box to mean anything"); + } + + [Test] + public void EdgeOutsideOrInside_GetsNoPoints() + { + var volume = UnitBox(); + var outside = DecalClipping.GetEdgePoints(volume, + new DecalClipVertex { position = new double3(-2, 1, 0) }, new DecalClipVertex { position = new double3(2, 1, 0) }, out _, out _); + Assert.That(outside, Is.EqualTo(0)); + var inside = DecalClipping.GetEdgePoints(volume, + new DecalClipVertex { position = new double3(-0.2, 0, 0) }, new DecalClipVertex { position = new double3(0.2, 0, 0) }, out _, out _); + Assert.That(inside, Is.EqualTo(0)); + var endingOnTheBoundary = DecalClipping.GetEdgePoints(volume, + new DecalClipVertex { position = new double3(-2, 0, 0) }, new DecalClipVertex { position = new double3(-0.5, 0, 0) }, out _, out _); + Assert.That(endingOnTheBoundary, Is.EqualTo(0), "an end on the boundary is no new point"); + } + + [Test] + public void EdgeTouchingACorner_GetsTheTouchingPoint() + { + var volume = UnitBox(); + // Passes through the box's edge at (0.5, 0.5, z) only + var count = DecalClipping.GetEdgePoints(volume, + new DecalClipVertex { position = new double3(0, 1, 0) }, new DecalClipVertex { position = new double3(1, 0, 0) }, out var point, out _); + Assert.That(count, Is.EqualTo(1)); + Assert.That(math.distance(point.position, new double3(0.5, 0.5, 0)), Is.LessThan(1e-9)); + } + #endregion + + #region Split + [Test] + public void TriangleInside_IsAllInside() + { + var volume = UnitBox(); + var triangle = Triangle(new double3(-0.2, 0, 0), new double3(0, 0.3, 0), new double3(0.2, 0, 0)); + var pieces = Split(volume, triangle); + AssertValidSplit(volume, triangle, pieces, out var inside); + Assert.That(inside, Is.EqualTo(Area(triangle)).Within(kAreaTolerance)); + } + + [Test] + public void TriangleOutside_StaysWhole() + { + var volume = UnitBox(); + var triangle = Triangle(new double3(2, 0, 0), new double3(2, 1, 0), new double3(3, 0, 0)); + var pieces = Split(volume, triangle); + Assert.That(pieces.Count, Is.EqualTo(1)); + Assert.That(pieces[0].inside, Is.False); + Assert.That(pieces[0].piece.v0.position, Is.EqualTo(triangle.v0.position)); + } + + [Test] + public void TriangleAroundTheBox_KeepsARingAroundIt() + { + var volume = UnitBox(); + // A floor triangle (in the XZ plane at y = 0) far larger than the box + var triangle = Triangle(new double3(-10, 0, -10), new double3(0, 0, 10), new double3(10, 0, -10)); + var pieces = Split(volume, triangle); + AssertValidSplit(volume, triangle, pieces, out var inside); + Assert.That(inside, Is.EqualTo(1.0).Within(1e-9), "the box's cross section is 1 by 1"); + } + + [Test] + public void TriangleOverTheBoxCorner_SplitsAlongTheBox() + { + var volume = UnitBox(); + // The triangle's corner (0,0,0) is at the box's center: a quarter of the box's cross section is inside + var triangle = Triangle(new double3(0, 0, 0), new double3(0, 0, 4), new double3(4, 0, 0)); + var pieces = Split(volume, triangle); + AssertValidSplit(volume, triangle, pieces, out var inside); + // Inside: the part of the triangle with x <= 0.5 and z <= 0.5, which is the square [0,0.5]^2 + Assert.That(inside, Is.EqualTo(0.25).Within(1e-9)); + } + + [Test] + public void TriangleThroughTheBox_SplitsIntoThree() + { + var volume = UnitBox(); + // A long thin triangle crossing the box from side to side + var triangle = Triangle(new double3(-3, 0, -0.2), new double3(3, 0, 0.2), new double3(3, 0, -0.2)); + var pieces = Split(volume, triangle); + AssertValidSplit(volume, triangle, pieces, out var inside); + var expected = 0.0; + for (int i = 0; i < 1000; i++) + { + var x = -0.5 + (i + 0.5) / 1000.0; + var top = -0.2 + (x + 3) / 6.0 * 0.4; + expected += (top + 0.2) / 1000.0; + } + Assert.That(inside, Is.EqualTo(expected).Within(1e-6)); + } + + [Test] + public void TriangleFacingEitherWay_KeepsItsWinding() + { + var volume = UnitBox(); + var front = Triangle(new double3(-3, 0, -3), new double3(0, 0, 3), new double3(3, 0, -3)); + var back = Triangle(new double3(-3, 0, -3), new double3(3, 0, -3), new double3(0, 0, 3)); + AssertValidSplit(volume, front, Split(volume, front), out var frontInside); + AssertValidSplit(volume, back, Split(volume, back), out var backInside); + Assert.That(frontInside, Is.EqualTo(1.0).Within(1e-9)); + Assert.That(backInside, Is.EqualTo(1.0).Within(1e-9)); + } + + [Test] + public void RandomTrianglesAndDecals_SplitValidly() + { + var random = new Random(1234); + var splits = 0; + for (int i = 0; i < 400; i++) + { + var decalToTree = float4x4.TRS(random.NextFloat3(-1, 1), + quaternion.EulerXYZ(random.NextFloat3(-3, 3)), + random.NextFloat3(0.5f, 2)); + var perspective = (i % 3) == 0; + var volume = Volume(Decal(random.NextFloat3(0.3f, 2), decalToTree, + projection: perspective ? ChiselDecalProjection.Perspective : ChiselDecalProjection.Orthographic, + fieldOfView: random.NextFloat(10, 120))); + var triangle = Triangle(random.NextFloat3(-3, 3), random.NextFloat3(-3, 3), random.NextFloat3(-3, 3)); + if (Area(triangle) < 1e-3) + continue; + var pieces = Split(volume, triangle); + AssertValidSplit(volume, triangle, pieces, out var inside); + + // What is inside matches clipping the triangle on its own + var clipped = new ListSink { pieces = new List<(DecalPiece, bool)>() }; + var expected = 0.0; + if (DecalClipping.AddInside(volume, triangle, 0, ref clipped)) + { + foreach (var (piece, _) in clipped.pieces) + expected += Area(piece); + } + Assert.That(inside, Is.EqualTo(expected).Within(1e-9 + 1e-9 * Area(triangle)), $"case {i}"); + if (inside > 0 && inside < Area(triangle) - 1e-6) + splits++; + } + Assert.That(splits, Is.GreaterThan(40), "too few triangles were actually split to mean anything"); + } + + // Two triangles sharing an edge, cut by the same decal, must put the same points on that edge: otherwise the + // pieces on either side leave a crack (a T-junction) along it. + [Test] + public void NeighbouringTriangles_GetTheSamePointsOnTheirSharedEdge() + { + var random = new Random(99); + var compared = 0; + for (int i = 0; i < 300; i++) + { + var decalToTree = float4x4.TRS(random.NextFloat3(-0.5f, 0.5f), quaternion.EulerXYZ(random.NextFloat3(-3, 3)), new float3(1)); + var volume = Volume(Decal(random.NextFloat3(0.5f, 1.5f), decalToTree)); + var a = (double3)random.NextFloat3(-2, 2); + var b = (double3)random.NextFloat3(-2, 2); + // Two triangles on either side of a-b, in one plane, wound the same way (so they walk a-b oppositely) + var side = math.normalize(math.cross(b - a, random.NextFloat3Direction())); + var mid = (a + b) * 0.5; + var left = Triangle(a, b, mid + side); + var right = Triangle(b, a, mid - side); + if (Area(left) < 1e-3 || Area(right) < 1e-3) + continue; + + var leftPoints = PointsOnSegment(Split(volume, left), a, b); + var rightPoints = PointsOnSegment(Split(volume, right), a, b); + Assert.That(rightPoints, Is.EquivalentTo(leftPoints), $"case {i}"); + if (leftPoints.Count > 2) + compared++; + } + Assert.That(compared, Is.GreaterThan(20), "too few shared edges crossed the decal to mean anything"); + } + + static List PointsOnSegment(List<(DecalPiece piece, bool inside)> pieces, double3 a, double3 b) + { + var result = new HashSet(); + var direction = b - a; + var lengthSqr = math.lengthsq(direction); + foreach (var (piece, _) in pieces) + { + foreach (var vertex in new[] { piece.v0, piece.v1, piece.v2 }) + { + var t = math.dot(vertex.position - a, direction) / lengthSqr; + if (t < -1e-12 || t > 1 + 1e-12) + continue; + if (math.distancesq(a + direction * t, vertex.position) > 1e-18) + continue; + result.Add(vertex.position); + } + } + return new List(result); + } + #endregion + } +} diff --git a/Core/Tests/Decals/DecalClippingTests.cs.meta b/Core/Tests/Decals/DecalClippingTests.cs.meta new file mode 100644 index 00000000..9e4898c2 --- /dev/null +++ b/Core/Tests/Decals/DecalClippingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7475186186ee02947867ecef7b202e73 \ No newline at end of file diff --git a/Core/Tests/EndWeldTests.cs b/Core/Tests/EndWeldTests.cs new file mode 100644 index 00000000..5c6d3bee --- /dev/null +++ b/Core/Tests/EndWeldTests.cs @@ -0,0 +1,641 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class EndWeldTests + { + internal static readonly uint[] kDrainCap = + { + 0x00000000,0x3F800000,0x00000000,0xBFFB2847, 0x00000000,0xBF800000,0x00000000,0xBFD9529A, 0x00000000,0x00000000,0xBF800000,0xBF930CF7, + 0x00000000,0x00000000,0x3F800000,0xBFA544F4, 0xBF800000,0x00000000,0x00000000,0xBE1C291A, 0x3F800000,0x00000000,0x00000000,0xBE1C28D2, + 0xBF3504F3,0x3F3504F3,0x00000000,0xBFB19859, 0xBF3504F3,0xBF3504F3,0x00000000,0xBF99AB9E, 0xBF3504F3,0x00000000,0xBF3504F3,0xBF4FF60B, + 0xBF3504F3,0x00000000,0x3F3504F3,0xBF69B9EC, + }; + internal static readonly uint[] kDrainCapPivot = { 0xC1A37AE1, 0xC173ACB7, 0x42099349 }; + + // The same drain cap, placed and sized a little differently + static readonly uint[] kMovedDrainCap = + { + 0x00000000,0x3F800000,0x00000000,0xBFA7DF3B, 0x00000000,0xBF800000,0x00000000,0xBF9072B0, 0x00000000,0x00000000,0xBF800000,0xBF88A3D0, + 0x00000000,0x00000000,0x3F800000,0xBFAFAE1C, 0xBF800000,0x00000000,0x00000000,0xBE215DC3, 0x3F800000,0x00000000,0x00000000,0xBE16F429, + 0xBF3504F3,0x3F3504F3,0x00000000,0xBF6E53C4, 0xBF3504F3,0xBF3504F3,0x00000000,0xBF4D335F, 0xBF3504F3,0x00000000,0xBF3504F3,0xBF422889, + 0xBF3504F3,0x00000000,0x3F3504F3,0xBF795E9A, + }; + static readonly uint[] kMovedDrainCapPivot = { 0x41B0F0D0, 0xC0490E56, 0x41828A3D }; + + static float4[] Planes(uint[] bits) + { + var planes = new float4[bits.Length / 4]; + for (int p = 0; p < planes.Length; p++) + planes[p] = math.asfloat(new uint4(bits[p * 4], bits[p * 4 + 1], bits[p * 4 + 2], bits[p * 4 + 3])); + return planes; + } + + static float3 Pivot(uint[] bits) => math.asfloat(new uint3(bits[0], bits[1], bits[2])); + + static string Describe(ContentsTreeHarness.CapturedTriangle triangle) + { + return $"({triangle.a.x:R}, {triangle.a.y:R}, {triangle.a.z:R}) ({triangle.b.x:R}, {triangle.b.y:R}, {triangle.b.z:R}) ({triangle.c.x:R}, {triangle.c.y:R}, {triangle.c.z:R})"; + } + + static void AssertDrawsNoTriangleWithTwoCornersAtOnePosition(uint[] planeBits, uint[] pivotBits, string what) + { + var node = ContentsSceneNode.Brush(Planes(planeBits), localToTree: float4x4.Translate(Pivot(pivotBits)), name: what); + using var harness = ContentsTreeHarness.Build(new ContentsScene().Add(node)); + harness.CheckWeldEachUpdate = false; + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + Assert.That(harness.TriangleCountOf(node), Is.GreaterThan(0), what + " draws nothing: " + harness.LastUpdateReport); + + var degenerate = harness.Triangles.Where(t => ContentsTreeHarness.TwoCornersAtOnePosition(t.a, t.b, t.c)).ToList(); + Assert.That(degenerate.Count, Is.EqualTo(0), + $"{what}: {degenerate.Count} of its {harness.Triangles.Count} triangles have two corners at one float position, " + + "e.g. " + string.Join("; ", degenerate.Take(3).Select(Describe))); + Assert.That(harness.DegenerateTriangleCount, Is.EqualTo(0), what + ": the render meshes hold triangles with two corners at one float position"); + Assert.That(harness.LookupMismatches, Is.Null, what + ": the picking lookup is out of step with the triangles: " + harness.LookupMismatches); + } + + [Test] + public void TheDrainCap_DrawsNoTriangleWithTwoCornersAtOneFloatPosition() + { + AssertDrawsNoTriangleWithTwoCornersAtOnePosition(kDrainCap, kDrainCapPivot, "the drain cap"); + } + + [Test] + public void TheMovedDrainCap_DrawsNoTriangleWithTwoCornersAtOneFloatPosition() + { + AssertDrawsNoTriangleWithTwoCornersAtOnePosition(kMovedDrainCap, kMovedDrainCapPivot, "the moved drain cap"); + } + + internal static readonly uint[][] kWedges = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBD6A3D6D, 0x3E50C259,0xBF34D288,0x3F2D89C4,0xBD258553, 0xBF690DC7,0x00000000,0x3ED3DDFB,0xBF1B4C98, + 0xBE4BD65D,0x00000000,0xBF7AE073,0xBD65E81F }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBDBB6456, 0x3E507E7A,0xBF358491,0x3F2CD4A2,0xBD5D6AA7, 0x3F7C8450,0x00000000,0xBE285835,0xBF7ECB56, + 0xBE93D5F7,0x00000000,0xBF7518A0,0xBD7B49B0, 0x3E4BDA49,0x00000000,0x3F7AE040,0xBD9E39CF }, + }; + internal static readonly uint[][] kWedgesPivots = { new uint[] { 0xC21C1083, 0x3F8B147B, 0x4317A59F }, new uint[] { 0xC218CB74, 0x3F86B021, 0x43175846 } }; + // Three brushes that make a needle 0.00004 units long + static readonly uint[][] kDetailNeedle = + { + new uint[] { 0xBF77E394,0x379A2F2D,0xBE7FB863,0xBF0B2C7F, 0x3EC1B800,0x384AB406,0xBF6CF80A,0xBE9B303B, 0xBEC2ED09,0x00000000,0x3F6CB8A7,0xBE9B36CA, + 0x3F3504D6,0x00000000,0x3F350511,0xBF220074, 0x00000000,0xBF800000,0x00000000,0xC0010C85, 0x00000000,0x3F800000,0x00000000,0xC04ACA85 }, + new uint[] { 0x00000000,0xBF800000,0x00000000,0xC0DCAF2F, 0xBF800000,0x00000000,0x00000000,0xBF7B9752, 0x3F76E32E,0x00000000,0x3E8763CF,0xBF9D0622, + 0x3E01D4B8,0x00000000,0xBF7DEF1D,0xBF9B15FB, 0xBE0551F4,0x00000000,0x3F7DD22E,0xBF9B149E, 0x00000000,0x3F800000,0x00000000,0xC08C6F89 }, + new uint[] { 0xBF76E32E,0x00000000,0xBE8763CF,0xBFA7DC84, 0x3F5D4971,0xB6392C10,0x3F00B79B,0xBF705887, 0x3EC75EDF,0x00000000,0xBF6BCB48,0xBF8797BD, + 0xBEC1AF20,0x00000000,0x3F6CF9DA,0xBFAD9F3D, 0x3F3503E9,0x00000000,0x3F3505FD,0xBF4095CB, 0x00000000,0x3F800000,0x00000000,0xC0D3EE69, + 0x00000000,0xBF800000,0x00000000,0xC12C31C1 }, + }; + static readonly uint[][] kDetailNeedlePivots = + { + new uint[] { 0xC3114D22, 0xC370C37C, 0x41A22BBE }, new uint[] { 0xC312A321, 0xC36D689B, 0x41936AAB }, new uint[] { 0xC3102612, 0xC36FA492, 0x4197994D }, + }; + + // A needle on a seam between three brushes + internal static readonly uint[][] kNeedleOnABrushEdge = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBEF2EA7B, 0xBF6FB345,0x3EB3C674,0x00000000,0xBF26868C, 0x3F6FB345,0x3EB3C674,0x00000000,0xBF5333C5, + 0x00000000,0x3EB3C674,0xBF6FB345,0xBF36C595, 0x00000000,0x3EB3C674,0x3F6FB345,0xBF42F4BD, 0x00000000,0xBF800000,0x00000000,0xBF3EDCAE }, + new uint[] { 0x00000000,0xBF800000,0x00000000,0xBEF2EA61, 0xBF6FB345,0xBEB3C674,0x00000000,0xBF268688, 0x3F6FB345,0xBEB3C674,0x00000000,0xBF5333C1, + 0x00000000,0xBEB3C674,0xBF6FB345,0xBF42F4B6, 0x00000000,0xBEB3C674,0x3F6FB345,0xBF36C593, 0x00000000,0x3F800000,0x00000000,0xBF3EDCBB }, + new uint[] { 0xBF800000,0x00000000,0x00000000,0xBF72EA33, 0x3F800000,0x00000000,0x00000000,0xBF97D294, 0x00000000,0x00000000,0xBF800000,0xBF88A3E6, + 0x00000000,0x00000000,0x3F800000,0xBF88A3C8, 0x00000000,0xBF800000,0x00000000,0xBF72EA45, 0x00000000,0x3F800000,0x00000000,0xBFBEDCC9 }, + }; + internal static readonly uint[][] kNeedleOnABrushEdgePivots = + { + new uint[] { 0x4298EC72, 0x4283D72A, 0x423E37E5 }, new uint[] { 0x4298EC72, 0x4277F4DF, 0x423E6BF2 }, new uint[] { 0x4298DF6E, 0x427EBBFB, 0x423E51EC }, + }; + // A needle on a seam, from x = -39.04 through x = 0 to x = 1.37e-8: coordinates 55 powers of two apart + internal static readonly uint[][] kFarApartNeedle = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBE3EDCBA, 0x00000000,0xBF800000,0x00000000,0xBDF2EA64, 0xBF800000,0x00000000,0x00000000,0xC00ACF14, + 0x3F800000,0x00000000,0x00000000,0xC02D82D8, 0xBF3504F3,0x00000000,0x3F3504F3,0xC0ABC46A, 0xBF550140,0x00000000,0xBF0E00D5,0xC086BECD }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBDF2EA73, 0x00000000,0xBF800000,0x00000000,0xBE3EDCB2, 0x3F3504F3,0x00000000,0xBF3504F3,0xC110296B, + 0x00000000,0x00000000,0x3F800000,0xC02D82D8, 0xBE785B42,0x00000000,0xBF785B42,0xC045C9D1, 0x00000000,0x00000000,0xBF800000,0xC00ACF13 }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBDF2EA73, 0x00000000,0xBF800000,0x00000000,0xBE3EDCB2, 0x3F550140,0x00000000,0x3F0E00D5,0xC13BAE53, + 0x00000000,0x00000000,0xBF800000,0xC050369E, 0xBEB3C674,0x00000000,0x3F6FB345,0xC09E66E4, 0x00000000,0x00000000,0x3F800000,0xC0822222 }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBE3EDCBA, 0x00000000,0xBF800000,0x00000000,0xBDF2EA64, 0xBF800000,0x00000000,0x00000000,0xC12D82D9, + 0x3E785B42,0x00000000,0x3F785B42,0xC0EBA9A7, 0x3EB3C674,0x00000000,0xBF6FB345,0xC0E372EC, 0x3F800000,0x00000000,0x00000000,0xC10ACF12 }, + }; + internal static readonly uint[][] kFarApartNeedlePivots = + { + new uint[] { 0xC02D82D8, 0x40A4D5E7, 0xC0FB9753 }, new uint[] { 0xC17740DA, 0x40A70123, 0xBE8ACF13 }, + new uint[] { 0xC1822222, 0x40A70123, 0xC1822222 }, new uint[] { 0xC1E1907F, 0x40A4D5E7, 0xC0FB9753 }, + }; + // A needle on the seam between two brushes + internal static readonly uint[][] kSeam = + { + new uint[] { 0x00000000,0x3F3504F3,0x3F3504F3,0xBF42C59C, 0x00000000,0xBF800000,0x00000000,0xBF5C2465, 0x00000000,0x00000000,0xBF800000,0xBD3EDC29, + 0x00000000,0x00000000,0x3F800000,0xBCF2EB85, 0xBF800000,0x00000000,0x00000000,0xBE1C2885, 0x3F800000,0x00000000,0x00000000,0xBE1C2966 }, + new uint[] { 0x3F800000,0x00000000,0x00000000,0xBE2D8266, 0xBF800000,0x00000000,0x00000000,0xBE0ACF85, 0x00000000,0xBF3504F3,0xBF3504F3,0xBECD81C4, + 0x00000000,0xBF3504F3,0x3F3504F3,0xBECA70BD, 0x00000000,0x3F800000,0x00000000,0xBD3EDC9A, 0x00000000,0xBF800000,0x00000000,0xBCF2EAA4 }, + new uint[] { 0x3F800000,0x00000000,0x00000000,0xBD9C28CD, 0xBF800000,0x00000000,0x00000000,0xBD9C291F, 0x00000000,0x3F800000,0x00000000,0xBE8F2594, + 0x00000000,0xBF800000,0x00000000,0xBE362FBA, 0x00000000,0x00000000,0xBF800000,0xBF0ACF03, 0x00000000,0x00000000,0x3F800000,0xBF2D82E9 }, + }; + internal static readonly uint[][] kSeamPivots = + { + new uint[] { 0x42697A3D, 0xC0CEB8E4, 0x4220EBE0 }, new uint[] { 0x426968E4, 0xC0AC4A86, 0x42233123 }, new uint[] { 0x4269C852, 0xC0A51B4F, 0x4222E765 }, + }; + // Two facing needles, each with the other across its long edge + internal static readonly uint[][] kFacingNeedles = + { + new uint[] { 0x00000000,0x00000000,0xBF800000,0xBF8F2588, 0xBF3504F3,0x00000000,0xBF3504F3,0xBD755D16, 0x3F3504F3,0x00000000,0x3F3504F3,0xBD4452D5, + 0x00000000,0xBF800000,0x00000000,0xC06E93F8, 0x00000000,0x3F800000,0x00000000,0xC017D26E, 0x00000000,0x00000000,0x3F800000,0xBF362FD1 }, + new uint[] { 0xBF800000,0x00000000,0x00000000,0xBFEFA952, 0xBF3504F3,0x00000000,0x3F3504F3,0xBD5CD318, 0x3F3504F3,0x00000000,0xBF3504F3,0xBD5CDCD3, + 0x00000000,0xBF800000,0x00000000,0xC058E378, 0x00000000,0x3F800000,0x00000000,0xC02D82EE, 0x00000000,0x00000000,0x3F800000,0xBFEE93E8 }, + new uint[] { 0x00000000,0xBF800000,0x00000000,0xBDA68F0A, 0xBF800000,0x00000000,0x00000000,0xBF1C28C3, 0x00000000,0x3F800000,0x00000000,0xBD91C2E1, + 0x3F3504F3,0x00000000,0x3F3504F3,0xBF5CD825, 0x3F3504F3,0x00000000,0xBF3504F3,0xBF5CD80F }, + }; + internal static readonly uint[][] kFacingNeedlesPivots = + { + new uint[] { 0xC30C1D16, 0xC3721A2B, 0x41B8A06D }, new uint[] { 0xC30CDE1E, 0xC37270ED, 0x41A0C4D6 }, new uint[] { 0xC30C4CCD, 0xC375BFA9, 0x41AFAE14 }, + }; + static readonly uint[][] kSplitTwice = + { + new uint[] { 0x00000000,0xBF800000,0x00000000,0xBD6A3D76,0xBF330AA2,0x3F361247,0x3D915B93,0xBD26966E,0xBE285835,0xB8B2DDB9,0xBF7C8450,0xBF1CC351, + 0x3F7FFE10,0xB53266B0,0xBBFBDC43,0xBD6A8ABB }, + new uint[] { 0x00000000,0xBF800000,0x00000000,0xBE3EDCB8,0x00000000,0x3F800000,0x00000000,0xBDF2EA66,0xBE785B42,0x00000000,0xBF785B42,0xBFA05F89, + 0x3E785B42,0x00000000,0x3F785B42,0xBFA05F7E,0x3F751896,0x00000000,0xBE93D63C,0xBD32A1EC,0xBF751896,0x00000000,0x3E93D63C,0xBD0EEE06 }, + new uint[] { 0x00000000,0xBF800000,0x00000000,0xBE3EDCB8,0x00000000,0x3F800000,0x00000000,0xBDF2EA66,0xBE785B42,0x00000000,0xBF785B42,0xBF9E4690, + 0x00000000,0x3BCCBEFA,0x3F7FFEB8,0xBF9F312C,0x3F7EBFAA,0x00000000,0xBDCA3EA7,0xBD2EEDBC,0xBF7EBABD,0x3A22FCFF,0x3DCBC8FC,0xBD0C0614 }, + }; + static readonly uint[][] kSplitTwicePivots = + { + new uint[] { 0x428907E1, 0xC097E3D7, 0x417273BA }, new uint[] { 0x42887AA2, 0xC0963210, 0x41550773 }, new uint[] { 0x42897383, 0xC0963210, 0x417C2458 }, + }; + // Needles split twice on one brush + static readonly uint[][] kSplitTwiceOnOneBrush = + { + new uint[] { 0xBF800000,0x00000000,0x00000000,0xBD0AD0F6,0x00000000,0x00000000,0x3F800000,0xBE1C28E6,0x00000000,0x00000000,0xBF800000,0xBE1C2905, + 0x3F800000,0x00000000,0x00000000,0xBD2D80F6,0xBF3504F3,0xBF3504F3,0x00000000,0xBE691D2E,0xBF3504F3,0x3F3504F3,0x00000000,0xBEB80977 }, + new uint[] { 0x00000000,0xBF800000,0x00000000,0xBD1C28F6,0x00000000,0x00000000,0x3F800000,0xBE1C28E6,0x00000000,0x00000000,0xBF800000,0xBE1C2905, + 0x00000000,0x3F800000,0x00000000,0xBD1C28F5,0x3F3504F3,0xBF3504F3,0x00000000,0xBF881C30,0xBF800000,0x00000000,0x00000000,0xBF99FDC1 }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBCF2EA5D,0x00000000,0x00000000,0x3F800000,0xBE1C28E6,0x00000000,0x00000000,0xBF800000,0xBE1C2905, + 0x00000000,0xBF800000,0x00000000,0xBD3EDCBD,0xBF3504F3,0x3F3504F3,0x00000000,0xC0A7BDCE,0x3F3504F3,0x3F3504F3,0x00000000,0xC0D1B989 }, + }; + static readonly uint[][] kSplitTwiceOnOneBrushPivots = + { + new uint[] { 0x424DDBAA, 0xBDBEDCBB, 0x408151EC }, new uint[] { 0x4247DC17, 0x3ED6B852, 0x408151EC }, new uint[] { 0x4228C444, 0xBED261D9, 0x408151EC }, + }; + static readonly uint[][] kClips = + { + new uint[] { 0x00000000,0x3F7F5974,0x3D91E9F9,0xBEBE61C2, 0x00000000,0xBF7F5974,0xBD91E9F9,0xBE7249F4, 0x00000000,0x00000000,0xBF800000,0xC088A3D5, + 0x00000000,0x00000000,0x3F800000,0xC088A3D9, 0xBF800000,0x00000000,0x00000000,0xBF8ACF26, 0x3F800000,0x00000000,0x00000000,0xBFAD82C5 }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBE72EDC3, 0x00000000,0xBF800000,0x00000000,0xBEBEDB0A, 0x3EA167AF,0xBF3594A5,0xBF2167AF,0xC108BEE9, + 0x00000000,0x00000000,0x3F800000,0xC12D82DA, 0xBECFF162,0x3F69EF8E,0x00000000,0xBEFDB272, 0x3ECFF162,0xBF69EF8E,0x00000000,0xBEFDAF01 }, + }; + static readonly uint[][] kClipsPivots = + { + new uint[] { 0xC2F7EE5D, 0xC38ACCA3, 0xC20D851F }, new uint[] { 0xC2F8BA3D, 0xC38A943F, 0xC249FA50 }, + }; + // A needle across another brush + static readonly uint[][] kAcrossAnotherBrush = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBF3EDC48, 0x00000000,0xBF800000,0x00000000,0xBEF2EB48, 0xBF64F92E,0x00000000,0x3EE4F92E,0xBF2AB65E, + 0x3F64F92E,0x00000000,0xBEE4F92E,0xBF5563F4, 0x00000000,0x00000000,0x3F800000,0xC01C28F2, 0x00000000,0x00000000,0xBF800000,0xC01C28FA }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBF3EDC48, 0x00000000,0xBF800000,0x00000000,0xBEF2EB48, 0xBF800000,0x00000000,0x00000000,0xBF24D5AE, + 0x3F6FB345,0x00000000,0xBEB3C674,0xBF40ED28, 0x00000000,0x00000000,0x3F800000,0xBF1C28DA, 0x00000000,0x00000000,0xBF800000,0xBF1C2912 }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBEBEDC8F, 0x00000000,0xBF800000,0x00000000,0xBE72EAB8, 0x00000000,0x3EE4F92E,0x3F64F92E,0xBF0F8DC5, + 0x3F800000,0x00000000,0x00000000,0xC077CBB4, 0xBF64F92E,0xA795FE1D,0x3EE4F92E,0xC02F13BA, 0x00000000,0x00000000,0xBF800000,0xBF478992 }, + }; + static readonly uint[][] kAcrossAnotherBrushPivots = + { + new uint[] { 0xC2BCD432, 0xC37903FB, 0xC251D70A }, new uint[] { 0xC2BF78E4, 0xC37903FB, 0xC25E0A3D }, new uint[] { 0xC2C880ED, 0xC378A48D, 0xC25D5CBB }, + }; + // A needle across another face of its own brush + static readonly uint[][] kAcrossItsOwnBrush = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBE72E91F, 0x00000000,0xBF800000,0x00000000,0xBEBEDD5C, 0xBF800000,0x00000000,0x00000000,0xC0903B28, + 0x3F230AAA,0x00000000,0xBF455DC0,0xC084D34B, 0x3EAD6CA3,0x00000000,0x3F70DDFF,0xC0DD8947, 0xBEE4F92E,0x00000000,0xBF64F92E,0xC10B6E67 }, + new uint[] { 0x3DF68C00,0xBF768C00,0x3E769ACF,0xC08E408C, 0xBF800000,0x00000000,0x00000000,0xBEB4747B, 0x3EE32B61,0x3E0048B0,0x3F632B61,0xBE8FE27F, + 0xBEE36364,0xBDED506C,0xBF63710C,0xBE720AFB, 0x3F3504F3,0x00000000,0xBF3504F3,0xBE8481D0 }, + new uint[] { 0x3DA582CA,0x3F7F29A3,0x00000000,0xBE72201E, 0xBDA582CA,0xBF7F29A3,0x00000000,0xBEBE3C56, 0xBF800000,0x00000000,0x00000000,0xC1489F4C, + 0x3F800000,0x00000000,0x00000000,0xC1207F6C, 0x3EE9E24E,0x00000000,0x3F63BAAA,0xC0E68F58, 0xBEF376F8,0x00000000,0xBF613472,0xC0E4011A }, + }; + static readonly uint[][] kAcrossItsOwnBrushPivots = + { + new uint[] { 0xC2A6AA62, 0xC37881D9, 0xC1B33456 }, new uint[] { 0xC2AEF9A0, 0xC3745875, 0xC1F321E3 }, new uint[] { 0xC2C3BE02, 0xC377B1A3, 0xC182F259 }, + }; + static readonly uint[][] kBevel = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xC1126668, 0x00000000,0xBF800000,0x00000000,0xC1126665, 0xBF800000,0x00000000,0x00000000,0xBE9C287B, + 0x3F800000,0x00000000,0x00000000,0xBE9C2971, 0x00000000,0x00000000,0x3F800000,0xC1151C71, 0x3F3504F3,0x00000000,0xBF3504F3,0xC0A8B337 }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBE9C2AE1, 0x00000000,0xBF800000,0x00000000,0xBE9C270A, 0xBF3504F3,0x00000000,0x3F3504F3,0xC0DCD800, + 0x3F3504F3,0x00000000,0xBF3504F3,0xC0DCD7EB, 0x3F3504F3,0x00000000,0x3F3504F3,0xC0D6B579, 0xBF3504F3,0x00000000,0xBF3504F3,0xC0ABC474 }, + }; + static readonly uint[][] kBevelPivots = + { + new uint[] { 0xC34248F6, 0xC3917C29, 0x41910B61 }, new uint[] { 0xC3427456, 0xC38CC1EC, 0x3F3EDCBB }, + }; + // Two brushes' sloped faces + static readonly uint[][] kSlopes = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBD26930A, 0x3EC8D3F2,0xBF5500AA,0x3EC8D3DF,0xBD2D3ADD, 0x3F351F9C,0x00000000,0xBF34EA46,0xBF9C2A92, + 0xBF34E99C,0x00000000,0x3F352047,0xBF88A501, 0xBF3504EB,0x00000000,0xBF3504FC,0xBDBB63F8 }, + new uint[] { 0x3EC8D4C3,0xBF5500C9,0x3EC8D28C,0xBD4A208A, 0x3F351F98,0xBA5F7354,0xBF34EA42,0xBD1C387D, 0xBF3520EC,0x3A6AC1F2,0x3F34E8EC,0xBD1C43A6, + 0xBEC8D0D7,0x3F55008B,0xBEC8D77E,0xBD9ECDA0, 0x00000000,0xBF800000,0x00000000,0xBE0ACF33, 0x00000000,0x3F800000,0x00000000,0xBE2D82B8 }, + new uint[] { 0x3F34FB9D,0x3998BC14,0xBF350E48,0xBF9C28D7, 0xBF34FC0F,0xB99C81B4,0x3F350DD6,0xBF9C296C, 0xBEC8D419,0x3F55008B,0xBEC8D43C,0xBDF56EC7, + 0x00000000,0xBF800000,0x00000000,0xBDF2EA66, 0x00000000,0x3F800000,0x00000000,0xBE3EDCB8, 0x3F350503,0x00000000,0x3F3504E3,0xBE95A57B }, + }; + static readonly uint[][] kSlopesPivots = + { + new uint[] { 0x420C22F0, 0x41DC7EA2, 0x430C2594 }, new uint[] { 0x420912E6, 0x41DCAF38, 0x430CF1DE }, new uint[] { 0x420D3517, 0x41DC8C84, 0x430C4E8A }, + }; + // Three touching brushes + static readonly uint[][] kWorldSolids = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBF72EA61, 0x00000000,0xBF800000,0x00000000,0xBFBEDCBB, 0x00000000,0x00000000,0x3F800000,0xC0D1D70A, + 0xBF800000,0x00000000,0x00000000,0xBF2ACCD4, 0x3F800000,0x00000000,0x00000000,0xBF2ACCC5, 0x00000000,0x00000000,0xBF800000,0xC0D1D70A }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBF72EA61, 0x00000000,0xBF800000,0x00000000,0xBFBEDCBB, 0xBF3504F3,0x00000000,0x3F3504F3,0xBEFDD0C3, + 0x3F3504F3,0x00000000,0x3F3504F3,0xBEFDD0AE, 0x00000000,0x00000000,0x3F800000,0xBD0ACAE1, 0x00000000,0x00000000,0xBF800000,0xBD2D870A }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBF72EA61, 0x00000000,0xBF800000,0x00000000,0xBFBEDCBB, 0x00000000,0x00000000,0x3F800000,0xC0D261CA, + 0xBF3504F3,0x00000000,0xBF3504F3,0xC094C34A, 0x3F800000,0x00000000,0x00000000,0xBD2D833D, 0xBF800000,0x00000000,0x00000000,0xBD0ACEAE }, + }; + static readonly uint[][] kWorldSolidsPivots = + { + new uint[] { 0xC14E2E14, 0xC058E38E, 0x432B9000 }, new uint[] { 0xC14E2E14, 0xC058E38E, 0x4324F89B }, new uint[] { 0xC142F679, 0xC058E38E, 0x432B8BAA }, + }; + // Four touching brushes + static readonly uint[][] kFourSolids = + { + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBEF2EA65, 0x00000000,0xBF800000,0x00000000,0xBF3EDCB9, 0xBF800000,0x00000000,0x00000000,0xBFAD82F3, + 0x3F800000,0x00000000,0x00000000,0xBF8ACEF8, 0x3F3504F3,0x00000000,0x3F3504F3,0xC03B1A7B, 0x3F3504F3,0x00000000,0xBF3504F3,0xC03B1A7C }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBEF2EA65, 0x00000000,0xBF800000,0x00000000,0xBF3EDCB9, 0x00000000,0x00000000,0x3F800000,0xBF9C28F6, + 0x00000000,0x00000000,0xBF800000,0xBF9C28F6, 0xBF3504F3,0x00000000,0xBF3504F3,0xC02BC467, 0x3F3504F3,0x00000000,0xBF3504F3,0xC056B586 }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBE79DB2A, 0x00000000,0xBF800000,0x00000000,0xBEBB6456, 0xBF800000,0x00000000,0x00000000,0xBD5038F6, + 0x3F7F5673,0x00000000,0x3D9338C4,0xBD95ABBE, 0x3F7CA7A2,0x00000000,0xBE24FFC3,0xBE27BF2D }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBE79DB2A, 0x00000000,0xBF800000,0x00000000,0xBEBB6456, 0x3D9338C4,0x00000000,0x3F7F5673,0xBE2A5076, + 0xBED9AB7D,0x00000000,0xBF67B68D,0xBE81F7C7, 0x3EE90451,0x00000000,0xBF63F387,0xBEBB864B }, + }; + static readonly uint[][] kFourSolidsPivots = + { + new uint[] { 0xC2A816C1, 0x404BE024, 0xC0AFAE14 }, new uint[] { 0xC2A0C4D6, 0x404BE024, 0xBF9C28F6 }, new uint[] { 0xC2A5D17E, 0x405A9FBE, 0xC0852EEF }, new uint[] { 0xC2A3ABAE, 0x405A9FBE, 0xC02C051F }, + }; + // A triangle the weld splits again on both sides of its first split + static readonly uint[][] kSplitBothWaysA = + { + new uint[] { 0x00000000,0x00000000,0xBF800000,0xBE8ACEF6, 0x00000000,0xBF3504F3,0x3F3504F3,0xBFAED5A8, 0x00000000,0x3F3504F3,0x3F3504F3,0xC0087E60, + 0x3F800000,0x00000000,0x00000000,0xBF0ACF14, 0x00000000,0x00000000,0x3F800000,0xBEAD82F6, 0xBF800000,0x00000000,0x00000000,0xBF2D82D7 }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBEBEDCBE, 0x00000000,0xBF800000,0x00000000,0xBE72EA5B, 0xBF800000,0x00000000,0x00000000,0xBF878E39, + 0x3F7ABDB2,0x00000000,0xBE4E7E1A,0xBFA93E68, 0x3DC37CA4,0x00000000,0x3F7ED4C3,0xBFFC9DBD, 0xBDC404DE,0x00000000,0xBF7ED320,0xBFFC9D9F }, + new uint[] { 0x00000000,0xBF800000,0x00000000,0xC05B999A, 0x00000000,0x3F800000,0x00000000,0xC003C28F, 0x3F208137,0x00000000,0x3F476F4D,0xBFFA1644, + 0x3EF47A9D,0x00000000,0x3F60EE15,0xBFCDBBFC, 0x3E90781B,0x00000000,0x3F7598FA,0xBFC6CECA, 0x3DC404DE,0x00000000,0x3F7ED320,0xBFEA0A98, + 0xBF800000,0x00000000,0x00000000,0xC045540A, 0x3F3504F3,0x00000000,0xBF3504F3,0xC0625095 }, + new uint[] { 0x3F800000,0x00000000,0x00000000,0xBEF2EA60, 0x00000000,0xBF3504F3,0xBF3504F3,0xBFCBF939, 0x00000000,0xBF3504F3,0x3F3504F3,0xBFC5D6CF, + 0x00000000,0x3F800000,0x00000000,0xBEBEDCBE, 0x00000000,0xBF800000,0x00000000,0xBE72EA5B, 0xBF800000,0x00000000,0x00000000,0xBF3EDCBB }, + new uint[] { 0x00000000,0xBF800000,0x00000000,0xBE72EA63, 0x00000000,0x3F800000,0x00000000,0xBEBEDCBA, 0x3F800000,0x00000000,0x00000000,0xBF0ACF14, + 0x00000000,0x3F3504F3,0xBF3504F3,0xBFD21BAC, 0x00000000,0x3F3504F3,0x3F3504F3,0xBFD83E23, 0xBF800000,0x00000000,0x00000000,0xBF2D82D7 }, + }; + static readonly uint[][] kSplitBothWaysAPivots = + { + new uint[] { 0xBFB0C3B3, 0xBF58E38E, 0x41E47A98 }, new uint[] { 0x3E61907F, 0x40044D5E, 0x41F63C96 }, new uint[] { 0x400FA5F6, 0x3EC33333, 0x41D61F8B }, new uint[] { 0xBFA816C1, 0x40044D5E, 0x41F75234 }, new uint[] { 0xBFB0C3B3, 0xC034048D, 0x41F6C765 }, + }; + // The same, with four brushes + + static readonly uint[][] kSplitBothWaysB = + { + new uint[] { 0x3F800000,0x00000000,0x00000000,0xC075C326, 0x00000000,0x00000000,0x3F800000,0xBD88A3AE, 0x00000000,0x00000000,0xBF800000,0xBD88A400, + 0x00000000,0x3F7D6D54,0xBE10D0C3,0xBCBCF12E, 0x00000000,0xBF7D6D54,0x3E10D0C3,0xBC707A53, 0xBF800000,0x00000000,0x00000000,0xC0C1194E }, + new uint[] { 0x3F800000,0x00000000,0x00000000,0xC075C326, 0x00000000,0x00000000,0x3F800000,0xBD88A35C, 0x00000000,0x00000000,0xBF800000,0xBD88A452, + 0x00000000,0x3F7D6D54,0x3E10D0C3,0xBCBCF0D2, 0x00000000,0xBF7D6D54,0xBE10D0C3,0xBC707B0C, 0xBF800000,0x00000000,0x00000000,0xC0C1194E }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBE8F257B, 0x00000000,0xBF800000,0x00000000,0xBE362FEC, 0xBF800000,0x00000000,0x00000000,0xBE878EA4, + 0x3F6C4EC5,0x3EC4EC4F,0x00000000,0xBE9B2849, 0x00000000,0x00000000,0x3F800000,0xBFA5EB80, 0x00000000,0x00000000,0xBF800000,0xBFA5EB8A }, + new uint[] { 0x00000000,0x3F800000,0x00000000,0xBF0F2576, 0x00000000,0xBF800000,0x00000000,0xBEB62FF6, 0xBF800000,0x00000000,0x00000000,0xBE0AD0CD, + 0x3F800000,0x00000000,0x00000000,0xBE2D811F, 0x00000000,0x00000000,0x3F800000,0xBF2FAE0A, 0x00000000,0x00000000,0xBF800000,0xBF2FAE1F }, + }; + static readonly uint[][] kSplitBothWaysBPivots = + { + new uint[] { 0xC3777573, 0x42F35308, 0xC2B4F5D7 }, new uint[] { 0xC3777573, 0x42F35308, 0xC2B4B185 }, new uint[] { 0xC37492F1, 0x42F3BEEF, 0xC2B4D3AE }, new uint[] { 0xC37D347F, 0x42F41A07, 0xC2B4D3AE }, + }; + + internal static ContentsScene SceneOf(uint[][] brushes, uint[][] pivots, string what) + { + var scene = new ContentsScene(); + for (int b = 0; b < brushes.Length; b++) + scene.Add(ContentsSceneNode.Brush(Planes(brushes[b]), localToTree: float4x4.Translate(Pivot(pivots[b])), name: what + " " + b)); + return scene; + } + + internal static List FlippableNeedles(ContentsTreeHarness harness) + { + var found = new List(); + var surfaces = harness.Triangles.GroupBy(t => (t.brushID, math.asuint(t.surfaceNormal).x, math.asuint(t.surfaceNormal).y, math.asuint(t.surfaceNormal).z, t.material, t.section)); + foreach (var surface in surfaces) + { + var byEdge = new Dictionary<(float3, float3), List>(); + foreach (var t in surface) + { + foreach (var (from, to) in new[] { (t.a, t.b), (t.b, t.c), (t.c, t.a) }) + { + if (!byEdge.TryGetValue((from, to), out var list)) + byEdge[(from, to)] = list = new List(); + list.Add(t); + } + } + foreach (var t in surface) + { + if (!ExactFloatLine.AsNeedle(t.a, t.b, t.c, out var u, out var m, out var w)) + continue; + if (!byEdge.TryGetValue((u, w), out var neighbours)) + continue; + foreach (var n in neighbours) + { + var d = !n.a.Equals(u) && !n.a.Equals(w) ? n.a : (!n.b.Equals(u) && !n.b.Equals(w) ? n.b : n.c); + if (!ExactFloatLine.OnOneLine(u, w, d)) + found.Add(Describe(t) + " beside " + Describe(n)); + } + } + } + return found; + } + + static void AssertNoFlippableNeedle(uint[][] brushes, uint[][] pivots, string what) + { + using var harness = ContentsTreeHarness.Build(SceneOf(brushes, pivots, what)); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + Assert.That(harness.Triangles.Count, Is.GreaterThan(0), what + " draws nothing: " + harness.LastUpdateReport); + var needles = FlippableNeedles(harness); + Assert.That(needles, Is.Empty, $"{what}: {needles.Count} needle(s) with a neighbour across their long edge on their own face, " + + "e.g. " + string.Join("; ", needles.Take(2))); + } + + // Every needle of the delivered meshes, however it lies + static List Needles(ContentsTreeHarness harness) + { + var found = new List(); + foreach (var t in harness.Triangles) + { + if (ExactFloatLine.AsNeedle(t.a, t.b, t.c, out _, out _, out _)) + found.Add(Describe(t)); + } + return found; + } + + static void AssertNoNeedle(uint[][] brushes, uint[][] pivots, string what) + { + using var harness = ContentsTreeHarness.Build(SceneOf(brushes, pivots, what)); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + Assert.That(harness.Triangles.Count, Is.GreaterThan(0), what + " draws nothing: " + harness.LastUpdateReport); + var needles = Needles(harness); + Assert.That(needles, Is.Empty, $"{what}: {needles.Count} needle(s) left, e.g. " + string.Join("; ", needles.Take(2))); + } + + // Needles in the delivered meshes with a triangle across their long edge - of any brush, its third corner off that + // line: what the weld across the model takes away (OutputModelWeld) + static List NeedlesWithATriangleAcross(ContentsTreeHarness harness) + { + var byEdge = new Dictionary<(float3, float3), List>(); + foreach (var t in harness.Triangles) + { + foreach (var (from, to) in new[] { (t.a, t.b), (t.b, t.c), (t.c, t.a) }) + { + if (!byEdge.TryGetValue((from, to), out var list)) + byEdge[(from, to)] = list = new List(); + list.Add(t); + } + } + var found = new List(); + foreach (var t in harness.Triangles) + { + if (!ExactFloatLine.AsNeedle(t.a, t.b, t.c, out var u, out var m, out var w)) + continue; + if (!byEdge.TryGetValue((u, w), out var across)) + continue; + foreach (var n in across) + { + var d = !n.a.Equals(u) && !n.a.Equals(w) ? n.a : (!n.b.Equals(u) && !n.b.Equals(w) ? n.b : n.c); + if (!ExactFloatLine.OnOneLine(u, w, d)) + found.Add(Describe(t) + " beside " + Describe(n)); + } + } + return found; + } + + [Test] + public void TheNeedlesAcrossALaterHalf_LeaveNoNeedle() + { + foreach (var (brushes, pivots, what) in new[] { (kClips, kClipsPivots, "the clips"), + (kAcrossAnotherBrush, kAcrossAnotherBrushPivots, "across another brush"), + (kAcrossItsOwnBrush, kAcrossItsOwnBrushPivots, "across its own brush") }) + { + using var harness = ContentsTreeHarness.Build(SceneOf(brushes, pivots, what)); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + Assert.That(harness.Triangles.Count, Is.GreaterThan(0), what + " draws nothing: " + harness.LastUpdateReport); + var needles = NeedlesWithATriangleAcross(harness); + Assert.That(needles, Is.Empty, $"{what}: {needles.Count} needle(s) left with a triangle across, e.g. " + string.Join("; ", needles.Take(2))); + } + } + + // Pairs of triangles in the delivered meshes at the same three positions, bit for bit (0 and -0 one position), wound + // the other way round + static List OppositeTwins(ContentsTreeHarness harness) + { + int3 Bits(float3 p) { var i = math.asint(p); return math.select(i, int3.zero, i == new int3(int.MinValue)); } + bool Less(int3 p, int3 q) => p.x != q.x ? p.x < q.x : (p.y != q.y ? p.y < q.y : p.z < q.z); + (int3, int3, int3) Key(float3 a, float3 b, float3 c) + { + int3 A = Bits(a), B = Bits(b), C = Bits(c); + if (Less(B, A) && Less(B, C)) return (B, C, A); + if (Less(C, A) && Less(C, B)) return (C, A, B); + return (A, B, C); + } + var keys = new HashSet<(int3, int3, int3)>(); + foreach (var t in harness.Triangles) + keys.Add(Key(t.a, t.b, t.c)); + var found = new List(); + foreach (var t in harness.Triangles) + { + if (keys.Contains(Key(t.a, t.c, t.b))) + found.Add(Describe(t)); + } + return found; + } + + // The weld across the model drops both triangles of each such pair + [Test] + public void TheOppositeTwinsOfFourScenes_GoTogether() + { + foreach (var (brushes, pivots, what) in new[] { + (kBevel, kBevelPivots, "the bevel"), + (kSlopes, kSlopesPivots, "the sloped faces"), + (kWorldSolids, kWorldSolidsPivots, "three brushes"), + (kFourSolids, kFourSolidsPivots, "four brushes") }) + { + using var harness = ContentsTreeHarness.Build(SceneOf(brushes, pivots, what)); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + Assert.That(harness.Triangles.Count, Is.GreaterThan(0), what + " draws nothing: " + harness.LastUpdateReport); + var twins = OppositeTwins(harness); + Assert.That(twins, Is.Empty, $"{what}: {twins.Count} triangle(s) left with an identical twin facing the other way, e.g. " + string.Join("; ", twins.Take(2))); + } + } + + [Test] + public void TheExactJudge_FindsTheNeedleScenesRight_MeshIncluded() + { + foreach (var (brushes, pivots, what) in new[] { (kWedges, kWedgesPivots, "the wedges"), + (kDetailNeedle, kDetailNeedlePivots, "the detail needle"), + (kNeedleOnABrushEdge, kNeedleOnABrushEdgePivots, "the needle on a seam"), + (kFarApartNeedle, kFarApartNeedlePivots, "the far apart needle"), + (kSeam, kSeamPivots, "the seam"), + (kFacingNeedles, kFacingNeedlesPivots, "the facing needles"), + (kClips, kClipsPivots, "the clips"), + (kAcrossAnotherBrush, kAcrossAnotherBrushPivots, "across another brush"), + (kAcrossItsOwnBrush, kAcrossItsOwnBrushPivots, "across its own brush"), + (kBevel, kBevelPivots, "the bevel"), + (kSlopes, kSlopesPivots, "the sloped faces"), + (kWorldSolids, kWorldSolidsPivots, "three brushes"), + (kFourSolids, kFourSolidsPivots, "four brushes") }) + { + using var harness = ContentsTreeHarness.Build(SceneOf(brushes, pivots, what)); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + var mismatches = ExactJudge.FindMismatches(harness, 8, out var judged, checkMesh: true); + Assert.That(judged, Is.GreaterThan(0), what + ": nothing was judged"); + Assert.That(mismatches.Count, Is.EqualTo(0), what + ": " + string.Join("; ", mismatches.Select(x => x.Format("exact")))); + } + } + + [Test] + public void TheWedges_LeaveNoNeedleTheFlipCouldTakeAway() + { + AssertNoFlippableNeedle(kWedges, kWedgesPivots, "the wedges"); + } + + [Test] + public void TheDetailNeedle_LeaveNoNeedleTheFlipCouldTakeAway() + { + AssertNoFlippableNeedle(kDetailNeedle, kDetailNeedlePivots, "the detail needle"); + } + + // A needle on the edge between two faces of one brush: the triangle across it, of the other face, is split at its + // middle corner, and it goes (OutputModelWeld) + [Test] + public void ANeedleOnABrushEdge_IsTakenAway() + { + AssertNoNeedle(kNeedleOnABrushEdge, kNeedleOnABrushEdgePivots, "the needle on a seam"); + } + + // The same, with coordinates 55 powers of two apart + [Test] + public void ANeedleWithFarApartCoordinates_IsTakenAway() + { + AssertNoNeedle(kFarApartNeedle, kFarApartNeedlePivots, "the far apart needle"); + } + + // A needle on the seam between two brushes: the other brush's triangle across it is split + [Test] + public void TheSeamBetweenTwoBrushes_LeavesNoNeedle() + { + AssertNoNeedle(kSeam, kSeamPivots, "the seam"); + } + + // Two facing needles go together + [Test] + public void TheFacingNeedles_LeaveNoNeedle() + { + AssertNoNeedle(kFacingNeedles, kFacingNeedlesPivots, "the facing needles"); + } + + [Test] + public void TheExactJudge_FollowsAHalfThatIsSplitAgain_MeshIncluded() + { + foreach (var (brushes, pivots, what) in new[] { (kSplitTwice, kSplitTwicePivots, "needles split twice"), + (kSplitTwiceOnOneBrush, kSplitTwiceOnOneBrushPivots, "needles split twice on one brush") }) + { + using var harness = ContentsTreeHarness.Build(SceneOf(brushes, pivots, what)); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + var mismatches = ExactJudge.FindMismatches(harness, 8, out var judged, checkMesh: true); + Assert.That(judged, Is.GreaterThan(0), what + ": nothing was judged"); + Assert.That(mismatches.Count, Is.EqualTo(0), what + ": " + string.Join("; ", mismatches.Select(x => x.Format("exact")))); + } + } + + [Test] + public void TheExactJudge_FollowsATriangleSplitAgainOnBothSides_MeshIncluded() + { + foreach (var (brushes, pivots, what) in new[] { (kSplitBothWaysA, kSplitBothWaysAPivots, "split both ways"), + (kSplitBothWaysB, kSplitBothWaysBPivots, "split both ways, four brushes") }) + { + using var harness = ContentsTreeHarness.Build(SceneOf(brushes, pivots, what)); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + var mismatches = ExactJudge.FindMismatches(harness, 8, out var judged, checkMesh: true); + Assert.That(judged, Is.GreaterThan(0), what + ": nothing was judged"); + Assert.That(mismatches.Count, Is.EqualTo(0), what + ": " + string.Join("; ", mismatches.Select(x => x.Format("exact")))); + } + } + + [Test] + public void TheExactJudge_DoesNotLookForTheTrianglesTheWeldDrops() + { + var node = ContentsSceneNode.Brush(Planes(kDrainCap), localToTree: float4x4.Translate(Pivot(kDrainCapPivot)), name: "drain cap"); + using var harness = ContentsTreeHarness.Build(new ContentsScene().Add(node)); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + var mismatches = ExactJudge.FindMismatches(harness, 8, out var judged, checkMesh: true); + Assert.That(judged, Is.GreaterThan(0), "nothing was judged"); + Assert.That(mismatches.Count, Is.EqualTo(0), "the exact judge disagrees with the drain cap's mesh: " + + string.Join("; ", mismatches.Select(m => m.Format("exact")))); + } + + [Test] + public void TheExactJudge_ChecksWhatTheCollapseRecorded() + { + // the drain cap's corners a few hundredths of a millimetre apart are collapsed (OutputModelWeld.kCollapseLength) + var node = ContentsSceneNode.Brush(Planes(kDrainCap), localToTree: float4x4.Translate(Pivot(kDrainCapPivot)), name: "drain cap"); + using var harness = ContentsTreeHarness.Build(new ContentsScene().Add(node)); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + var recorded = new List(harness.CollapseMoves); + Assert.That(recorded.Count, Is.GreaterThan(0), "the weld collapsed nothing"); + Assert.That(ExactJudge.FindMismatches(harness, 8, out _, checkMesh: true), Is.Empty, "as the weld recorded it"); + + // a move further than the collapse goes is reported + harness.CollapseMoves = new List(recorded) { new float3x2(recorded[0].c0, recorded[0].c0 + new float3(0.002f, 0, 0)) }; + harness.CollapseMoves.RemoveAt(0); + Assert.That(ExactJudge.FindMismatches(harness, 8, out _, checkMesh: true).Exists(m => m.Format("exact").Contains("further than it collapses")), + Is.True, "a move of 2 mm is not reported"); + + // without the record the moved triangles are the mesh's own mistakes + harness.CollapseMoves = new List(); + Assert.That(ExactJudge.FindMismatches(harness, 8, out _, checkMesh: true), Is.Not.Empty, "without the record nothing is wrong"); + } + + [Test] + public void TwoBoxesSideBySide_TheColliderHasOneVertexPerPosition() + { + var left = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0, 0, 0), new float3(1, 1, 1)), name: "left"); + var right = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1, 0, 0), new float3(2, 1, 1)), name: "right"); + using var harness = ContentsTreeHarness.Build(new ContentsScene().Add(left).Add(right)); + harness.CheckWeldEachUpdate = false; + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(harness.ColliderArea, Is.EqualTo(10.0), "the collider is not the 2 x 1 x 1 box's surface: " + harness.LastUpdateReport); + Assert.That(harness.ColliderPositionCount, Is.EqualTo(12), "the collider's triangles are not at the box's 12 corner positions"); + Assert.That(harness.ColliderVertexCount, Is.EqualTo(harness.ColliderPositionCount), + $"the collider has {harness.ColliderVertexCount} vertices at {harness.ColliderPositionCount} positions"); + Assert.That(harness.ColliderDegenerateTriangleCount, Is.EqualTo(0), "the collider holds triangles with two corners at one position"); + } + + [Test] + public void ADrainCapAndABox_EveryTriangleIsPickedAsTheBrushThatDrewIt() + { + var pivot = Pivot(kDrainCapPivot); + var cap = ContentsSceneNode.Brush(Planes(kDrainCap), localToTree: float4x4.Translate(pivot), name: "drain cap"); + var box = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(pivot + new float3(10, 0, 0), pivot + new float3(11, 1, 1)), name: "box"); + using var harness = ContentsTreeHarness.Build(new ContentsScene().Add(cap).Add(box)); + harness.CheckWeldEachUpdate = false; + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(harness.LookupMismatches, Is.Null, "the picking lookup is out of step with the triangles: " + harness.LookupMismatches); + Assert.That(harness.TriangleCountOf(cap), Is.GreaterThan(0), "the drain cap draws nothing: " + harness.LastUpdateReport); + Assert.That(harness.TriangleCountOf(box), Is.EqualTo(12), "the box is not drawn as 12 triangles: " + harness.LastUpdateReport); + var wrong = new List(); + foreach (var triangle in harness.Triangles) + { + var drawnBy = harness.NodeOf(triangle.brushID); + var expected = triangle.Center.x > pivot.x + 5 ? box : cap; + if (drawnBy != expected) + wrong.Add($"{Describe(triangle)} is picked as {drawnBy?.name ?? "nothing"}, it lies in the {expected.name}"); + } + Assert.That(wrong, Is.Empty, string.Join("; ", wrong.Take(5))); + } + } +} diff --git a/Core/Tests/EndWeldTests.cs.meta b/Core/Tests/EndWeldTests.cs.meta new file mode 100644 index 00000000..6692d41d --- /dev/null +++ b/Core/Tests/EndWeldTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6bb9988a115210942a270c3b8857663d \ No newline at end of file diff --git a/Core/Tests/ExactPlaneSnapTests.cs b/Core/Tests/ExactPlaneSnapTests.cs new file mode 100644 index 00000000..50ea7d47 --- /dev/null +++ b/Core/Tests/ExactPlaneSnapTests.cs @@ -0,0 +1,61 @@ +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class ExactPlaneSnapTests + { + static bool Same(ExactPlane p, ExactPlane q) => p.a == q.a && p.b == q.b && p.c == q.c && p.w == q.w; + + // The sample scene's pit: two carves meant to meet at y = -2, their planes fitted to float vertices + [Test] + public void PlanesAFloatsNoiseApart_BecomeOnePlane() + { + var lower = ExactPlane.Quantize(0, 1, 5.960464e-08, 2 + 5.960464e-08 * 30); + var upper = ExactPlane.Quantize(0, 1, 8.141123e-08, 2 + 8.141123e-08 * 30); + Assert.That(Same(lower, upper), Is.True, $"({lower.a}, {lower.b}, {lower.c}, {lower.w}) vs ({upper.a}, {upper.b}, {upper.c}, {upper.w})"); + Assert.That(Same(lower, ExactPlane.Quantize(0, 1, 0, 2)), Is.True, "not the plane y = -2 they were meant to be"); + } + + [Test] + public void PlanesOneStepApart_StayTwo() + { + const double step = 1.0 / ExactPlane.kOffsetScale; + var p = ExactPlane.Quantize(0, 1, 0, 2); + var q = ExactPlane.Quantize(0, 1, 0, 2 + step); + Assert.That(Same(p, q), Is.False, "an offset step merged"); + Assert.That(q.w - p.w, Is.EqualTo((long)(ExactPlane.kNormalScale / ExactPlane.kOffsetScale))); + + var tilted = ExactPlane.Quantize(0, 1, 1.0 / ExactPlane.kNormalScale, 2); + Assert.That(Same(p, tilted), Is.False, "a normal step merged"); + } + + [Test] + public void TheOppositePlane_IsExactlyTheNegation() + { + var p = ExactPlane.Quantize(0.3, -0.7, 0.2, 17.123456); + var q = ExactPlane.Quantize(-0.3, 0.7, -0.2, -17.123456); + Assert.That(Same(p.Flipped(), q), Is.True); + } + + // The snap moves a plane by at most half a step: its offset by 2^-15, its normal by 2^-21 in each component + [Test] + public void TheSnap_MovesAPlaneByAtMostHalfAStep() + { + var random = new System.Random(1234); + for (int i = 0; i < 1000; i++) + { + double x = random.NextDouble() * 2 - 1, y = random.NextDouble() * 2 - 1, z = random.NextDouble() * 2 - 1; + double length = System.Math.Sqrt(x * x + y * y + z * z); + if (length < 1e-3) continue; + x /= length; y /= length; z /= length; + double d = (random.NextDouble() * 2 - 1) * 1000; + var p = ExactPlane.Quantize(x, y, z, d); + Assert.That(System.Math.Abs(p.a / ExactPlane.kNormalScale - x), Is.LessThanOrEqualTo(0.5 / ExactPlane.kNormalScale)); + Assert.That(System.Math.Abs(p.b / ExactPlane.kNormalScale - y), Is.LessThanOrEqualTo(0.5 / ExactPlane.kNormalScale)); + Assert.That(System.Math.Abs(p.c / ExactPlane.kNormalScale - z), Is.LessThanOrEqualTo(0.5 / ExactPlane.kNormalScale)); + Assert.That(System.Math.Abs(p.w / ExactPlane.kNormalScale - d), Is.LessThanOrEqualTo(0.5 / ExactPlane.kOffsetScale)); + } + } + } +} diff --git a/Core/Tests/ExactPlaneSnapTests.cs.meta b/Core/Tests/ExactPlaneSnapTests.cs.meta new file mode 100644 index 00000000..16b563a5 --- /dev/null +++ b/Core/Tests/ExactPlaneSnapTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2cc2620f3fc0f7745897009b059ccd9d \ No newline at end of file diff --git a/Core/Tests/FindHotspotAffectedBrushesJobTests.cs b/Core/Tests/FindHotspotAffectedBrushesJobTests.cs new file mode 100644 index 00000000..6b99671a --- /dev/null +++ b/Core/Tests/FindHotspotAffectedBrushesJobTests.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Jobs; + +namespace Chisel.Core.Tests +{ + // A hotspot layout edit rebuilds the brushes with a surface on that layout, besides the ones rebuilt anyway + [TestFixture] + public class FindHotspotAffectedBrushesJobTests + { + readonly List> blobs = new(); + + [TearDown] + public void TearDown() + { + foreach (var blob in blobs) + if (blob.IsCreated) blob.Dispose(); + blobs.Clear(); + } + + // A brush's base polygons with one surface per layout given (0: no hotspot layout) + BlobAssetReference Brush(params int[] layouts) + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + builder.Allocate(ref root.polygons, 0); + builder.Allocate(ref root.edges, 0); + builder.Allocate(ref root.vertices, 0); + var surfaces = builder.Allocate(ref root.surfaces, layouts.Length); + for (int s = 0; s < layouts.Length; s++) + surfaces[s] = new BaseSurface { hotspotLayout = layouts[s] }; + var blob = builder.CreateBlobAssetReference(Allocator.Persistent); + builder.Dispose(); + blobs.Add(blob); + return blob; + } + + static IndexOrder Order(int nodeOrder) => new() { compactNodeID = new CompactNodeID(default, new SlotIndex { index = nodeOrder + 1, generation = 1 }), nodeOrder = nodeOrder }; + + // The node orders listed to rebuild after the job, for these brushes, layouts changed and brushes already listed + List Rebuilt(BlobAssetReference[] brushes, bool[] changed, params int[] listed) + { + using var cache = new NativeList>(Allocator.TempJob); + using var orders = new NativeList(Allocator.TempJob); + using var rebuild = new NativeList(Allocator.TempJob); + using var changedLayouts = new NativeArray(changed, Allocator.TempJob); + for (int b = 0; b < brushes.Length; b++) + { + cache.Add(brushes[b]); + orders.Add(Order(b)); + } + foreach (var b in listed) + rebuild.Add(Order(b)); + new FindHotspotAffectedBrushesJob + { + changedLayouts = changedLayouts, + basePolygonCache = cache, + allTreeBrushIndexOrders = orders, + brushCount = brushes.Length, + rebuildTreeBrushIndexOrders = rebuild + }.Run(); + var result = new List(); + foreach (var order in rebuild) + result.Add(order.nodeOrder); + return result; + } + + BlobAssetReference[] Model() => new[] + { + Brush(0, 1), // a surface on layout 1 + Brush(2), // on layout 2 + Brush(0), // no hotspot surface + Brush(1, 2), // on both + default // new: no polygons yet, rebuilt anyway + }; + + [Test] + public void NoLayoutChanged_ListsNothingMore() + { + Assert.That(Rebuilt(Model(), new[] { false, false, false }, 2), Is.EqualTo(new[] { 2 })); + } + + [Test] + public void ALayoutChanged_ListsTheBrushesOnIt_InTreeOrder() + { + Assert.That(Rebuilt(Model(), new[] { false, true, false }, 2), Is.EqualTo(new[] { 0, 2, 3 })); + Assert.That(Rebuilt(Model(), new[] { false, false, true }), Is.EqualTo(new[] { 1, 3 })); + } + + [Test] + public void ABrushListedAlready_IsListedOnce() + { + Assert.That(Rebuilt(Model(), new[] { false, true, true }, 3), Is.EqualTo(new[] { 0, 1, 3 })); + } + + [Test] + public void TheNoneLayout_ChangesNothing() + { + // index 0 is "no layout": a surface without one is never rebuilt for a layout edit + Assert.That(Rebuilt(Model(), new[] { true, false, false }), Is.Empty); + } + + [Test] + public void ALayoutPastTheChangedOnes_IsLeftAlone() + { + // the store knows two layouts here: a surface on a third is not one of them + Assert.That(Rebuilt(new[] { Brush(3) }, new[] { false, true, true }), Is.Empty); + } + } +} diff --git a/Core/Tests/FindHotspotAffectedBrushesJobTests.cs.meta b/Core/Tests/FindHotspotAffectedBrushesJobTests.cs.meta new file mode 100644 index 00000000..17771e3c --- /dev/null +++ b/Core/Tests/FindHotspotAffectedBrushesJobTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67b3b8046fec9b14b912b7d301d2d5af \ No newline at end of file diff --git a/Core/Tests/FixupBrushCacheIndicesTests.cs b/Core/Tests/FixupBrushCacheIndicesTests.cs new file mode 100644 index 00000000..effac9f9 --- /dev/null +++ b/Core/Tests/FixupBrushCacheIndicesTests.cs @@ -0,0 +1,90 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class FixupBrushCacheIndicesTests + { + static CompactNodeID Brush(int slot) => new CompactNodeID(default, new SlotIndex { index = slot, generation = 1 }); + + static BrushIntersection Touch(CompactNodeID brush, int nodeOrder, IntersectionType type, int bottomUp) + { + return new BrushIntersection + { + nodeIndexOrder = new IndexOrder { compactNodeID = brush, nodeOrder = nodeOrder }, + type = type, + bottomUpStart = bottomUp, + bottomUpEnd = bottomUp + 1 + }; + } + + static BlobAssetReference TouchCache(params BrushIntersection[] touches) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var array = builder.Allocate(ref root.brushIntersections, touches.Length); + for (int i = 0; i < touches.Length; i++) + array[i] = touches[i]; + builder.Allocate(ref root.intersectionBits, 1); + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + [Test] + public void AReorderedTouchCache_KeepsEachBrushWithItsOwnIntersection() + { + var owner = Brush(10); + var x = Brush(11); + var y = Brush(12); + + // The cache was built for the order owner, x, y; x is behind y now + var cache = TouchCache(Touch(x, 1, IntersectionType.AInsideB, 100), + Touch(y, 2, IntersectionType.BInsideA, 200)); + var orders = new NativeList(Allocator.TempJob); + var idToOrder = new NativeList(Allocator.TempJob); + var offset = new NativeReference(10, Allocator.TempJob); + var basePolygons = new NativeList>(Allocator.TempJob); + var touchCaches = new NativeList>(Allocator.TempJob); + try + { + orders.Add(new IndexOrder { compactNodeID = owner, nodeOrder = 0 }); + orders.Add(new IndexOrder { compactNodeID = y, nodeOrder = 1 }); + orders.Add(new IndexOrder { compactNodeID = x, nodeOrder = 2 }); + idToOrder.Add(0); // slot 10, owner + idToOrder.Add(2); // slot 11, x + idToOrder.Add(1); // slot 12, y + basePolygons.Add(default); + touchCaches.Add(cache); + + new FixupBrushCacheIndicesJob + { + allTreeBrushIndexOrders = orders, + nodeIDValueToNodeOrder = idToOrder, + nodeIDValueToNodeOrderOffsetRef = offset, + basePolygonCache = basePolygons, + brushesTouchedByBrushCache = touchCaches + }.Execute(0); + + ref var touches = ref cache.Value.brushIntersections; + Assert.That(touches[0].nodeIndexOrder.compactNodeID, Is.EqualTo(y)); + Assert.That(touches[0].nodeIndexOrder.nodeOrder, Is.EqualTo(1)); + Assert.That(touches[0].type, Is.EqualTo(IntersectionType.BInsideA)); + Assert.That(touches[0].bottomUpStart, Is.EqualTo(200)); + Assert.That(touches[1].nodeIndexOrder.compactNodeID, Is.EqualTo(x)); + Assert.That(touches[1].nodeIndexOrder.nodeOrder, Is.EqualTo(2)); + Assert.That(touches[1].type, Is.EqualTo(IntersectionType.AInsideB)); + Assert.That(touches[1].bottomUpStart, Is.EqualTo(100)); + } + finally + { + cache.Dispose(); + orders.Dispose(); + idToOrder.Dispose(); + offset.Dispose(); + basePolygons.Dispose(); + touchCaches.Dispose(); + } + } + } +} diff --git a/Core/Tests/FixupBrushCacheIndicesTests.cs.meta b/Core/Tests/FixupBrushCacheIndicesTests.cs.meta new file mode 100644 index 00000000..a619a49f --- /dev/null +++ b/Core/Tests/FixupBrushCacheIndicesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 07addd4816aee2645b63472df59e74c2 \ No newline at end of file diff --git a/Core/Tests/GeneratorBranchJobPoolTests.cs b/Core/Tests/GeneratorBranchJobPoolTests.cs new file mode 100644 index 00000000..274516ba --- /dev/null +++ b/Core/Tests/GeneratorBranchJobPoolTests.cs @@ -0,0 +1,117 @@ +using System.Threading; +using NUnit.Framework; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Entities; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public unsafe class GeneratorBranchJobPoolTests + { + struct CountingGenerator : IBranchGenerator + { + [NativeDisableUnsafePtrRestriction] public int* disposed; + + public int PrepareAndCountRequiredBrushMeshes() => 0; + public bool GenerateNodes(BlobAssetReference internalSurfaceArrayBlob, NativeList nodes, Allocator allocator = Allocator.Persistent) => false; + public void Dispose() + { + if (disposed != null) + Interlocked.Increment(ref *disposed); + disposed = null; + } + public void Reset() { } + public int RequiredSurfaceCount => 0; + public void UpdateSurfaces(ref ChiselSurfaceArray surfaceArray) { } + public bool Validate() => true; + public void GetWarningMessages(IChiselMessageHandler messages) { } + } + + NativeArray disposed; + GeneratorBranchJobPool pool; + readonly System.Collections.Generic.List branches = new(); + + [SetUp] + public void SetUp() + { + disposed = new NativeArray(1, Allocator.Persistent); + pool = new GeneratorBranchJobPool(); + } + + [TearDown] + public void TearDown() + { + pool.Dispose(); + foreach (var branch in branches) + { + if (branch.Valid) + branch.Destroy(); + } + branches.Clear(); + disposed.Dispose(); + } + + CountingGenerator Generator() => new CountingGenerator { disposed = (int*)disposed.GetUnsafePtr() }; + + // The pool takes ownership of the surface array as well, and disposes it with the generator + static BlobAssetReference Surfaces() => BrushMeshManager.BuildInternalSurfaceArrayBlob(1, Allocator.TempJob); + + CSGTreeBranch Branch() + { + var branch = CSGTreeBranch.Create(); + branches.Add(branch); + return branch; + } + + // The control: a generator that runs is disposed once, by the create job + [Test] + public void AGeneratorThatRuns_IsDisposedOnce() + { + pool.ScheduleUpdate(Branch(), Generator(), Surfaces()); + pool.ScheduleGenerateJob(runInParallel: false).Complete(); + Assert.That(disposed[0], Is.EqualTo(1)); + } + + // The same node scheduled twice before the pool runs: the second generator replaces the first + [Test] + public void AGeneratorReplacedBeforeItRuns_IsDisposed() + { + var branch = Branch(); + pool.ScheduleUpdate(branch, Generator(), Surfaces()); + pool.ScheduleUpdate(branch, Generator(), Surfaces()); + pool.ScheduleGenerateJob(runInParallel: false).Complete(); + Assert.That(disposed[0], Is.EqualTo(2), "two generators were handed to the pool"); + } + + // The node went away before the pool ran, so the pool drops its generator + [Test] + public void AGeneratorWhoseNodeIsGone_IsDisposed() + { + var branch = Branch(); + pool.ScheduleUpdate(branch, Generator(), Surfaces()); + var destroyed = branch; + destroyed.Destroy(); + pool.ScheduleGenerateJob(runInParallel: false).Complete(); + Assert.That(disposed[0], Is.EqualTo(1)); + } + + // GeneratorJobPoolManager.Clear, at the end of every tree update, clears every pool + [Test] + public void AGeneratorStillWaitingWhenThePoolIsCleared_IsDisposed() + { + pool.ScheduleUpdate(Branch(), Generator(), Surfaces()); + pool.AllocateOrClear(); + Assert.That(disposed[0], Is.EqualTo(1)); + } + + // A domain reload disposes every pool + [Test] + public void AGeneratorStillWaitingWhenThePoolIsDisposed_IsDisposed() + { + pool.ScheduleUpdate(Branch(), Generator(), Surfaces()); + pool.Dispose(); + Assert.That(disposed[0], Is.EqualTo(1)); + } + } +} diff --git a/Core/Tests/GeneratorBranchJobPoolTests.cs.meta b/Core/Tests/GeneratorBranchJobPoolTests.cs.meta new file mode 100644 index 00000000..eabb188d --- /dev/null +++ b/Core/Tests/GeneratorBranchJobPoolTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 099954bf1c1a6c242b600be804db6b81 \ No newline at end of file diff --git a/Core/Tests/HotspotBorderTests.cs b/Core/Tests/HotspotBorderTests.cs new file mode 100644 index 00000000..202adb99 --- /dev/null +++ b/Core/Tests/HotspotBorderTests.cs @@ -0,0 +1,350 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class HotspotBorderTests + { + // A 2048 sheet at 256 pixels a unit with one tile + sealed class OneTile : System.IDisposable + { + public ChiselHotspotLayout layout; + public NativeList tiles = new(Allocator.Temp); + public HotspotLayoutData data; + public HotspotSections border = HotspotSections.Create(Allocator.Temp); + + public OneTile(int borderPixels, params Vector2Int[] points) + { + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List { new() { id = 1, name = "tile", points = points, border = borderPixels } }; + HotspotMatcher.AddTiles(layout, tiles, out data); + } + + public HotspotChoice Build(in HotspotShape shape) + { + var choice = HotspotMatcher.Choose(tiles.AsArray(), data, shape, 0, 0); + Assert.That(choice.tile, Is.EqualTo(0)); + Assert.That(border.Build(tiles[0], data, shape, choice), Is.True, "no border was built"); + return choice; + } + + public float2 UV(double x, double y) => border.UV(new double2(x, y)); + + public void Dispose() + { + border.Dispose(); + tiles.Dispose(); + Object.DestroyImmediate(layout); + } + } + + // 1024 x 512 pixels: 4 x 2 units + static Vector2Int[] B1 => new[] { new Vector2Int(1024, 0), new Vector2Int(2048, 0), new Vector2Int(2048, 512), new Vector2Int(1024, 512) }; + + static HotspotShape Shape(params float3[] points) + { + using var positions = new NativeArray(points, Allocator.Temp); + return HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + } + + static HotspotShape Wall(float width, float height) => Shape(new float3(0, 0, 0), new float3(width, 0, 0), new float3(width, height, 0), new float3(0, height, 0)); + + // Across every line between sections, at points along it inside the surface, the uvs do not jump + static void AssertContinuous(in HotspotSections border, System.Func inside) + { + for (int l = 0; l < border.lines.Length; l++) + { + var line = border.lines[l]; + var along = new double2(-line.normal.y, line.normal.x); + var through = -line.normal * line.offset; + for (double s = -20; s <= 20; s += 0.0137) + { + var p = through + along * s; + if (!inside(p)) + continue; + var step = line.normal * 1e-7; + Assert.That(math.distance(border.UV(p - step), border.UV(p + step)), Is.LessThan(1e-5f), $"the uvs jump across line {l} at {p}"); + } + } + } + + static void AreEqual(float2 expectedPixels, float2 actual, string what) + { + var expected = expectedPixels / 2048; + Assert.That(math.distance(expected, actual), Is.LessThan(1e-6f), $"{what}: expected {expectedPixels} px, got {actual * 2048} px"); + } + + [Test] + public void AWallTheSizeOfItsTile_IsMappedAsWithoutABorder() + { + using var sheet = new OneTile(32, B1); + var shape = Wall(4, 2); + var choice = sheet.Build(shape); + var plain = HotspotMatcher.Mapping(sheet.tiles[0], sheet.data, shape, choice, new float3(0, 0, -1)); + for (float x = 0; x <= 4; x += 0.0625f) + { + for (float y = 0; y <= 2; y += 0.0625f) + { + var expected = math.mul(plain, new float4(x, y, 0, 1)).xy; + Assert.That(math.distance(expected, sheet.UV(x, y)), Is.LessThan(1e-6f), $"at ({x}, {y})"); + } + } + } + + [Test] + public void AStretchedWall_KeepsItsBorderAtItsPixelWidth_AndStretchesTheRest() + { + using var sheet = new OneTile(32, B1); + sheet.Build(Wall(8, 2)); + // 32 pixels at 256 a unit: an eighth of a unit; B1 is 1024..2048 x 1536..2048 with y up + AreEqual(new float2(1024, 1536), sheet.UV(0, 0), "the bottom left corner"); + AreEqual(new float2(1024 + 32, 1536 + 32), sheet.UV(0.125, 0.125), "the inner corner at the bottom left"); + AreEqual(new float2(2048 - 32, 2048 - 32), sheet.UV(7.875, 1.875), "the inner corner at the top right"); + AreEqual(new float2(1024 + 16, 1536 + 16), sheet.UV(0.0625, 0.0625), "inside the corner, unstretched"); + AreEqual(new float2(1536, 1536 + 16), sheet.UV(4, 0.0625), "the bottom strip, stretched along only"); + AreEqual(new float2(1024 + 16, 1792), sheet.UV(0.0625, 1), "the left strip"); + AreEqual(new float2(1536, 1792), sheet.UV(4, 1), "the middle"); + AssertContinuous(sheet.border, p => p.x > 1e-3 && p.x < 8 - 1e-3 && p.y > 1e-3 && p.y < 2 - 1e-3); + } + + [Test] + public void ATriangle_KeepsItsBorderAtItsPixelWidth() + { + // 256 x 128 pixels, its right angle at the bottom right as the texture shows it: (1792, 896) with y up + using var sheet = new OneTile(16, new Vector2Int(1792, 1024), new Vector2Int(1792, 1152), new Vector2Int(1536, 1152)); + // larger than the tile and not the same shape, its right angle at the bottom right + sheet.Build(Shape(new float3(0, 0, 0), new float3(2, 0, 0), new float3(2, 1.5f, 0))); + AreEqual(new float2(1792, 896), sheet.UV(2, 0), "the right angle"); + AreEqual(new float2(1792 - 16, 896 + 16), sheet.UV(2 - 0.0625, 0.0625), "its inner corner, 16 pixels in"); + AreEqual(new float2(1792 - 8, 896 + 8), sheet.UV(2 - 0.03125, 0.03125), "inside its corner, unstretched"); + AssertContinuous(sheet.border, p => p.y > 1e-3 && p.x < 2 - 1e-3 && p.y < p.x * 0.75 - 1e-3); + } + + // Every uv over the surface's bounds is a number and lies within the border's depth of the tile's edges + static void AssertAllBorder(OneTile sheet, double width, double height, float2 tileMin, float2 tileMax, float borderPixels) + { + for (double x = 0.0031; x < width; x += width / 97) + { + for (double y = 0.0017; y < height; y += height / 31) + { + var pixel = sheet.UV(x, y) * 2048; + Assert.That(math.all(math.isfinite(pixel)), Is.True, $"at ({x}, {y}): {pixel}"); + var depth = math.cmin(math.min(pixel - tileMin, tileMax - pixel)); + Assert.That(depth, Is.LessThanOrEqualTo(borderPixels + 1e-3f), $"({x}, {y}) is in the tile's interior: {pixel} px"); + } + } + } + + [Test] + public void AWallThinnerThanTwoBorders_IsAllBorder_ItsBorderKeepingItsPixelWidth() + { + using var sheet = new OneTile(32, B1); + var shape = Wall(4, 0.2f); + var choice = sheet.Build(shape); + Assert.That(HotspotSections.IsAllBorder(HotspotSections.Input(sheet.tiles[0], sheet.data, shape, choice, new float3(0, 0, -1))), Is.True); + // 32 pixels is an eighth of a unit, so the strips along the top and bottom meet in the middle, 0.1 in + // (25.6 pixels), and keep 256 pixels a unit across + AreEqual(new float2(1024, 1536), sheet.UV(0, 0), "the bottom left corner"); + AreEqual(new float2(1024 + 12.8f, 1536 + 12.8f), sheet.UV(0.05, 0.05), "inside the corner, unstretched"); + AreEqual(new float2(1536, 1536 + 12.8f), sheet.UV(2, 0.05), "the bottom strip"); + AreEqual(new float2(1536, 2048 - 12.8f), sheet.UV(2, 0.15), "the top strip"); + AssertAllBorder(sheet, 4, 0.2f, new float2(1024, 1536), new float2(2048, 2048), 32); + + var tall = Wall(4, 2); + var fits = sheet.Build(tall); + Assert.That(HotspotSections.IsAllBorder(HotspotSections.Input(sheet.tiles[0], sheet.data, tall, fits, new float3(0, 0, -1))), Is.False); + } + + [Test] + public void AWallThatIsAllBorder_IsCutWithoutNaN_ItsStripsMeetingOnOneLine() + { + // the insets of the top and the bottom are the same line: no point may come from crossing them + using var sheet = new OneTile(32, B1); + sheet.Build(Wall(4, 0.2f)); + using var indices = new NativeList(Allocator.Temp) { 0, 1, 2, 0, 2, 3 }; + using var collider = new NativeList(Allocator.Temp) { new float3(0, 0, 0), new float3(4, 0, 0), new float3(4, 0.2f, 0), new float3(0, 0.2f, 0) }; + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in collider) + { + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1) }); + } + var right = new float3(1, 0, 0); var up = new float3(0, 1, 0); + sheet.border.Split(right, up, new float3(0, 0, -1), indices, collider, select, render); + sheet.border.AssignUVs(indices, collider, select, render, right, up); + var area = 0.0; + for (int t = 0; t < indices.Length; t += 3) + { + var a = (double3)collider[indices[t]]; var b = (double3)collider[indices[t + 1]]; var c = (double3)collider[indices[t + 2]]; + area += ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) * 0.5; + } + for (int i = 0; i < render.Length; i++) + { + Assert.That(math.all(math.isfinite(collider[i])), Is.True, $"vertex {i}: {collider[i]}"); + Assert.That(math.all(math.isfinite(render[i].uv0)), Is.True, $"vertex {i}'s uv: {render[i].uv0}"); + } + Assert.That(area, Is.EqualTo((double)4 * 0.2f).Within(1e-6), "the pieces cover the wall once"); + } + + // A rectangle's two triangles cut once along its sections (CutOnce) + static (NativeList indices, NativeList positions, NativeList render) CutOnce(OneTile sheet, float width, float height) + { + var indices = new NativeList(Allocator.Temp) { 0, 1, 2, 0, 2, 3 }; + var collider = new NativeList(Allocator.Temp) { new float3(0, 0, 0), new float3(width, 0, 0), new float3(width, height, 0), new float3(0, height, 0) }; + var select = new NativeList(Allocator.Temp); + var render = new NativeList(Allocator.Temp); + foreach (var p in collider) + { + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1) }); + } + var right = new float3(1, 0, 0); var up = new float3(0, 1, 0); + Assert.That(sheet.border.CutOnce(right, up, new float3(0, 0, -1), indices, collider, select, render), Is.True, "not cut once"); + sheet.border.AssignUVs(indices, collider, select, render, right, up); + select.Dispose(); + return (indices, collider, render); + } + + // Every triangle wound as before, nothing flat, the pieces cover the surface once, and an edge only one triangle + // has is on the outline: no T-junctions + public static void AssertWatertight(NativeList indices, NativeList positions, double expectedArea, System.Func onOutline) + { + var area = 0.0; + var uses = new Dictionary<(float3, float3), int>(); + for (int t = 0; t < indices.Length; t += 3) + { + var a = (double3)positions[indices[t]]; var b = (double3)positions[indices[t + 1]]; var c = (double3)positions[indices[t + 2]]; + var doubled = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + Assert.That(doubled, Is.GreaterThan(0), $"triangle {t / 3} is flat or turned over"); + area += doubled * 0.5; + for (int k = 0; k < 3; k++) + { + var p = positions[indices[t + k]]; var q = positions[indices[t + (k + 1) % 3]]; + var key = Less(p, q) ? (p, q) : (q, p); + uses.TryGetValue(key, out var n); + uses[key] = n + 1; + } + } + Assert.That(area, Is.EqualTo(expectedArea).Within(1e-6 * expectedArea), "the pieces do not cover the surface once"); + foreach (var (edge, n) in uses) + { + Assert.That(n, Is.LessThanOrEqualTo(2), $"edge {edge} is used {n} times"); + if (n == 1) + Assert.That(onOutline(edge.Item1) && onOutline(edge.Item2), Is.True, $"edge {edge} has one triangle but is not on the outline: a T-junction"); + } + } + + [Test] + public void ABorderedWall_IsCutOnce_EachSectionTwoTriangles() + { + using var sheet = new OneTile(32, B1); + sheet.Build(Wall(8, 2)); + var (indices, positions, render) = CutOnce(sheet, 8, 2); + // four corners, four strips and the interior: nine quads on a grid of 4 x 4 points + Assert.That(indices.Length / 3, Is.EqualTo(18)); + Assert.That(positions.Length, Is.EqualTo(16)); + AssertWatertight(indices, positions, 16, p => p.x == 0 || p.x == 8 || p.y == 0 || p.y == 2); + AreEqual(new float2(1024 + 32, 1536 + 32), UVOf(render, new float3(0.125f, 0.125f, 0)), "the inner corner"); + indices.Dispose(); positions.Dispose(); render.Dispose(); + } + + [Test] + public void AWallThatIsAllBorder_IsCutOnce() + { + using var sheet = new OneTile(32, B1); + sheet.Build(Wall(4, 0.2f)); + var (indices, positions, render) = CutOnce(sheet, 4, 0.2f); + // four corners and the strips along the top and the bottom, meeting in the middle + Assert.That(indices.Length / 3, Is.EqualTo(12)); + AssertWatertight(indices, positions, 4 * 0.2f, p => p.x == 0 || p.x == 4 || p.y == 0 || p.y == 0.2f); + for (int i = 0; i < render.Length; i++) + Assert.That(math.all(math.isfinite(render[i].uv0)), Is.True); + indices.Dispose(); positions.Dispose(); render.Dispose(); + } + + static bool Less(float3 a, float3 b) => a.x != b.x ? a.x < b.x : (a.y != b.y ? a.y < b.y : a.z < b.z); + + static float2 UVOf(NativeList render, float3 position) + { + for (int i = 0; i < render.Length; i++) + { + if (math.distance(render[i].position, position) < 1e-5f) + return render[i].uv0; + } + Assert.Fail($"no vertex at {position}"); + return default; + } + + [Test] + public void ATriangleSmallerThanItsBorder_IsAllBorder() + { + // 16 pixels: a sixteenth of a unit, more than the triangle's inradius of 0.05 + using var sheet = new OneTile(16, new Vector2Int(1792, 1024), new Vector2Int(1792, 1152), new Vector2Int(1536, 1152)); + var shape = Shape(new float3(0, 0, 0), new float3(0.2f, 0, 0), new float3(0.2f, 0.15f, 0)); + var choice = sheet.Build(shape); + Assert.That(HotspotSections.IsAllBorder(HotspotSections.Input(sheet.tiles[0], sheet.data, shape, choice, new float3(0, 0, -1))), Is.True); + AreEqual(new float2(1792, 896), sheet.UV(0.2, 0), "the right angle"); + AreEqual(new float2(1792 - 2.56f, 896 + 2.56f), sheet.UV(0.2 - 0.01, 0.01), "inside its corner, unstretched"); + for (double x = 0.01; x < 0.2; x += 0.0071) + { + for (double y = 0.001; y < x * 0.75; y += 0.0043) + Assert.That(math.all(math.isfinite(sheet.UV(x, y))), Is.True, $"at ({x}, {y})"); + } + } + + [Test] + public void AWall_IsCutWhereItsSectionsMeet_TheTrianglesOnItsDiagonalSharingTheirPoints() + { + using var sheet = new OneTile(32, B1); + sheet.Build(Wall(4.1f, 2.3f)); + using var indices = new NativeList(Allocator.Temp) { 0, 1, 2, 0, 2, 3 }; + using var collider = new NativeList(Allocator.Temp) { new float3(0, 0, 0), new float3(4.1f, 0, 0), new float3(4.1f, 2.3f, 0), new float3(0, 2.3f, 0) }; + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in collider) + { + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1) }); + } + sheet.border.Split(new float3(1, 0, 0), new float3(0, 1, 0), new float3(0, 0, -1), indices, collider, select, render); + + // 4 corners, 2 points on each side, 4 inner corners and the 4 points where the lines cross the diagonal + Assert.That(collider.Length, Is.EqualTo(20)); + Assert.That(render.Length, Is.EqualTo(20)); + Assert.That(select.Length, Is.EqualTo(20)); + var area = 0.0; + var cells = sheet.border.cells; + for (int t = 0; t < indices.Length; t += 3) + { + var a = (double3)collider[indices[t]]; var b = (double3)collider[indices[t + 1]]; var c = (double3)collider[indices[t + 2]]; + var doubled = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + Assert.That(doubled, Is.Not.EqualTo(0), "a flat triangle"); + Assert.That(doubled, Is.GreaterThan(0), "the winding changed"); + area += doubled * 0.5; + // in one section: the one its middle is in holds all its corners + var middle = ((a + b + c) / 3).xy; + var inside = false; + for (int s = 0; s < cells.Length && !inside; s++) + { + var cell = cells[s]; + var all = true; + foreach (var corner in new[] { middle, a.xy, b.xy, c.xy }) + { + for (int h = cell.halfPlaneStart; h < cell.halfPlaneStart + cell.halfPlaneCount; h++) + all &= sheet.border.halfPlanes[h].side * sheet.border.lines[sheet.border.halfPlanes[h].line].Value(corner) >= -1e-9; + } + inside = all; + } + Assert.That(inside, Is.True, $"triangle {t / 3} lies across sections"); + } + Assert.That(area, Is.EqualTo((double)4.1f * 2.3f).Within(1e-9), "the pieces cover the wall once"); + } + } +} diff --git a/Core/Tests/HotspotBorderTests.cs.meta b/Core/Tests/HotspotBorderTests.cs.meta new file mode 100644 index 00000000..729ad1a4 --- /dev/null +++ b/Core/Tests/HotspotBorderTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7745317f841b7a444877620cd53c6fbf \ No newline at end of file diff --git a/Core/Tests/HotspotConformingTests.cs b/Core/Tests/HotspotConformingTests.cs new file mode 100644 index 00000000..61d40a32 --- /dev/null +++ b/Core/Tests/HotspotConformingTests.cs @@ -0,0 +1,964 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class HotspotConformingTests + { + // B1 of the test sheet alone: 1024 x 512 pixels at the top right of a 2048 sheet, 256 pixels a unit, a 32 pixel + // border: an eighth of a unit + sealed class Surface : System.IDisposable + { + public ChiselHotspotLayout layout; + public NativeList tiles = new(Allocator.Temp); + public HotspotLayoutData data; + public HotspotSections sections = HotspotSections.Create(Allocator.Temp); + public HotspotSectionInput input; + public NativeList indices = new(Allocator.Temp); + public NativeList positions = new(Allocator.Temp); + + public Surface(params float2[] triangles) : this(32, triangles) { } + + public Surface(int border, params float2[] triangles) : this(border, 0, triangles) { } + + public Surface(int border, float padding, params float2[] triangles) : this(border, padding, padding, triangles) { } + + public Surface(int border, float padding, float interiorPadding, params float2[] triangles) : this(border, padding, interiorPadding, null, triangles) { } + + // B1 linked to its inside corner pieces + public Surface(HotspotTile pieces, params float2[] triangles) : this(32, 0, 0, pieces, triangles) { } + + Surface(int border, float padding, float interiorPadding, HotspotTile? pieces, float2[] triangles) + { + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.padding = padding; + layout.interiorPadding = interiorPadding; + layout.tiles = new List { new() { id = 1, name = "B1", border = border, insideCorners = pieces?.id ?? 0, + points = new[] { new Vector2Int(1024, 0), new Vector2Int(2048, 0), new Vector2Int(2048, 512), new Vector2Int(1024, 512) } } }; + if (pieces.HasValue) + layout.tiles.Add(pieces.Value); + HotspotMatcher.AddTiles(layout, tiles, out data); + var ids = new Dictionary(); + foreach (var p in triangles) + { + if (!ids.TryGetValue(p, out var id)) + { + id = positions.Length; + ids.Add(p, id); + positions.Add(new float3(p, 0)); + } + indices.Add(id); + } + // facing -z: the frame's right is +x and its up +y + var shape = HotspotMatcher.ShapeOf(positions.AsArray(), new float3(0, 0, -1)); + var choice = HotspotMatcher.Choose(tiles.AsArray(), data, shape, 0, 0); + Assert.That(choice.tile, Is.EqualTo(0)); + input = HotspotSections.Input(tiles[0], data, shape, choice, new float3(0, 0, -1)); + } + + public float2 Pixels(double x, double y) => sections.UV(new double2(x, y)) * 2048; + + public void Dispose() + { + sections.Dispose(); + tiles.Dispose(); + indices.Dispose(); + positions.Dispose(); + Object.DestroyImmediate(layout); + } + } + + static float2[] Rectangle(float w, float h) => new[] { new float2(0, 0), new float2(w, 0), new float2(w, h), new float2(0, 0), new float2(w, h), new float2(0, h) }; + + // Edges given a border (border 1) or none (-1) by hand, each between two corners + static NativeArray Rules(int border, params float2[] corners) + { + var rules = new NativeArray(corners.Length, Allocator.Temp); + for (int i = 0; i < corners.Length; i++) + rules[i] = new HotspotBorderCorner { position = new float3(corners[i], 0), rule = 1, border = border }; + return rules; + } + + // An 8 x 2 wall with a corner on its bottom edge at x = 2 + static float2[] WallWithACornerOnItsBottom() => new[] + { + new float2(0, 0), new float2(2, 0), new float2(0, 2), + new float2(2, 0), new float2(8, 0), new float2(8, 2), + new float2(2, 0), new float2(8, 2), new float2(0, 2), + }; + + [Test] + public void TwoSquaresWithTheEdgeTheyShareKeptByHand_AreTwoLoops() + { + var squares = new List(Rectangle(2, 2)); + foreach (var p in Rectangle(2, 2)) squares.Add(p + new float2(2, 0)); + using var wall = new Surface(squares.ToArray()); + using var corners = new NativeList(Allocator.Temp); + using var loops = new NativeList(Allocator.Temp); + using var bordered = new NativeList(Allocator.Temp); + HotspotSections.OutlineOf(wall.indices, wall.positions, new float3(1, 0, 0), new float3(0, 1, 0), default, corners, loops, bordered); + Assert.That(loops.Length, Is.EqualTo(1), "together, one loop"); + using var rules = Rules(1, new float2(2, 0), new float2(2, 2)); + HotspotSections.OutlineOf(wall.indices, wall.positions, new float3(1, 0, 0), new float3(0, 1, 0), rules, corners, loops, bordered); + Assert.That(loops.Length, Is.EqualTo(2), "with the edge kept, a loop each"); + Assert.That(loops[0].y, Is.EqualTo(4)); + Assert.That(loops[1].y, Is.EqualTo(4)); + foreach (var b in bordered) + Assert.That(b, Is.True, "every edge has a border"); + } + + [Test] + public void AnEdgeWithoutABorderOnPartOfIt_KeepsTheCornerWhereTheBorderStops() + { + using var wall = new Surface(WallWithACornerOnItsBottom()); + using var corners = new NativeList(Allocator.Temp); + using var loops = new NativeList(Allocator.Temp); + using var bordered = new NativeList(Allocator.Temp); + HotspotSections.OutlineOf(wall.indices, wall.positions, new float3(1, 0, 0), new float3(0, 1, 0), default, corners, loops, bordered); + Assert.That(loops.Length == 1 && loops[0].y == 4, Is.True, "by itself: no corner on a straight edge"); + using var rules = Rules(-1, new float2(2, 0), new float2(8, 0)); + HotspotSections.OutlineOf(wall.indices, wall.positions, new float3(1, 0, 0), new float3(0, 1, 0), rules, corners, loops, bordered); + Assert.That(loops.Length == 1 && loops[0].y == 5, Is.True, "the corner where the border stops"); + var without = 0; + for (int k = 0; k < corners.Length; k++) + { + if (bordered[k]) + continue; + without++; + AreEqual(new float2(2, 0), (float2)corners[k], "the edge without a border starts at x = 2"); + AreEqual(new float2(8, 0), (float2)corners[(k + 1) % corners.Length], "and ends at the corner"); + } + Assert.That(without, Is.EqualTo(1)); + } + + [Test] + public void AnEdgeWithoutABorderOnPartOfIt_HasAStripOnlyWhereItHasOne() + { + using var wall = new Surface(WallWithACornerOnItsBottom()); + using var rules = Rules(-1, new float2(2, 0), new float2(8, 0)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions, rules), Is.True); + // B1 is 1024..2048 x 1536..2048 with y up: the bottom strip 16 pixels up where it has a border + Assert.That(wall.Pixels(1, 0.0625).y, Is.EqualTo(1536 + 16).Within(1e-3f), "the bottom strip"); + Assert.That(wall.Pixels(2 - 0.01, 0.0625).y, Is.EqualTo(1536 + 16).Within(1e-3f), "up to where it stops"); + // past it, the interior (here its density up the wall is the border's, so the pixel alone cannot tell) + Assert.That(wall.sections.Locate(new double2(2 - 0.01, 0.0625)), Is.Not.EqualTo(wall.sections.interiorCell), "the strip up to where it stops"); + Assert.That(wall.sections.Locate(new double2(2 + 0.01, 0.0625)), Is.EqualTo(wall.sections.interiorCell), "not past it"); + Assert.That(wall.sections.Locate(new double2(5, 0.0625)), Is.EqualTo(wall.sections.interiorCell), "nor further along"); + // the right side keeps its strip down to the bottom + Assert.That(wall.Pixels(8 - 0.0625, 0.01).x, Is.EqualTo(2048 - 16).Within(1e-3f), "the right strip at the bottom"); + Assert.That(wall.sections.interiorToEdges, Is.True); + Assert.That(wall.sections.openSides, Is.EqualTo(0), "the bottom has a border along part of it"); + } + + [Test] + public void TheOffset_MovesABorderedTilesInteriorRepeatsAlong() + { + using var wall = new Surface(Rectangle(8, 2)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions), Is.True); + // B1's inside is 1056..2016 wide: 3.75 units a repeat at 256 pixels a unit + using var repeats = HotspotSections.Create(Allocator.Temp); + Assert.That(repeats.BuildInteriorRepeats(wall.input), Is.True); + Assert.That((repeats.UV(new double2(0.125 + 1e-6, 1)) * 2048).x, Is.EqualTo(1056).Within(1e-2f), "unmoved, a repeat starts at the inner corner"); + var input = wall.input; + input.interiorOffset = new float2(0.5f, 0); + Assert.That(repeats.BuildInteriorRepeats(input), Is.True); + Assert.That((repeats.UV(new double2(0.125 + 1e-6, 1)) * 2048).x, Is.EqualTo(1056 + 480).Within(1e-2f), "moved half a repeat along"); + Assert.That((repeats.UV(new double2(0.125 + 1.875 + 1e-6, 1)) * 2048).x, Is.EqualTo(1056).Within(1e-2f), "the next repeat starts half a repeat in"); + } + + // The border along one edge (from, to) given by hand + static NativeArray Edge(int border, float2 from, float2 to) + { + return new NativeArray(new[] { new HotspotBorderCorner { position = new float3(from, 0), end = new float3(to, 0), segment = true, rule = 1, border = border } }, Allocator.Temp); + } + + [Test] + public void ARectangle_WithTheBorderAlongOneEdgeHidden_HasNoStripAlongIt() + { + using var wall = new Surface(Rectangle(8, 2)); + // the brush's edge a little longer than what is drawn of it, and a float step off: the outline's edge lies along it + using var rules = Edge(-1, new float2(-1, 1e-6f), new float2(9, 0)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions, rules), Is.True); + Assert.That(wall.sections.openSides, Is.EqualTo(1), "the bottom"); + AreEqual(new float2(1024 + 16, 1536 + 32), wall.Pixels(0.0625, 0), "the left strip at the bottom"); + Assert.That(wall.Pixels(4, 2 - 0.0625).y, Is.EqualTo(2048 - 16).Within(1e-3f), "the top keeps its strip"); + } + + [Test] + public void ARectangle_WithTheBorderAlongOneEdgeHidden_KeepsItsOtherEdges() + { + using var wall = new Surface(Rectangle(8, 2)); + // a rule along a line the outline only touches at a corner changes nothing + using var rules = Edge(-1, new float2(8, 0), new float2(12, 0)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions, rules), Is.True); + Assert.That(wall.sections.openSides, Is.EqualTo(0)); + Assert.That(wall.sections.interiorToEdges, Is.False); + } + + [Test] + public void ARectangle_WithItsBottomBorderHiddenByHand_HasItsInteriorAndSidesRunOnToTheBottom() + { + using var wall = new Surface(Rectangle(8, 2)); + using var rules = Rules(-1, new float2(0, 0), new float2(8, 0)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions, rules), Is.True); + Assert.That(wall.sections.openSides, Is.EqualTo(1), "the bottom"); + // B1 is 1024..2048 x 1536..2048 with y up; its inside starts 32 pixels up. The sides' strips run down to the + // bottom on the tile's side strips, and the interior starts at the bottom + AreEqual(new float2(1024 + 16, 1536 + 32), wall.Pixels(0.0625, 0), "the left strip at the bottom"); + AreEqual(new float2(2048 - 16, 1536 + 32), wall.Pixels(8 - 0.0625, 0), "the right strip at the bottom"); + Assert.That(wall.Pixels(4, 1e-6).y, Is.EqualTo(1536 + 32).Within(1e-3f), "the interior at the bottom"); + // the top keeps its border + Assert.That(wall.Pixels(4, 2 - 0.0625).y, Is.EqualTo(2048 - 16).Within(1e-3f), "the top strip"); + // and the interior's repeats start at the bottom too + using var repeats = HotspotSections.Create(Allocator.Temp); + Assert.That(repeats.BuildInteriorRepeats(wall.input, wall.sections.openSides, wall.sections.interiorToEdges), Is.True); + Assert.That((repeats.UV(new double2(4, 1e-6)) * 2048).y, Is.EqualTo(1536 + 32).Within(1e-2f), "the first repeat at the bottom"); + } + + // A 6 x 3 wall with a doorway 1.2 wide and 2.3 high in its middle, in 6 triangles without T junctions + static float2[] Doorway() => new[] + { + new float2(0, 0), new float2(2.4f, 0), new float2(2.4f, 2.3f), new float2(0, 0), new float2(2.4f, 2.3f), new float2(0, 3), + new float2(0, 3), new float2(2.4f, 2.3f), new float2(3.6f, 2.3f), new float2(0, 3), new float2(3.6f, 2.3f), new float2(6, 3), + new float2(3.6f, 2.3f), new float2(3.6f, 0), new float2(6, 0), new float2(3.6f, 2.3f), new float2(6, 0), new float2(6, 3), + }; + + // A 6 x 3 wall with a window 1.5 x 1 in it, in 8 triangles without T junctions + static float2[] Window() => new[] + { + new float2(0, 0), new float2(6, 0), new float2(3.75f, 1), new float2(0, 0), new float2(3.75f, 1), new float2(2.25f, 1), + new float2(6, 0), new float2(6, 3), new float2(3.75f, 2), new float2(6, 0), new float2(3.75f, 2), new float2(3.75f, 1), + new float2(6, 3), new float2(0, 3), new float2(2.25f, 2), new float2(6, 3), new float2(2.25f, 2), new float2(3.75f, 2), + new float2(0, 3), new float2(0, 0), new float2(2.25f, 1), new float2(0, 3), new float2(2.25f, 1), new float2(2.25f, 2), + }; + + // A trapezoid 4 wide at its foot and 2 at its top, 2 high + static float2[] Trapezoid() => new[] { new float2(0, 0), new float2(4, 0), new float2(3, 2), new float2(0, 0), new float2(3, 2), new float2(1, 2) }; + + // An L: 4 x 2 with its top right 2 x 1 cut away + static float2[] LShape() => new[] + { + new float2(0, 0), new float2(4, 0), new float2(4, 1), new float2(0, 0), new float2(4, 1), new float2(2, 1), + new float2(0, 0), new float2(2, 1), new float2(2, 2), new float2(0, 0), new float2(2, 2), new float2(0, 2), + }; + + // Inside corner pieces at the sheet's top left: four in 64 x 64 pixels, their middle at (32, 2016) y up; or one, + // 32 x 32, the corner of its outer edges at (32, 2016) + static HotspotTile FourPieces => new() { id = 2, name = "Inside", flags = HotspotTileFlags.InsideCorners, + points = new[] { new Vector2Int(0, 0), new Vector2Int(64, 0), new Vector2Int(64, 64), new Vector2Int(0, 64) } }; + static HotspotTile OnePiece => new() { id = 2, name = "Inside", flags = HotspotTileFlags.InsideCornerTurned, + points = new[] { new Vector2Int(0, 0), new Vector2Int(32, 0), new Vector2Int(32, 32), new Vector2Int(0, 32) } }; + + // A convex polygon as a fan of triangles from its first corner + static float2[] Fan(params float2[] corners) + { + var triangles = new List(); + for (int i = 1; i + 1 < corners.Length; i++) + triangles.AddRange(new[] { corners[0], corners[i], corners[i + 1] }); + return triangles.ToArray(); + } + + // A hexagon 4 wide and 2 high with a point at its top and its foot, as a cap seen from above + static readonly float2[] kHexagon = { new(2, 0), new(4, 0.5f), new(4, 1.5f), new(2, 2), new(0, 1.5f), new(0, 0.5f) }; + + // No triangle lies flat in the tile: its uvs have area + static void AssertNoneFlat(Surface surface, NativeList render) + { + for (int t = 0; t < surface.indices.Length; t += 3) + { + var a = render[surface.indices[t]]; var b = render[surface.indices[t + 1]]; var c = render[surface.indices[t + 2]]; + var area = math.length(math.cross(b.position - a.position, c.position - a.position)); + if (area < 1e-6f) + continue; + var uvArea = math.abs((b.uv0.x - a.uv0.x) * (c.uv0.y - a.uv0.y) - (b.uv0.y - a.uv0.y) * (c.uv0.x - a.uv0.x)) * 2048 * 2048; + Assert.That(uvArea, Is.GreaterThan(1e-3f), $"triangle {t / 3} ({a.position}, {b.position}, {c.position}) lies flat in the tile"); + } + } + + [Test] + public void ABorderedHexagon_HasNoTriangleFlatInTheTile() + { + using var hexagon = new Surface(32, Fan(kHexagon)); + Assert.That(hexagon.sections.BuildConforming(hexagon.input, hexagon.indices, hexagon.positions), Is.True); + using var render = Map(hexagon); + AssertNoneFlat(hexagon, render); + AssertNotFolded(hexagon, render); + } + + [Test] + public void ATrapezoidWithABorder_HasItsInteriorAsItsBoundsMapIt() + { + // undistorted: the inset bounds, 0.125 in from 4 x 2, onto the tile's, 32 pixels in + using var trapezoid = new Surface(32, Trapezoid()); + Assert.That(trapezoid.sections.BuildConforming(trapezoid.input, trapezoid.indices, trapezoid.positions), Is.True); + AreEqual(new float2(1536, 1792), trapezoid.Pixels(2, 1), "the middle"); + AreEqual(new float2(1408, 1664), trapezoid.Pixels(1.5, 0.5), "lower left"); + using var render = Map(trapezoid); + AssertNotFolded(trapezoid, render); + } + + [Test] + public void AFaceWithoutABorder_IsNotCut() + { + using var trapezoid = new Surface(0, Trapezoid()); + Assert.That(trapezoid.sections.BuildConforming(trapezoid.input, trapezoid.indices, trapezoid.positions), Is.False); + using var hexagon = new Surface(0, Fan(kHexagon)); + Assert.That(hexagon.sections.BuildConforming(hexagon.input, hexagon.indices, hexagon.positions), Is.False); + } + + // The end of a spiral staircase's step: two rectangles touching at one corner, (1, 1) + static float2[] TwoRectanglesTouchingAtACorner() => new[] + { + new float2(1, 0), new float2(3, 0), new float2(3, 1), new float2(1, 0), new float2(3, 1), new float2(1, 1), + new float2(0, 1), new float2(1, 1), new float2(1, 2), new float2(0, 1), new float2(1, 2), new float2(0, 2), + }; + + [Test] + public void TwoSurfacesMeetingAlongAnEdge_WithACornerOfOneOnTheOthersEdge_AreOneLoop() + { + // a 4 x 1 strip below two 2 x 1 pieces: the pieces' shared corner (2, 1) is on the strip's top edge + using var wall = new Surface(0, new[] + { + new float2(0, 0), new float2(4, 0), new float2(4, 1), new float2(0, 0), new float2(4, 1), new float2(0, 1), + new float2(0, 1), new float2(2, 1), new float2(2, 2), new float2(0, 1), new float2(2, 2), new float2(0, 2), + new float2(2, 1), new float2(4, 1), new float2(4, 2), new float2(2, 1), new float2(4, 2), new float2(2, 2), + }); + using var corners = new NativeList(Allocator.Temp); + using var loops = new NativeList(Allocator.Temp); + HotspotSections.OutlineOf(wall.indices, wall.positions, new float3(1, 0, 0), new float3(0, 1, 0), corners, loops); + Assert.That(loops.Length, Is.EqualTo(1), "loops"); + Assert.That(loops[0].y, Is.EqualTo(4), "corners: the 4 x 2 rectangle"); + } + + [Test] + public void TwoRectanglesTouchingAtACorner_AreTwoLoops() + { + using var steps = new Surface(0, TwoRectanglesTouchingAtACorner()); + using var corners = new NativeList(Allocator.Temp); + using var loops = new NativeList(Allocator.Temp); + HotspotSections.OutlineOf(steps.indices, steps.positions, new float3(1, 0, 0), new float3(0, 1, 0), corners, loops); + Assert.That(loops.Length, Is.EqualTo(2), "loops"); + Assert.That(loops[0].y, Is.EqualTo(4), "corners of the first"); + Assert.That(loops[1].y, Is.EqualTo(4), "corners of the second"); + } + + [Test] + public void TwoRectanglesTouchingAtACorner_KeepTheirInteriorAsTheBoundsMapIt() + { + using var steps = new Surface(32, TwoRectanglesTouchingAtACorner()); + Assert.That(steps.sections.BuildConforming(steps.input, steps.indices, steps.positions), Is.True); + using var render = Map(steps); + AssertNoneFlat(steps, render); + } + + // The surface cut and given its uvs as OutputHotspotSections does; render vertices returned + static NativeList Map(Surface surface) + { + var select = new NativeList(Allocator.Temp); + var render = new NativeList(Allocator.Temp); + foreach (var p in surface.positions) + { + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1) }); + } + var right = new float3(1, 0, 0); var up = new float3(0, 1, 0); + if (!surface.sections.CutOnce(right, up, new float3(0, 0, -1), surface.indices, surface.positions, select, render)) + surface.sections.Split(right, up, new float3(0, 0, -1), surface.indices, surface.positions, select, render); + surface.sections.AssignUVs(surface.indices, surface.positions, select, render, right, up); + select.Dispose(); + return render; + } + + [Test] + public void ASliverOfTheCut_TakesItsNeighboursSection() + { + // two border bands and 0.2 mm between them: the interior there is a sliver, far less than a texel high + using var wall = new Surface(32, Rectangle(4, 0.25f + 0.0002f)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions), Is.True); + using var render = Map(wall); + var worst = 0.0; + for (int t = 0; t + 2 < wall.indices.Length; t += 3) + { + var a = render[wall.indices[t]]; var b = render[wall.indices[t + 1]]; var c = render[wall.indices[t + 2]]; + if (!HotspotDiagnostics.Measure(a.position, b.position, c.position, a.uv0, b.uv0, c.uv0, new float2(2048), 256, out var s1, out var s2, out _)) + continue; + worst = math.max(worst, s2 > 0 ? s1 / s2 : double.PositiveInfinity); + } + Assert.That(worst, Is.LessThan(1.01), "no triangle squeezes a section into the sliver"); + } + + static float2 PixelsAt(NativeList render, float2 position) + { + for (int i = 0; i < render.Length; i++) + { + if (math.distance(render[i].position.xy, position) < 1e-5f) + return render[i].uv0 * 2048; + } + Assert.Fail($"no vertex at {position}"); + return default; + } + + static void AssertNotFolded(Surface surface, NativeList render) + { + for (int t = 0; t < surface.indices.Length; t += 3) + { + var a = render[surface.indices[t]].uv0; var b = render[surface.indices[t + 1]].uv0; var c = render[surface.indices[t + 2]].uv0; + var area = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + Assert.That(area, Is.GreaterThan(0), $"triangle {t / 3} is folded over in the tile"); + foreach (var uv in new[] { a, b, c }) + Assert.That(uv.x >= 0.5f - 1e-6f && uv.x <= 1 + 1e-6f && uv.y >= 0.75f - 1e-6f && uv.y <= 1 + 1e-6f, Is.True, $"{uv * 2048} is outside the tile"); + } + } + + static bool OnSegment(float3 p, float2 a, float2 b) + { + var d = b - a; + var t = math.dot(p.xy - a, d) / math.lengthsq(d); + return t > -1e-6f && t < 1 + 1e-6f && math.distance(a + d * t, p.xy) < 1e-5f; + } + + static System.Func OnOutline(params float2[] corners) => + p => System.Linq.Enumerable.Range(0, corners.Length).Any(k => OnSegment(p, corners[k], corners[(k + 1) % corners.Length])); + + [Test] + public void ABorderedTrapezoid_IsCutOnce_WithoutTJunctions() + { + using var trapezoid = new Surface(32, Trapezoid()); + Assert.That(trapezoid.sections.BuildConforming(trapezoid.input, trapezoid.indices, trapezoid.positions), Is.True); + using var render = Map(trapezoid); + // four strips, four corners in two halves and the interior + Assert.That(trapezoid.indices.Length / 3, Is.LessThanOrEqualTo(26), "more triangles than its sections need"); + HotspotBorderTests.AssertWatertight(trapezoid.indices, trapezoid.positions, 6, + OnOutline(new float2(0, 0), new float2(4, 0), new float2(3, 2), new float2(1, 2))); + } + + [Test] + public void ABorderedRectangleTurnedInItsFrame_IsCutOnce_WithoutTJunctions() + { + // a 3 x 1.5 rectangle turned 30 degrees, as a tumbled box's face is in the texture's frame + math.sincos(math.radians(30f), out float sin, out float cos); + var c0 = new float2(0, 0); var c1 = new float2(3 * cos, 3 * sin); var c3 = new float2(-1.5f * sin, 1.5f * cos); var c2 = c1 + c3; + using var turned = new Surface(32, c0, c1, c2, c0, c2, c3); + Assert.That(turned.sections.BuildConforming(turned.input, turned.indices, turned.positions), Is.True); + using var render = Map(turned); + Assert.That(turned.indices.Length / 3, Is.LessThanOrEqualTo(26), "more triangles than its sections need"); + HotspotBorderTests.AssertWatertight(turned.indices, turned.positions, 3 * 1.5, OnOutline(c0, c1, c2, c3)); + for (int i = 0; i < render.Length; i++) + Assert.That(math.all(math.isfinite(render[i].uv0)), Is.True, $"vertex {i}'s uv"); + } + + [Test] + public void AnLAWindowAndARectangle_KeepTheirInteriorAsTheBoundsMapIt() + { + using var l = new Surface(32, LShape()); + Assert.That(l.sections.BuildConforming(l.input, l.indices, l.positions), Is.True); + using var window = new Surface(32, Window()); + Assert.That(window.sections.BuildConforming(window.input, window.indices, window.positions), Is.True); + using var rectangle = new Surface(0, Rectangle(4, 2)); + Assert.That(rectangle.sections.BuildConforming(rectangle.input, rectangle.indices, rectangle.positions), Is.False, "a rectangle without a border is one section"); + } + + [Test] + public void AnInsideCorner_TakesThePieceOnTheSideItsSquareLies() + { + // the L's inside corner at (2, 1): its square, an eighth of a unit each way, lies below and left of it + using var l = new Surface(FourPieces, LShape()); + Assert.That(l.sections.BuildConforming(l.input, l.indices, l.positions), Is.True); + AreEqual(new float2(32 - 2.56f, 2016 - 1.28f), l.Pixels(2 - 0.01, 1 - 0.005), "in the bottom left piece"); + AreEqual(new float2(0.256f, 1984.256f), l.Pixels(2 - 0.125 + 0.001, 1 - 0.125 + 0.001), "its far corner"); + + // the doorway's: above and left of the first, above and right of the second + using var door = new Surface(FourPieces, Doorway()); + Assert.That(door.sections.BuildConforming(door.input, door.indices, door.positions), Is.True); + AreEqual(new float2(32 - 5.12f, 2016 + 1.28f), door.Pixels(2.4 - 0.02, 2.3 + 0.005), "in the top left piece"); + AreEqual(new float2(32 + 5.12f, 2016 + 1.28f), door.Pixels(3.6 + 0.02, 2.3 + 0.005), "in the top right piece"); + } + + [Test] + public void OneInsideCornerPiece_IsTurnedNeverMirrored() + { + using var door = new Surface(OnePiece, Doorway()); + Assert.That(door.sections.BuildConforming(door.input, door.indices, door.positions), Is.True); + // the first corner's square lies as the piece is drawn, the top left + AreEqual(new float2(32 - 5.12f, 2016 + 1.28f), door.Pixels(2.4 - 0.02, 2.3 + 0.005), "as drawn"); + // the second's, its mirror image, takes the piece turned a quarter turn + AreEqual(new float2(32 - 1.28f, 2016 + 5.12f), door.Pixels(3.6 + 0.02, 2.3 + 0.005), "turned"); + + using var l = new Surface(OnePiece, LShape()); + Assert.That(l.sections.BuildConforming(l.input, l.indices, l.positions), Is.True); + AreEqual(new float2(32 - 1.28f, 2016 + 2.56f), l.Pixels(2 - 0.01, 1 - 0.005), "turned a quarter turn the other way"); + } + + [Test] + public void AtAnInsideCornerWithPieces_TheStripsStopAtEachOthersEdge() + { + using var with = new Surface(FourPieces, LShape()); + Assert.That(with.sections.BuildConforming(with.input, with.indices, with.positions), Is.True); + using var without = new Surface(LShape()); + Assert.That(without.sections.BuildConforming(without.input, without.indices, without.positions), Is.True); + // beside the square the strips are as they were + foreach (var p in new[] { new double2(2.001, 0.95), new double2(2.3, 0.9), new double2(1.95, 1.001), new double2(1.9, 1.4) }) + AreEqual(without.Pixels(p.x, p.y), with.Pixels(p.x, p.y), $"at {p}"); + // inside it, without pieces, the strips' (the tile's right half) + Assert.That(without.Pixels(2 - 0.01, 1 - 0.005).x, Is.GreaterThanOrEqualTo(1024)); + Assert.That(with.Pixels(2 - 0.01, 1 - 0.005).x, Is.LessThan(64)); + } + + [Test] + public void InsideCornerPieces_AreNeverMatched() + { + using var l = new Surface(FourPieces, LShape()); + Assert.That(l.tiles.Length, Is.EqualTo(2)); + Assert.That(l.tiles[0].insideMax, Is.EqualTo(new float2(64, 2048))); + Assert.That(l.tiles[0].insideMin, Is.EqualTo(new float2(0, 1984))); + Assert.That(l.tiles[0].insideOne, Is.False); + // a 0.25 square fits the pieces best by shape and size, yet takes B1 + using var small = new Surface(FourPieces, Rectangle(0.25f, 0.25f)); + Assert.That(small.input.t1.x, Is.GreaterThanOrEqualTo(1024)); + } + + // A torus's small faces are all border and their strips meet on one line: no NaN points where it crosses itself + [TestCase(0.31f, 0.29f, 0.13f)] + [TestCase(0.2f, 0.18f, 0.2f)] + [TestCase(0.123f, 0.117f, 0.071f)] + [TestCase(0.4f, 0.4f, 0.2f)] + public void ASmallQuadThatIsAllBorder_IsMappedWithoutNaN(float foot, float top, float height) + { + var inset = (foot - top) * 0.5f; + using var quad = new Surface(32, new float2(0, 0), new float2(foot, 0), new float2(foot - inset, height), + new float2(0, 0), new float2(foot - inset, height), new float2(inset, height)); + Assert.That(quad.sections.BuildConforming(quad.input, quad.indices, quad.positions), Is.True); + using var render = Map(quad); + for (int i = 0; i < render.Length; i++) + { + Assert.That(math.all(math.isfinite(render[i].position)), Is.True, $"vertex {i}: {render[i].position}"); + Assert.That(math.all(math.isfinite(render[i].uv0)), Is.True, $"vertex {i}'s uv: {render[i].uv0}"); + } + } + + [Test] + public void AWallThinnerThanTwoBorders_IsAllBorder() + { + // 32 pixels is an eighth of a unit: the strips along the top and bottom meet 0.1 in, 256 pixels a unit across + using var wall = new Surface(32, Rectangle(4, 0.2f)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions), Is.True); + AreEqual(new float2(1024 + 12.8f, 1536 + 12.8f), wall.Pixels(0.05, 0.05), "inside the corner, unstretched"); + AreEqual(new float2(1536, 1536 + 12.8f), wall.Pixels(2, 0.05), "the bottom strip"); + AreEqual(new float2(1536, 2048 - 12.8f), wall.Pixels(2, 0.15), "the top strip"); + AssertAllBorder(wall, p => true, 4, 0.2); + + // not a rectangle, and too thin to conform: all border as well + using var sliver = new Surface(32, new float2(0, 0), new float2(4, 0), new float2(3.9f, 0.2f), new float2(0, 0), new float2(3.9f, 0.2f), new float2(0.1f, 0.2f)); + Assert.That(sliver.sections.BuildConforming(sliver.input, sliver.indices, sliver.positions), Is.True); + AssertAllBorder(sliver, p => p.x > p.y * 0.5 && p.x < 4 - p.y * 0.5, 4, 0.2); + } + + // Every uv inside is a number and lies within the border's depth of B1's edges + static void AssertAllBorder(Surface surface, System.Func inside, double width, double height) + { + for (double x = 0.0031; x < width; x += width / 97) + { + for (double y = 0.0017; y < height; y += height / 31) + { + if (!inside(new double2(x, y))) + continue; + var pixel = surface.Pixels(x, y); + Assert.That(math.all(math.isfinite(pixel)), Is.True, $"at ({x}, {y}): {pixel}"); + var depth = math.cmin(math.min(pixel - new float2(1024, 1536), new float2(2048, 2048) - pixel)); + Assert.That(depth, Is.LessThanOrEqualTo(32 + 1e-3f), $"({x}, {y}) is in the tile's interior: {pixel} px"); + } + } + } + + static void AreEqual(float2 expected, float2 actual, string what) + { + Assert.That(math.distance(expected, actual), Is.LessThan(1e-3f), $"{what}: expected {expected} px, got {actual} px"); + } + + [Test] + public void TheOutline_IsTheLoopsOfWhatTheSurfaceDraws() + { + using var door = new Surface(Doorway()); + using var corners = new NativeList(Allocator.Temp); + using var loops = new NativeList(Allocator.Temp); + HotspotSections.OutlineOf(door.indices, door.positions, new float3(1, 0, 0), new float3(0, 1, 0), corners, loops); + Assert.That(loops.Length, Is.EqualTo(1)); + Assert.That(loops[0].y, Is.EqualTo(8), "the doorway's U has 8 corners"); + + using var window = new Surface(Window()); + HotspotSections.OutlineOf(window.indices, window.positions, new float3(1, 0, 0), new float3(0, 1, 0), corners, loops); + Assert.That(loops.Length, Is.EqualTo(2), "the wall and its hole"); + Assert.That(loops[0].y + loops[1].y, Is.EqualTo(8)); + } + + // A 4 x 2 wall whose bottom right corner the CSG left cut off by a tenth of a millimetre (as where a capsule's side + // meets its cap) + static float2[] WallWithATinyChamfer() => Fan(new float2(0, 0), new float2(4 - 1e-4f, 0), new float2(4, 1e-4f), new float2(4, 2), new float2(0, 2)); + + [Test] + public void ACornerCutOffByLessThanAPixel_IsNoCornerOfTheOutline() + { + using var wall = new Surface(WallWithATinyChamfer()); + using var corners = new NativeList(Allocator.Temp); + using var loops = new NativeList(Allocator.Temp); + HotspotSections.OutlineOf(wall.indices, wall.positions, new float3(1, 0, 0), new float3(0, 1, 0), default, corners, loops, default, 0.25 / 256); + Assert.That(loops.Length == 1 && loops[0].y == 4, Is.True, $"four corners: {loops.Length} loops, {(loops.Length > 0 ? loops[0].y : 0)} corners"); + } + + [Test] + public void AWallThinnerThanTwoBorders_WithItsTopAFloatStepOff_HasNoSliverWhereItsStripsMeet() + { + // the strips along the top and bottom meet in the middle, their lines a float step apart (as on a capsule) + using var wall = new Surface(32, new float2(0, 0), new float2(4, 0), new float2(4, 0.2000001f), new float2(0, 0), new float2(4, 0.2000001f), new float2(0, 0.2f)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions), Is.True); + using var render = Map(wall); + for (int t = 0; t < wall.indices.Length; t += 3) + { + var a = wall.positions[wall.indices[t]]; var b = wall.positions[wall.indices[t + 1]]; var c = wall.positions[wall.indices[t + 2]]; + var longest = math.max(math.distance(a, b), math.max(math.distance(b, c), math.distance(c, a))); + var height = math.length(math.cross(b - a, c - a)) / longest; + Assert.That(height, Is.GreaterThan(1e-5f), $"triangle {t / 3}: {a} {b} {c}"); + } + } + + static HotspotSectionInput SideInput(int side, HotspotBorderMode mode, float maxStretch = 0, HotspotTileFlags flags = HotspotTileFlags.None) + { + var input = new HotspotSectionInput { flags = flags }; + input.sideMode[side] = (int)mode; + input.sideStretch[side] = maxStretch; + return input; + } + + [Test] + public void AStripAlongASideThatRepeats_TakesWholeRepeatsStretchedToFit() + { + Assert.That(HotspotSections.StripRepeats(SideInput(1, HotspotBorderMode.Repeat), 1, 9, 4, out var step), Is.EqualTo(2)); + Assert.That(step, Is.EqualTo(4.5).Within(1e-9)); + // the other sides as the tile's flags say: stretched + Assert.That(HotspotSections.StripRepeats(SideInput(1, HotspotBorderMode.Repeat), 0, 9, 4, out step), Is.EqualTo(1)); + Assert.That(step, Is.EqualTo(9).Within(1e-9)); + } + + [Test] + public void AStripAlongASideThatRepeatsPartially_KeepsItsSize_AndTheLastIsCutOff() + { + Assert.That(HotspotSections.StripRepeats(SideInput(2, HotspotBorderMode.RepeatPartial), 2, 9, 4, out var step), Is.EqualTo(3)); + Assert.That(step, Is.EqualTo(4).Within(1e-9)); + } + + [Test] + public void AStripAlongASideThatStretches_RepeatsTheFewestTimes_WhenItWouldStretchTooFar() + { + // 9 over a strip of 4 is 2.25 times: within 3 it stretches, beyond 1.5 it takes 2 repeats of 4.5 + Assert.That(HotspotSections.StripRepeats(SideInput(0, HotspotBorderMode.Stretch, 3), 0, 9, 4, out var step), Is.EqualTo(1)); + Assert.That(step, Is.EqualTo(9).Within(1e-9)); + Assert.That(HotspotSections.StripRepeats(SideInput(0, HotspotBorderMode.Stretch, 1.5f), 0, 9, 4, out step), Is.EqualTo(2)); + Assert.That(step, Is.EqualTo(4.5).Within(1e-9)); + // a side that follows the tile's flags: they make it repeat + Assert.That(HotspotSections.StripRepeats(SideInput(0, HotspotBorderMode.Tile, 0, HotspotTileFlags.RepeatBorder), 0, 9, 4, out step), Is.EqualTo(2)); + } + + [Test] + public void AStripThatWouldRepeatMoreThanItsBudget_Stretches() + { + var input = SideInput(0, HotspotBorderMode.Repeat); + Assert.That(HotspotSections.StripRepeats(input, 0, 9, 2, out _), Is.EqualTo(4), "within the hard limit: 4 repeats"); + input.maxRepeats = 3; + Assert.That(HotspotSections.StripRepeats(input, 0, 9, 2, out var step), Is.EqualTo(1), "over a budget of 3: stretched"); + Assert.That(step, Is.EqualTo(9).Within(1e-9)); + } + + [Test] + public void ThePadding_KeepsTheInteriorAndItsRepeatsOffTheBorder() + { + // a padding of 2: the tile is 1026..2046, its border 30 deep (30 / 256 units on the surface) keeps its inner + // edge at 1056..2016, and the interior samples 1058..2014, 956 pixels: 3.734375 units a repeat + const double band = 30.0 / 256; + using var wall = new Surface(32, 2, Rectangle(8, 2)); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions), Is.True); + Assert.That(wall.Pixels(band + 1e-6, 1).x, Is.EqualTo(1058).Within(1e-2f), "the interior starts the padding in"); + Assert.That(wall.Pixels(band - 1e-6, 1).x, Is.EqualTo(1056).Within(1e-2f), "the strip still ends at the border's inner edge"); + using var repeats = HotspotSections.Create(Allocator.Temp); + Assert.That(repeats.BuildInteriorRepeats(wall.input), Is.True); + Assert.That((repeats.UV(new double2(band + 1e-6, 1)) * 2048).x, Is.EqualTo(1058).Within(1e-2f), "a repeat starts the padding in"); + Assert.That((repeats.UV(new double2(band + 3.734375 - 1e-6, 1)) * 2048).x, Is.EqualTo(2014).Within(1e-2f), "and ends the padding short"); + Assert.That((repeats.UV(new double2(band + 3.734375 + 1e-6, 1)) * 2048).x, Is.EqualTo(1058).Within(1e-2f), "where the next starts again"); + for (double y = 0.13; y < 1.87; y += 0.05) + { + var v = (repeats.UV(new double2(4, y)) * 2048).y; + Assert.That(2048 - v, Is.InRange(34 - 1e-2, 478 + 1e-2), "never in the border's rows"); + } + } + + [Test] + public void TheInteriorPadding_IsItsOwn_AndNoneByDefault() + { + // no padding along the tile's edges, 2 pixels where the interior meets the border: 1058..2014 + using (var inner = new Surface(32, 0, 2, Rectangle(8, 2))) + { + using var repeats = HotspotSections.Create(Allocator.Temp); + Assert.That(repeats.BuildInteriorRepeats(inner.input), Is.True); + Assert.That((repeats.UV(new double2(0.125 + 1e-6, 1)) * 2048).x, Is.EqualTo(1058).Within(1e-2f), "the interior padded"); + } + // 2 pixels along the tile's edges only: the interior reaches the border's inner edge, 1056 + using (var outer = new Surface(32, 2, 0, Rectangle(8, 2))) + { + using var repeats = HotspotSections.Create(Allocator.Temp); + Assert.That(repeats.BuildInteriorRepeats(outer.input), Is.True); + Assert.That((repeats.UV(new double2(30.0 / 256 + 1e-6, 1)) * 2048).x, Is.EqualTo(1056).Within(1e-2f), "the interior not padded"); + } + var fresh = ScriptableObject.CreateInstance(); + Assert.That(fresh.interiorPadding, Is.Zero, "none by default"); + Object.DestroyImmediate(fresh); + } + + [Test] + public void AnInteriorThatWouldRepeatMoreThanItsBudget_IsNotRepeated() + { + using var wall = new Surface(Rectangle(8, 2)); + using var repeats = HotspotSections.Create(Allocator.Temp); + // B1's inside is 3.75 units a repeat: an 8 x 2 wall takes 3 of them + Assert.That(repeats.BuildInteriorRepeats(wall.input), Is.True); + Assert.That(repeats.cells.Length, Is.EqualTo(3)); + var input = wall.input; + input.maxRepeats = 2; + Assert.That(repeats.BuildInteriorRepeats(input), Is.False, "over a budget of 2"); + } + + [Test] + public void ATilesSideRules_FollowTheTile_WhereverTheSectionsLayItsSides() + { + var layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + // points from the top left, clockwise as the texture shows them: side 0 is the top, side 1 the right + var sides = new HotspotBorderSide[4]; + sides[0].mode = HotspotBorderMode.Repeat; + sides[1].mode = HotspotBorderMode.RepeatPartial; + layout.tiles = new List { new() { id = 1, name = "B1", border = 32, sides = sides, + points = new[] { new Vector2Int(1024, 0), new Vector2Int(2048, 0), new Vector2Int(2048, 512), new Vector2Int(1024, 512) } } }; + using var tiles = new NativeList(Allocator.Temp); + HotspotMatcher.AddTiles(layout, tiles, out var data); + using var positions = new NativeArray(new[] { new float3(0, 0, 0), new float3(4, 0, 0), new float3(4, 2, 0), new float3(0, 2, 0) }, Allocator.Temp); + var shape = HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + var choice = HotspotMatcher.Choose(tiles.AsArray(), data, shape, 0, 0); + var input = HotspotSections.Input(tiles[0], data, shape, choice, new float3(0, 0, -1)); + // as the sections lay a tile that is not turned: bottom, right, top, left + Assert.That((HotspotBorderMode)input.sideMode[2], Is.EqualTo(HotspotBorderMode.Repeat), "the top"); + Assert.That((HotspotBorderMode)input.sideMode[1], Is.EqualTo(HotspotBorderMode.RepeatPartial), "the right"); + Assert.That((HotspotBorderMode)input.sideMode[0], Is.EqualTo(HotspotBorderMode.Tile), "the bottom"); + Assert.That((HotspotBorderMode)input.sideMode[3], Is.EqualTo(HotspotBorderMode.Tile), "the left"); + // turned a quarter, the tile's top lies along another of the sections' sides + choice.rotated = true; + var turned = HotspotSections.Input(tiles[0], data, shape, choice, new float3(0, 0, -1)); + var repeating = 0; + for (int j = 0; j < 4; j++) + repeating += (HotspotBorderMode)turned.sideMode[j] == HotspotBorderMode.Repeat ? 1 : 0; + Assert.That(repeating, Is.EqualTo(1), "one side still repeats"); + Assert.That((HotspotBorderMode)turned.sideMode[2], Is.Not.EqualTo(HotspotBorderMode.Repeat), "but not the one at the top of the sections"); + Object.DestroyImmediate(layout); + } + + [Test] + public void ABorderedWall_WithItsTopSideRepeating_HasTheTopStripInRepeats() + { + using var plain = new Surface(Rectangle(8, 2)); + Assert.That(plain.sections.BuildConforming(plain.input, plain.indices, plain.positions), Is.True); + using var wall = new Surface(Rectangle(8, 2)); + var input = wall.input; + input.sideMode[2] = (int)HotspotBorderMode.Repeat; // the top, as the sections lay it + Assert.That(wall.sections.BuildConforming(input, wall.indices, wall.positions), Is.True); + Assert.That(wall.sections.cells.Length, Is.GreaterThan(plain.sections.cells.Length), "more sections: the top strip's repeats"); + // the bottom strip still stretches: one section + Assert.That(wall.Pixels(4, 0.0625).y, Is.EqualTo(plain.Pixels(4, 0.0625).y).Within(1e-3f)); + } + + [Test] + public void AHexagonOnAShapedTile_HasItsBorderMeasuredFromTheTilesOwnSides() + { + var layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + // the hexagon tile at (1024, 0) from the top left: 512 x 444, a 32 pixel border + layout.tiles = new List { new() { id = 1, name = "Hex", border = 32, points = new[] { + new Vector2Int(1152, 0), new Vector2Int(1408, 0), new Vector2Int(1536, 222), + new Vector2Int(1408, 444), new Vector2Int(1152, 444), new Vector2Int(1024, 222) } } }; + using var tiles = new NativeList(Allocator.Temp); + using var tileSides = new NativeList(Allocator.Temp); + HotspotMatcher.AddTiles(layout, tiles, out var data, tileSides); + Assert.That(tileSides.Length, Is.EqualTo(6)); + using var hexagon = new Surface(32, Fan(new float2(0.5f, 0), new float2(1.5f, 0), new float2(2, 0.867f), new float2(1.5f, 1.734f), new float2(0.5f, 1.734f), new float2(0, 0.867f))); + var shape = HotspotMatcher.ShapeOf(hexagon.positions.AsArray(), new float3(0, 0, -1)); + HotspotMatcher.MaskOf(ref shape, hexagon.positions.AsArray(), hexagon.indices.AsArray(), new float3(1, 0, 0), new float3(0, 1, 0)); + var choice = HotspotMatcher.Choose(tiles.AsArray(), data, shape, 0, 0); + Assert.That(choice.tile, Is.EqualTo(0)); + var input = HotspotSections.Input(tiles[0], data, shape, choice, new float3(0, 0, -1)); + Assert.That(input.sideCount, Is.EqualTo(6)); + Assert.That(hexagon.sections.BuildConforming(input, hexagon.indices, hexagon.positions, default, tileSides.AsArray()), Is.True); + // 16 pixels in from the middle of the lower right edge: 16 pixels from the tile's lower right side + var a = new double2(1.5, 0); var b = new double2(2, 0.867); + var d = math.normalize(b - a); var n = new double2(-d.y, d.x); + var p = (a + b) * 0.5 + n * 0.0625; + var pixel = (double2)hexagon.sections.UV(p) * 2048; + // the tile's lower right side, y up: from (1408, 2048 - 444) to (1536, 2048 - 222) + var ta = new double2(1408, 2048 - 444); var tb = new double2(1536, 2048 - 222); + var td = math.normalize(tb - ta); var tn = new double2(-td.y, td.x); + Assert.That(math.dot(pixel - ta, tn), Is.EqualTo(16).Within(1e-2), $"{pixel} px: 16 in from the tile's own side"); + var along = math.dot(pixel - ta, td) / math.length(tb - ta); + Assert.That(along, Is.InRange(0.0, 1.0), "along that side"); + Object.DestroyImmediate(layout); + } + + [Test] + public void ARectangle_HasTheBorderItsBoundsHave() + { + using var wall = new Surface(Rectangle(8, 2)); + using var bounds = HotspotSections.Create(Allocator.Temp); + Assert.That(bounds.Build(wall.input), Is.True); + Assert.That(wall.sections.BuildConforming(wall.input, wall.indices, wall.positions), Is.True); + for (double x = 0.013; x < 8; x += 0.171) + { + for (double y = 0.007; y < 2; y += 0.0613) + { + var p = new double2(x, y); + Assert.That(math.distance(bounds.UV(p), wall.sections.UV(p)), Is.LessThan(1e-6f), $"at {p}"); + } + } + } + + [Test] + public void ADoorway_HasABorderAlongItsEdges() + { + using var door = new Surface(Doorway()); + Assert.That(door.sections.BuildConforming(door.input, door.indices, door.positions), Is.True); + // B1 is 1024..2048 x 1536..2048 with y up; the wall's outside sides as before + AreEqual(new float2(1024 + 16, door.Pixels(0.0625, 1.5).y), door.Pixels(0.0625, 1.5), "the wall's left side"); + // the doorway's left edge faces right: the tile's right side, 16 pixels in + AreEqual(new float2(2048 - 16, door.Pixels(1, 1).y), door.Pixels(2.4 - 0.0625, 1), "the doorway's left edge"); + // its right edge faces left + AreEqual(new float2(1024 + 16, door.Pixels(5, 1).y), door.Pixels(3.6 + 0.0625, 1), "the doorway's right edge"); + // its top faces down + AreEqual(new float2(door.Pixels(3, 2.8).x, 1536 + 16), door.Pixels(3, 2.3 + 0.0625), "the doorway's top"); + // where the doorway meets the floor: the tile's bottom right corner + AreEqual(new float2(2048 - 16, 1536 + 16), door.Pixels(2.4 - 0.0625, 0.0625), "the corner beside the doorway"); + // away from every edge: the interior, as the bounds map it + using var bounds = HotspotSections.Create(Allocator.Temp); + bounds.Build(door.input); + AreEqual(bounds.UV(new double2(1, 1.5)) * 2048, door.Pixels(1, 1.5), "the interior"); + } + + [Test] + public void AWindow_HasABorderAroundItsHole() + { + using var window = new Surface(Window()); + Assert.That(window.sections.BuildConforming(window.input, window.indices, window.positions), Is.True); + AreEqual(new float2(1024 + 16, window.Pixels(5, 1.5).y), window.Pixels(3.75 + 0.0625, 1.5), "right of the window: the tile's left side"); + AreEqual(new float2(2048 - 16, window.Pixels(1, 1.5).y), window.Pixels(2.25 - 0.0625, 1.5), "left of it: its right side"); + AreEqual(new float2(window.Pixels(3, 0.5).x, 2048 - 16), window.Pixels(3, 1 - 0.0625), "below it: its top"); + AreEqual(new float2(window.Pixels(3, 2.5).x, 1536 + 16), window.Pixels(3, 2 + 0.0625), "above it: its bottom"); + } + + [Test] + public void ADoorway_IsCutSoEachTriangleLiesInOneSection() + { + using var door = new Surface(Doorway()); + Assert.That(door.sections.BuildConforming(door.input, door.indices, door.positions), Is.True); + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in door.positions) + { + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1) }); + } + var right = new float3(1, 0, 0); var up = new float3(0, 1, 0); + door.sections.Split(right, up, new float3(0, 0, -1), door.indices, door.positions, select, render); + door.sections.AssignUVs(door.indices, door.positions, select, render, right, up); + var area = 0.0; + for (int t = 0; t < door.indices.Length; t += 3) + { + var a = (double3)door.positions[door.indices[t]]; var b = (double3)door.positions[door.indices[t + 1]]; var c = (double3)door.positions[door.indices[t + 2]]; + area += ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) * 0.5; + var cell = door.sections.Locate(((a + b + c) / 3).xy); + foreach (var i in new[] { door.indices[t], door.indices[t + 1], door.indices[t + 2] }) + { + var expected = door.sections.UV(cell, ((double3)render[i].position).xy); + Assert.That(math.distance(render[i].uv0, expected), Is.LessThan(1e-5f), $"triangle {t / 3} is not mapped by its section"); + } + } + Assert.That(area, Is.EqualTo(6 * 3 - 1.2 * 2.3).Within(1e-4), "the pieces cover the wall once"); + } + + // Cut once (CutOnce) and by Split, on two copies: triangles of each, uvs assigned + static int CutBoth(System.Func make, out Surface once) + { + using var split = make(); + Assert.That(split.sections.BuildConforming(split.input, split.indices, split.positions), Is.True); + var right = new float3(1, 0, 0); var up = new float3(0, 1, 0); + using (var select = new NativeList(Allocator.Temp)) + using (var render = new NativeList(Allocator.Temp)) + { + foreach (var p in split.positions) { select.Add(new SelectVertex { position = p }); render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1) }); } + split.sections.Split(right, up, new float3(0, 0, -1), split.indices, split.positions, select, render); + } + once = make(); + Assert.That(once.sections.BuildConforming(once.input, once.indices, once.positions), Is.True); + using (var select = new NativeList(Allocator.Temp)) + using (var render = new NativeList(Allocator.Temp)) + { + foreach (var p in once.positions) { select.Add(new SelectVertex { position = p }); render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1) }); } + Assert.That(once.sections.CutOnce(right, up, new float3(0, 0, -1), once.indices, once.positions, select, render), Is.True, "not cut once"); + } + return split.indices.Length / 3; + } + + // every triangle lies in one section: its corners are all in the section its middle is in + static void AssertEachTriangleInOneSection(Surface surface) + { + for (int t = 0; t < surface.indices.Length; t += 3) + { + var a = (double3)surface.positions[surface.indices[t]]; var b = (double3)surface.positions[surface.indices[t + 1]]; var c = (double3)surface.positions[surface.indices[t + 2]]; + var cell = surface.sections.cells[surface.sections.Locate(((a + b + c) / 3).xy)]; + for (int h = cell.halfPlaneStart; h < cell.halfPlaneStart + cell.halfPlaneCount; h++) + { + var line = surface.sections.lines[surface.sections.halfPlanes[h].line]; var side = surface.sections.halfPlanes[h].side; + foreach (var p in new[] { a.xy, b.xy, c.xy }) + Assert.That(side * line.Value(p), Is.GreaterThanOrEqualTo(-1e-6), $"triangle {t / 3} lies across sections"); + } + } + } + + [Test] + public void ADoorway_IsCutOnce_WithFewerTrianglesThanTriangleByTriangle() + { + var bySplit = CutBoth(() => new Surface(Doorway()), out var door); + using (door) + { + Assert.That(door.indices.Length / 3, Is.LessThan(bySplit), "no fewer triangles than cutting triangle by triangle"); + HotspotBorderTests.AssertWatertight(door.indices, door.positions, 6 * 3 - 1.2 * 2.3, + OnOutline(new float2(0, 0), new float2(2.4f, 0), new float2(2.4f, 2.3f), new float2(3.6f, 2.3f), new float2(3.6f, 0), new float2(6, 0), new float2(6, 3), new float2(0, 3))); + AssertEachTriangleInOneSection(door); + } + } + + [Test] + public void AWindow_IsCutOnce_WithFewerTrianglesThanTriangleByTriangle() + { + var bySplit = CutBoth(() => new Surface(Window()), out var window); + using (window) + { + Assert.That(window.indices.Length / 3, Is.LessThan(bySplit), "no fewer triangles than cutting triangle by triangle"); + var outer = OnOutline(new float2(0, 0), new float2(6, 0), new float2(6, 3), new float2(0, 3)); + var hole = OnOutline(new float2(2.25f, 1), new float2(3.75f, 1), new float2(3.75f, 2), new float2(2.25f, 2)); + HotspotBorderTests.AssertWatertight(window.indices, window.positions, 6 * 3 - 1.5, p => outer(p) || hole(p)); + AssertEachTriangleInOneSection(window); + } + } + } +} diff --git a/Core/Tests/HotspotConformingTests.cs.meta b/Core/Tests/HotspotConformingTests.cs.meta new file mode 100644 index 00000000..de835997 --- /dev/null +++ b/Core/Tests/HotspotConformingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 53aa7c3e5c61dbc4fb5578a98054b22a \ No newline at end of file diff --git a/Core/Tests/HotspotCutCacheTests.cs b/Core/Tests/HotspotCutCacheTests.cs new file mode 100644 index 00000000..e73ddbe9 --- /dev/null +++ b/Core/Tests/HotspotCutCacheTests.cs @@ -0,0 +1,224 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + // What the hotspot cut made of a brush is kept until the brush's inputs change (OutputHotspotSections.CutModel, cache) + [TestFixture] + public class HotspotCutCacheTests + { + ChiselHotspotLayout layout; + NativeList tiles; + NativeArray layouts; + NativeParallelHashMap cache; + readonly List> buffers = new(); + + [SetUp] + public void SetUp() + { + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List + { + new() { id = 1, name = "A1", points = new[] { new Vector2Int(0, 0), new Vector2Int(512, 0), new Vector2Int(512, 512), new Vector2Int(0, 512) } }, + new() { id = 2, name = "B1", points = new[] { new Vector2Int(1024, 0), new Vector2Int(2048, 0), new Vector2Int(2048, 512), new Vector2Int(1024, 512) } }, + }; + tiles = new NativeList(Allocator.Temp); + HotspotMatcher.AddTiles(layout, tiles, out var data); + layouts = new NativeArray(new[] { data }, Allocator.Temp); + cache = new NativeParallelHashMap(8, Allocator.Temp); + } + + [TearDown] + public void TearDown() + { + foreach (var entry in cache) + if (entry.Value.result.IsCreated) entry.Value.result.Dispose(); + cache.Dispose(); + foreach (var buffer in buffers) + if (buffer.IsCreated) buffer.Dispose(); + buffers.Clear(); + tiles.Dispose(); + layouts.Dispose(); + Object.DestroyImmediate(layout); + } + + // A brush's render buffer with one square surface, x from x0 to x0 + 2 and y from 0 to 2, facing -z + BlobAssetReference Square(float x0, float density = 1) + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var surfaces = builder.Allocate(ref root.surfaces, 1); + builder.Allocate(ref root.querySurfaces, 0); + using var indices = new NativeList(Allocator.Temp) { 0, 2, 1, 0, 3, 2 }; + using var collider = new NativeList(Allocator.Temp); + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in new[] { new float3(x0, 0, 0), new float3(x0 + 2, 0, 0), new float3(x0 + 2, 2, 0), new float3(x0, 2, 0) }) + { + collider.Add(p); + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1), tangent = new float4(1, 0, 0, -1) }); + } + surfaces[0].Store(builder, indices, collider, select, render); + surfaces[0].hotspotSource = new HotspotSurfaceSource { layout = 1, right = new float3(1, 0, 0), up = new float3(0, 1, 0), normal = new float3(0, 0, -1), density = density }; + root.surfaceCount = 1; + var buffer = builder.CreateBlobAssetReference(Allocator.Persistent); + builder.Dispose(); + buffers.Add(buffer); + return buffer; + } + + // The same square without a hotspot, so it is not cut + BlobAssetReference Plain(float x0) + { + var buffer = Square(x0); + buffer.Value.surfaces[0].hotspotSource = default; + return buffer; + } + + static CompactNodeID BrushID(int b) => new CompactNodeID(default, new SlotIndex { index = b + 1, generation = 1 }); + + NativeList Brushes(params BlobAssetReference[] squares) + { + var brushes = new NativeList(Allocator.Temp); + for (int b = 0; b < squares.Length; b++) + brushes.Add(new BrushData { brushRenderBuffer = squares[b], brushSurfaceCount = 1, + brushIndexOrder = new IndexOrder { compactNodeID = BrushID(b), nodeOrder = b } }); + return brushes; + } + + // The buffers the cut hands on, one per brush; the ones it made without keeping them are ours to free + BlobAssetReference[] Run(bool cached, params BlobAssetReference[] squares) + { + using var brushes = Brushes(squares); + using var patched = new NativeList>(Allocator.Temp); + if (cached) + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, default, default, default, default, cache); + else + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, default); + foreach (var buffer in patched) buffers.Add(buffer); + var result = new BlobAssetReference[squares.Length]; + for (int b = 0; b < squares.Length; b++) + result[b] = brushes[b].brushRenderBuffer; + return result; + } + + // Where the buffers are, taken right away: a buffer the cache replaced is freed + static unsafe long[] Ptrs(BlobAssetReference[] buffers) + { + var ptrs = new long[buffers.Length]; + for (int i = 0; i < ptrs.Length; i++) + ptrs[i] = (long)buffers[i].GetUnsafePtr(); + return ptrs; + } + + static float2[] UVs(BlobAssetReference buffer) + { + ref var surface = ref buffer.Value.surfaces[0]; + var uvs = new float2[surface.renderVertices.Length]; + for (int i = 0; i < uvs.Length; i++) + uvs[i] = surface.renderVertices[i].uv0; + return uvs; + } + + void BorderOnB1(int pixels) + { + var b1 = layout.tiles[1]; + b1.border = pixels; + layout.tiles[1] = b1; + tiles.Clear(); + HotspotMatcher.AddTiles(layout, tiles, out var data); + layouts[0] = data; + } + + [Test] + public void TheSameInputs_GiveTheSameBufferAgain() + { + var squares = new[] { Square(-2), Square(0) }; + var first = Ptrs(Run(true, squares)); + var second = Ptrs(Run(true, squares)); + Assert.That(cache.Count(), Is.EqualTo(2)); + Assert.That(second, Is.EqualTo(first), "cut again"); + } + + [Test] + public void WhatIsKept_IsWhatTheCutMakesWithoutACache() + { + var squares = new[] { Square(-2), Square(0) }; + var plain = Run(false, squares); + var kept = Run(true, squares); + var again = Run(true, squares); + for (int b = 0; b < 2; b++) + { + Assert.That(UVs(kept[b]), Is.EqualTo(UVs(plain[b])), $"brush {b} the first time"); + Assert.That(UVs(again[b]), Is.EqualTo(UVs(plain[b])), $"brush {b} reused"); + } + } + + [Test] + public void ABrushThatChanged_IsCutAgainWithItsGroupAndNotTheOtherGroups() + { + var first = Ptrs(Run(true, Square(-2), Square(0), Square(10), Square(12))); + var second = Ptrs(Run(true, Square(-2, 2), buffers[1], buffers[2], buffers[3])); + Assert.That(second[0], Is.Not.EqualTo(first[0]), "the changed one"); + Assert.That(second[1], Is.Not.EqualTo(first[1]), "its neighbour, mapped with it"); + Assert.That(second[2], Is.EqualTo(first[2]), "the other group"); + Assert.That(second[3], Is.EqualTo(first[3]), "the other group"); + Assert.That(cache.Count(), Is.EqualTo(4)); + } + + [Test] + public void ALayoutThatChanged_CutsEveryBrushAgain() + { + var squares = new[] { Square(-2), Square(0) }; + var first = Ptrs(Run(true, squares)); + BorderOnB1(32); + var secondBuffers = Run(true, squares); + var second = Ptrs(secondBuffers); + var plain = Run(false, squares); + for (int b = 0; b < 2; b++) + { + Assert.That(second[b], Is.Not.EqualTo(first[b]), $"brush {b}"); + Assert.That(UVs(secondBuffers[b]), Is.EqualTo(UVs(plain[b])), $"brush {b} matches an uncached cut"); + } + } + + [Test] + public void ABrushThatIsGone_LeavesNothingKept() + { + var squares = new[] { Square(-2), Square(0) }; + Run(true, squares); + Assert.That(cache.Count(), Is.EqualTo(2)); + Run(true, squares[0], Plain(5)); + Assert.That(cache.Count(), Is.Zero, "a lone square is not cut, and the other brush is gone"); + } + + [Test] + public unsafe void ABrushThatIsNotCutAnyMore_LeavesNothingKept() + { + Run(true, Square(-2), Square(0)); + Assert.That(cache.Count(), Is.EqualTo(2)); + var plain = Plain(0); + var result = Run(true, buffers[0], plain); + Assert.That(cache.Count(), Is.Zero, "nothing left to group"); + Assert.That((long)result[1].GetUnsafePtr(), Is.EqualTo((long)plain.GetUnsafePtr()), "handed on as it was"); + } + + [Test] + public void KeptBuffers_AreNotAlsoHandedBackToBeFreed() + { + using var brushes = Brushes(Square(-2), Square(0)); + using var patched = new NativeList>(Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, default, default, default, default, cache); + Assert.That(patched.Length, Is.Zero, "the cache owns them"); + Assert.That(cache.Count(), Is.EqualTo(2)); + } + } +} diff --git a/Core/Tests/HotspotCutCacheTests.cs.meta b/Core/Tests/HotspotCutCacheTests.cs.meta new file mode 100644 index 00000000..a8d3d8fd --- /dev/null +++ b/Core/Tests/HotspotCutCacheTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e4f649e051ad44a68bfeafa15d74d28e diff --git a/Core/Tests/HotspotDiagnosticsTests.cs b/Core/Tests/HotspotDiagnosticsTests.cs new file mode 100644 index 00000000..ce7860a9 --- /dev/null +++ b/Core/Tests/HotspotDiagnosticsTests.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class HotspotDiagnosticsTests + { + // a 2048 texture at 256 pixels a unit: a unit is an eighth of the texture + static readonly float2 kTexture = new float2(2048, 2048); + const float kPixelsPerUnit = 256; + + // The uv of a point of a plane through the origin with these axes, at the tile's density + static float2 UV(float3 p, float3 right, float3 up, float2 scale) => new float2(math.dot(p, right), math.dot(p, up)) * scale / 8; + + static void Measure(float3 a, float3 b, float3 c, float3 right, float3 up, float2 scale, out double s1, out double s2, out bool mirrored) + { + Assert.That(HotspotDiagnostics.Measure(a, b, c, UV(a, right, up, scale), UV(b, right, up, scale), UV(c, right, up, scale), + kTexture, kPixelsPerUnit, out s1, out s2, out mirrored), Is.True); + } + + [Test] + public void ATriangleAtItsTilesDensity_IsUndistorted_OnAnyPlane() + { + var right = math.normalize(new float3(1, -1, 0)); + var up = math.normalize(new float3(1, 1, -2)); + var a = right * 0.3f + up * 0.1f; var b = right * 1.7f + up * 0.4f; var c = right * 0.9f + up * 1.3f; + Measure(a, b, c, right, up, new float2(1), out var s1, out var s2, out _); + Assert.That(s1, Is.EqualTo(1).Within(1e-5)); + Assert.That(s2, Is.EqualTo(1).Within(1e-5)); + Assert.That(HotspotDiagnostics.Energy(s1, s2), Is.EqualTo(1).Within(1e-5)); + } + + [Test] + public void ATriangleStretchedAcross_HasHalfTheDensityThatWay() + { + var right = new float3(1, 0, 0); var up = new float3(0, 1, 0); + Measure(new float3(0, 0, 0), new float3(1.3f, 0, 0), new float3(0.2f, 0.7f, 0), right, up, new float2(0.5f, 1), out var s1, out var s2, out _); + Assert.That(s1, Is.EqualTo(1).Within(1e-5)); + Assert.That(s2, Is.EqualTo(0.5).Within(1e-5)); + // (1 + 1 + 1/4 + 4) / 4 + Assert.That(HotspotDiagnostics.Energy(s1, s2), Is.EqualTo(1.5625).Within(1e-5)); + } + + [Test] + public void AMirroredTexture_IsMirrored_AndAsDistortedAsUnmirrored() + { + var right = new float3(1, 0, 0); var up = new float3(0, 1, 0); + var a = new float3(0, 0, 0); var b = new float3(1.3f, 0, 0); var c = new float3(0.2f, 0.7f, 0); + Measure(a, b, c, right, up, new float2(1, 2), out var s1, out var s2, out var mirrored); + Measure(a, b, c, right, up, new float2(-1, 2), out var m1, out var m2, out var mirroredToo); + Assert.That(mirroredToo, Is.Not.EqualTo(mirrored)); + Assert.That(m1, Is.EqualTo(s1).Within(1e-6)); + Assert.That(m2, Is.EqualTo(s2).Within(1e-6)); + } + + [Test] + public void ATriangleWithoutArea_IsNotMeasured() + { + Assert.That(HotspotDiagnostics.Measure(new float3(0, 0, 0), new float3(1, 0, 0), new float3(2, 0, 0), float2.zero, new float2(1, 0), new float2(0, 1), + kTexture, kPixelsPerUnit, out _, out _, out _), Is.False); + } + + [Test] + public void TheRecord_SaysWhatMatchingChose() + { + var layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List { new() { id = 7, name = "B1", border = 32, pixelsPerUnit = 128, + points = new[] { new Vector2Int(1024, 0), new Vector2Int(2048, 0), new Vector2Int(2048, 512), new Vector2Int(1024, 512) } } }; + using var tiles = new NativeList(Allocator.Temp); + HotspotMatcher.AddTiles(layout, tiles, out var data); + try + { + foreach (var (width, height, pinned, lastResort, allBorder) in new[] { (8f, 4f, false, false, false), (8f, 0.3f, true, false, true), (400f, 12f, false, true, false) }) + { + using var positions = new NativeArray(new[] { new float3(0, 0, 0), new float3(width, 0, 0), new float3(width, height, 0), new float3(0, height, 0) }, Allocator.Temp); + var shape = HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + var choice = HotspotMatcher.Choose(tiles.AsArray(), data, shape, 0, pinned ? 7 : 0); + var input = HotspotSections.Input(tiles[choice.tile], data, shape, choice, new float3(0, 0, -1)); + var info = HotspotDiagnostics.Info(tiles[choice.tile], data, choice, input, pinned); + var what = $"{width} x {height}"; + Assert.That(info.IsMapped, Is.True, what); + Assert.That(info.tileId, Is.EqualTo(7), what); + Assert.That(info.pixelsPerUnit, Is.EqualTo(128), what); + Assert.That(info.textureSize, Is.EqualTo(kTexture), what); + Assert.That((info.flags & HotspotSurfaceFlags.Pinned) != 0, Is.EqualTo(pinned), what + ": pinned"); + Assert.That((info.flags & HotspotSurfaceFlags.LastResort) != 0, Is.EqualTo(lastResort), what + ": last resort"); + Assert.That((info.flags & HotspotSurfaceFlags.Repeated) != 0, Is.EqualTo(lastResort), what + ": repeated"); + Assert.That((info.flags & HotspotSurfaceFlags.AllBorder) != 0, Is.EqualTo(allBorder), what + ": all border"); + } + Assert.That(default(HotspotSurfaceInfo).IsMapped, Is.False); + } + finally + { + Object.DestroyImmediate(layout); + } + } + } +} diff --git a/Core/Tests/HotspotDiagnosticsTests.cs.meta b/Core/Tests/HotspotDiagnosticsTests.cs.meta new file mode 100644 index 00000000..0a5d320b --- /dev/null +++ b/Core/Tests/HotspotDiagnosticsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e2286f4864f26d438628888c1a5d5b1 \ No newline at end of file diff --git a/Core/Tests/HotspotGroupsIncrementalTests.cs b/Core/Tests/HotspotGroupsIncrementalTests.cs new file mode 100644 index 00000000..c11ddc14 --- /dev/null +++ b/Core/Tests/HotspotGroupsIncrementalTests.cs @@ -0,0 +1,344 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + // HotspotGroups.PrepareIncremental groups again only what an edit can have changed; whatever it groups again or keeps, + // its result must be Prepare's, bit for bit (IncrementalHotspotGroups.md) + [TestFixture] + public class HotspotGroupsIncrementalTests + { + ChiselHotspotLayout layout; + NativeList tiles; + NativeArray layouts; + HotspotGroupMemory memory; + readonly List> buffers = new(); + // each brush as the last Update had it: one the same again is kept (HotspotStageMemory.keptKinds) + readonly Dictionary last = new(); + + // A brush of the test model: its id stays while it is edited + class Brush + { + public int id; + public float3[] corners; + public float3 normal, right; + // no hotspot layout: not a candidate + public bool plain; + } + + [SetUp] + public void SetUp() + { + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List + { + new() { id = 1, name = "A1", points = new[] { new Vector2Int(0, 0), new Vector2Int(512, 0), new Vector2Int(512, 512), new Vector2Int(0, 512) } }, + new() { id = 2, name = "B1", points = new[] { new Vector2Int(1024, 0), new Vector2Int(2048, 0), new Vector2Int(2048, 512), new Vector2Int(1024, 512) } }, + }; + tiles = new NativeList(Allocator.Persistent); + HotspotMatcher.AddTiles(layout, tiles, out var data); + layouts = new NativeArray(new[] { data }, Allocator.Persistent); + memory = HotspotGroupMemory.Create(Allocator.Persistent); + } + + [TearDown] + public void TearDown() + { + foreach (var buffer in buffers) + if (buffer.IsCreated) buffer.Dispose(); + buffers.Clear(); + last.Clear(); + memory.Dispose(); + tiles.Dispose(); + layouts.Dispose(); + Object.DestroyImmediate(layout); + } + + static CompactNodeID BrushID(int id) => new CompactNodeID(default, new SlotIndex { index = id + 1, generation = 1 }); + + BlobAssetReference Buffer(Brush brush) + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var surfaces = builder.Allocate(ref root.surfaces, 1); + builder.Allocate(ref root.querySurfaces, 0); + using var indices = new NativeList(Allocator.Temp) { 0, 2, 1, 0, 3, 2 }; + using var collider = new NativeList(Allocator.Temp); + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in brush.corners) + { + collider.Add(p); + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = brush.normal, tangent = new float4(brush.right, -1) }); + } + surfaces[0].Store(builder, indices, collider, select, render); + surfaces[0].hotspotSource = new HotspotSurfaceSource { layout = brush.plain ? 0 : 1, right = brush.right, up = new float3(0, 1, 0), normal = brush.normal, density = 1 }; + root.surfaceCount = 1; + var buffer = builder.CreateBlobAssetReference(Allocator.Persistent); + builder.Dispose(); + buffers.Add(buffer); + return buffer; + } + + // A square x from x0 to x0 + width and y from 0 to 2, facing -z (a sliver when width is under a texel) + static Brush Square(int id, float x0, float width = 2, float3 right = default) + { + return new Brush + { + id = id, normal = new float3(0, 0, -1), right = math.all(right == 0) ? new float3(1, 0, 0) : right, + corners = new[] { new float3(x0, 0, 0), new float3(x0 + width, 0, 0), new float3(x0 + width, 2, 0), new float3(x0, 2, 0) } + }; + } + + // A square round the corner at x: in the plane x, z from 0 to 2, facing -x + static Brush Side(int id, float x) + { + return new Brush + { + id = id, normal = new float3(-1, 0, 0), right = new float3(0, 0, 1), + corners = new[] { new float3(x, 0, 2), new float3(x, 0, 0), new float3(x, 2, 0), new float3(x, 2, 2) } + }; + } + + static HotspotLink Link(int a, int b, int order, bool connect) => + new() { brushA = BrushID(a), surfaceA = 0, brushB = BrushID(b), surfaceB = 0, order = order, connection = connect ? 1 : -1 }; + + // The model grouped both ways; asserts they are the same and returns what the incremental one did + (bool full, int dirty, int groups) Update(List model, HotspotLink[] links = null, string step = "") + { + using var brushes = new NativeList(Allocator.Temp); + for (int b = 0; b < model.Count; b++) + brushes.Add(new BrushData { brushRenderBuffer = Buffer(model[b]), brushSurfaceCount = 1, + brushIndexOrder = new IndexOrder { compactNodeID = BrushID(model[b].id), nodeOrder = b } }); + var keptList = new bool[model.Count]; + for (int b = 0; b < model.Count; b++) + keptList[b] = last.TryGetValue(model[b].id, out var was) && Same(was, model[b]); + using var kept = new NativeArray(keptList, Allocator.Temp); + last.Clear(); + foreach (var brush in model) + last[brush.id] = brush; + using var linkArray = new NativeArray(links ?? new HotspotLink[0], Allocator.Temp); + var full = FindState.Create(Allocator.Temp); + var incremental = FindState.Create(Allocator.Temp); + try + { + HotspotGroups.Prepare(brushes, tiles.AsArray(), layouts, linkArray, ref full); + HotspotGroups.PrepareIncremental(brushes, tiles.AsArray(), layouts, linkArray, ref incremental, ref memory, kept); + Assert.That(HotspotGroups.SameAs(incremental, full, out var difference), Is.True, $"{step}: {difference}"); + return (memory.lastFull.Value, memory.lastDirty.Value, full.leads.Length); + } + finally + { + full.Dispose(); + incremental.Dispose(); + } + } + + static bool Same(Brush a, Brush b) + { + if (!a.normal.Equals(b.normal) || !a.right.Equals(b.right) || a.plain != b.plain || a.corners.Length != b.corners.Length) + return false; + for (int i = 0; i < a.corners.Length; i++) + { + if (!a.corners[i].Equals(b.corners[i])) + return false; + } + return true; + } + + // Two rows of three squares, far apart: two groups + static List TwoRows() => new() + { + Square(0, 0), Square(1, 2), Square(2, 4), + Square(3, 20), Square(4, 22), Square(5, 24) + }; + + // Squares far from the rest and from each other: enough of the model stays clean that it is grouped incrementally + static List WithFarSquares(List model, int firstId) + { + for (int i = 0; i < 10; i++) + model.Add(Square(firstId + i, 100 + i * 3)); + return model; + } + + [Test] + public void TheSameModelAgain_GroupsNothingAgain() + { + var model = TwoRows(); + Assert.That(Update(model).full, Is.True, "nothing kept the first time"); + var again = Update(model, step: "again"); + Assert.That(again.full, Is.False); + Assert.That(again.dirty, Is.Zero); + Assert.That(again.groups, Is.EqualTo(2)); + } + + [Test] + public void MovingASquareOutOfItsRow_GroupsOnlyItsRowAgain() + { + var model = TwoRows(); + Update(model); + model[2] = Square(2, 6.5f); + var moved = Update(model, step: "moved away"); + Assert.That(moved.full, Is.False, "grouped incrementally"); + Assert.That(moved.dirty, Is.EqualTo(3), "the row it was in"); + model[2] = Square(2, 4); + var back = Update(model, step: "moved back"); + Assert.That(back.full, Is.False); + Assert.That(back.groups, Is.EqualTo(2)); + } + + [Test] + public void AddingAndRemovingSquares_MatchesTheFullGrouping() + { + var model = WithFarSquares(TwoRows(), 100); + Update(model); + model.Add(Square(6, 26)); + Assert.That(Update(model, step: "added").full, Is.False); + model.RemoveAt(4); + Assert.That(Update(model, step: "removed from the middle of a row").full, Is.False); + model.RemoveAt(0); + Assert.That(Update(model, step: "removed the first").full, Is.False); + } + + // A brush that stays, with a surface that is not a candidate any more: its kept candidate is gone + [Test] + public void ASquareThatLosesItsLayout_LeavesItsRow() + { + var model = WithFarSquares(TwoRows(), 100); + Update(model); + var plain = Square(1, 2); + plain.plain = true; + model[1] = plain; + Assert.That(Update(model, step: "the middle square lost its layout").full, Is.False); + model[1] = Square(1, 2); + Assert.That(Update(model, step: "and has it again").full, Is.False); + } + + [Test] + public void ASliverBesideASquare_FollowsItsNeighbour() + { + var model = WithFarSquares(new List { Square(0, 0), Square(1, 2, width: 0.001f), Square(2, 2.001f), Square(3, 20), Square(4, 22) }, 100); + Update(model); + model[2] = Square(2, 2.001f, right: new float3(-1, 0, 0)); + Assert.That(Update(model, step: "the sliver's other neighbour turned").full, Is.False); + model[0] = Square(0, -0.5f); + Assert.That(Update(model, step: "its first neighbour moved away").full, Is.False); + model[0] = Square(0, 0); + Assert.That(Update(model, step: "and back").full, Is.False); + } + + [Test] + public void AFoldedPair_KeepsItsUnfold() + { + var model = new List { Square(0, -2), Side(1, 0), Square(2, 20), Square(3, 22) }; + var links = new[] { Link(0, 1, 1, true) }; + Update(model, links); + model[3] = Square(3, 23); + var moved = Update(model, links, "the other pair moved apart"); + Assert.That(moved.full, Is.False); + Assert.That(moved.dirty, Is.EqualTo(2)); + } + + [Test] + public void ALinkEdit_GroupsEverythingAgain() + { + var model = TwoRows(); + Update(model); + Assert.That(Update(model, new[] { Link(0, 1, 1, false) }, "linked").full, Is.True); + } + + [Test] + public void BrushesInAnotherOrder_GroupEverythingAgain() + { + var model = TwoRows(); + Update(model); + (model[0], model[1]) = (model[1], model[0]); + Assert.That(Update(model, step: "reordered").full, Is.True); + } + + // Random edits on a strip of squares, a fold and slivers: whatever is grouped again, the result is the full one's + [Test] + public void RandomEdits_AlwaysMatchTheFullGrouping([Values(1, 2, 3, 4)] int seed) + { + var random = new Unity.Mathematics.Random((uint)seed * 7919u); + var model = new List(); + var nextId = 0; + for (int i = 0; i < 12; i++) + model.Add(Square(nextId++, i * 2)); + model.Add(Side(nextId++, 24)); + // slivers between two squares: each follows the neighbour it shares its longest edge with + for (int i = 0; i < 3; i++) + { + var x = 40 + i * 10; + model.Add(Square(nextId++, x)); + model.Add(Square(nextId++, x + 2, width: 0.001f)); + // in another frame: the sliver is only grouped with it when its other neighbour is gone + model.Add(Square(nextId++, x + 2.001f, right: new float3(-1, 0, 0))); + } + for (int i = 0; i < 10; i++) + model.Add(Square(nextId++, 200 + i * 3)); + Update(model); + int incremental = 0; + for (int step = 0; step < 200; step++) + { + var what = random.NextInt(7); + var at = random.NextInt(model.Count); + if (what == 6) + { + // nudge a square beside a sliver away from it, or back + var x = 40 + random.NextInt(3) * 10 + (random.NextBool() ? 0 : 2.001f); + for (int k = 0; k < model.Count; k++) + { + if (model[k].corners[0].x == x || model[k].corners[0].x == x - 0.5f) + { + var width = model[k].corners[1].x - model[k].corners[0].x; + model[k] = Square(model[k].id, model[k].corners[0].x == x ? x - 0.5f : x, width, model[k].right); + break; + } + } + if (Update(model, step: $"seed {seed} step {step}: nudged beside a sliver").full == false) + incremental++; + continue; + } + string description; + switch (what) + { + case 0: // move a square along the strip, touching other squares or not + model[at] = Square(model[at].id, random.NextInt(0, 34) * 1.0f); + description = $"moved {model[at].id}"; + break; + case 1: // turn its frame + model[at] = Square(model[at].id, model[at].corners[0].x, model[at].corners[1].x - model[at].corners[0].x, random.NextBool() ? new float3(1, 0, 0) : new float3(-1, 0, 0)); + description = $"turned {model[at].id}"; + break; + case 2: // make it a sliver, or not + model[at] = Square(model[at].id, model[at].corners[0].x, random.NextBool() ? 0.001f : 2); + description = $"resized {model[at].id}"; + break; + case 3: // remove one + if (model.Count <= 3) { description = "nothing"; break; } + description = $"removed {model[at].id}"; + model.RemoveAt(at); + break; + case 4: // add one, after the others + model.Add(Square(nextId++, random.NextInt(0, 34) * 1.0f)); + description = $"added {nextId - 1}"; + break; + default: // nothing + description = "nothing"; + break; + } + if (Update(model, step: $"seed {seed} step {step}: {description}").full == false) + incremental++; + } + Assert.That(incremental, Is.GreaterThan(100), "most steps were grouped incrementally"); + } + } +} diff --git a/Core/Tests/HotspotGroupsIncrementalTests.cs.meta b/Core/Tests/HotspotGroupsIncrementalTests.cs.meta new file mode 100644 index 00000000..1fa10090 --- /dev/null +++ b/Core/Tests/HotspotGroupsIncrementalTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 43e0fb2ad6cd88844a0473cbfbc5f48f \ No newline at end of file diff --git a/Core/Tests/HotspotGroupsTests.cs b/Core/Tests/HotspotGroupsTests.cs new file mode 100644 index 00000000..88ef06c2 --- /dev/null +++ b/Core/Tests/HotspotGroupsTests.cs @@ -0,0 +1,494 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + // Coplanar neighbouring surfaces of different brushes mapped as one (HotspotGroups, OutputHotspotSections.CutModel) + [TestFixture] + public class HotspotGroupsTests + { + // A1 (512 x 512 at the top left) and B1 (1024 x 512 at the top right) of a 2048 sheet, 256 pixels a unit + ChiselHotspotLayout layout; + NativeList tiles; + NativeArray layouts; + readonly List> buffers = new(); + + [SetUp] + public void SetUp() + { + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List + { + new() { id = 1, name = "A1", points = new[] { new Vector2Int(0, 0), new Vector2Int(512, 0), new Vector2Int(512, 512), new Vector2Int(0, 512) } }, + new() { id = 2, name = "B1", points = new[] { new Vector2Int(1024, 0), new Vector2Int(2048, 0), new Vector2Int(2048, 512), new Vector2Int(1024, 512) } }, + }; + tiles = new NativeList(Allocator.Temp); + HotspotMatcher.AddTiles(layout, tiles, out var data); + layouts = new NativeArray(new[] { data }, Allocator.Temp); + } + + [TearDown] + public void TearDown() + { + foreach (var buffer in buffers) + if (buffer.IsCreated) buffer.Dispose(); + buffers.Clear(); + tiles.Dispose(); + layouts.Dispose(); + Object.DestroyImmediate(layout); + } + + // A brush's render buffer with one quad surface (corners counter-clockwise seen from in front) + BlobAssetReference Quad(float3 normal, float3 right, params float3[] corners) + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var surfaces = builder.Allocate(ref root.surfaces, 1); + builder.Allocate(ref root.querySurfaces, 0); + using var indices = new NativeList(Allocator.Temp) { 0, 2, 1, 0, 3, 2 }; + using var collider = new NativeList(Allocator.Temp); + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in corners) + { + collider.Add(p); + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = normal, tangent = new float4(right, -1) }); + } + surfaces[0].Store(builder, indices, collider, select, render); + surfaces[0].hotspotSource = new HotspotSurfaceSource { layout = 1, right = right, up = new float3(0, 1, 0), normal = normal, density = 1 }; + root.surfaceCount = 1; + var buffer = builder.CreateBlobAssetReference(Allocator.Persistent); + builder.Dispose(); + buffers.Add(buffer); + return buffer; + } + + // A brush's render buffer with one square surface, x from x0 to x0 + 2 and y from 0 to 2, facing -z + BlobAssetReference Square(float x0, float3 right) + { + var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var surfaces = builder.Allocate(ref root.surfaces, 1); + builder.Allocate(ref root.querySurfaces, 0); + using var indices = new NativeList(Allocator.Temp) { 0, 2, 1, 0, 3, 2 }; + using var collider = new NativeList(Allocator.Temp); + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in new[] { new float3(x0, 0, 0), new float3(x0 + 2, 0, 0), new float3(x0 + 2, 2, 0), new float3(x0, 2, 0) }) + { + collider.Add(p); + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1), tangent = new float4(1, 0, 0, -1) }); + } + surfaces[0].Store(builder, indices, collider, select, render); + surfaces[0].hotspotSource = new HotspotSurfaceSource { layout = 1, right = right, up = new float3(0, 1, 0), normal = new float3(0, 0, -1), density = 1 }; + root.surfaceCount = 1; + var buffer = builder.CreateBlobAssetReference(Allocator.Persistent); + builder.Dispose(); + buffers.Add(buffer); + return buffer; + } + + static CompactNodeID BrushID(int b) => new CompactNodeID(default, new SlotIndex { index = b + 1, generation = 1 }); + + NativeList Brushes(params BlobAssetReference[] squares) + { + var brushes = new NativeList(Allocator.Temp); + for (int b = 0; b < squares.Length; b++) + brushes.Add(new BrushData { brushRenderBuffer = squares[b], brushSurfaceCount = 1, + brushIndexOrder = new IndexOrder { compactNodeID = BrushID(b), nodeOrder = b } }); + return brushes; + } + + int GroupsOf(NativeList brushes, params HotspotLink[] links) + { + var groupOf = new UnsafeHashMap(4, Allocator.Temp); + var unfoldOf = new UnsafeHashMap(4, Allocator.Temp); + using var groups = new NativeList(Allocator.Temp); + using var lines = new NativeList(Allocator.Temp); + using var halfPlanes = new NativeList(Allocator.Temp); + using var cells = new NativeList(Allocator.Temp); + using var linkArray = new NativeArray(links, Allocator.Temp); + HotspotGroups.Find(brushes, tiles.AsArray(), layouts, linkArray, default, ref groupOf, ref unfoldOf, groups, lines, halfPlanes, cells); + groupOf.Dispose(); + unfoldOf.Dispose(); + return groups.Length; + } + + static HotspotLink Link(int order, bool connect) => + new() { brushA = BrushID(0), surfaceA = 0, brushB = BrushID(1), surfaceB = 0, order = order, connection = connect ? 1 : -1 }; + + // B1 with a border this many pixels wide + void BorderOnB1(int pixels) + { + var b1 = layout.tiles[1]; + b1.border = pixels; + layout.tiles[1] = b1; + tiles.Clear(); + HotspotMatcher.AddTiles(layout, tiles, out var data); + layouts[0] = data; + } + + static List UVsAt(ref ChiselSurfaceRenderBuffer surface, float3 position) + { + var uvs = new List(); + for (int i = 0; i < surface.renderVertices.Length; i++) + if (math.distance(surface.renderVertices[i].position, position) < 1e-5f) + uvs.Add(surface.renderVertices[i].uv0); + return uvs; + } + + [Test] + public void TwoCoplanarSquares_WithTheBorderShownByHand_HaveABorderWhereTheyMeet() + { + BorderOnB1(32); + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0))); + using var patched = new NativeList>(Allocator.Temp); + using var links = new NativeArray(new[] { new HotspotLink { brushA = BrushID(0), brushB = BrushID(1), order = 1, border = 1 } }, Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, links); + foreach (var buffer in patched) buffers.Add(buffer); + // still one 4 x 2 surface on B1, but where they meet the left one has the tile's right side and the right one its + // left side (without the link both have the interior there, u 0.75) + var left = UVsAt(ref brushes[0].brushRenderBuffer.Value.surfaces[0], new float3(0, 0.125f, 0)); + var right = UVsAt(ref brushes[1].brushRenderBuffer.Value.surfaces[0], new float3(0, 0.125f, 0)); + Assert.That(left.Count, Is.GreaterThan(0), "the left one has a vertex there"); + Assert.That(right.Count, Is.GreaterThan(0), "the right one has a vertex there"); + foreach (var uv in left) + Assert.That(uv.x, Is.EqualTo(1).Within(1e-5f), $"the left one: {uv}"); + foreach (var uv in right) + Assert.That(uv.x, Is.EqualTo(0.5f).Within(1e-5f), $"the right one: {uv}"); + } + + [Test] + public void TheBrushesOwnBuffers_AreToldTheGroupTheirSurfacesWereMappedWith() + { + BorderOnB1(32); + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0)), Square(5, new float3(1, 0, 0))); + using var patched = new NativeList>(Allocator.Temp); + var own = new BlobAssetReference[brushes.Length]; + for (int b = 0; b < brushes.Length; b++) + own[b] = brushes[b].brushRenderBuffer; + using var kept = new NativeArray>(own, Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, default, default, kept); + foreach (var buffer in patched) buffers.Add(buffer); + var a = kept[0].Value.surfaces[0].hotspot.group; + Assert.That(a, Is.GreaterThan(0), "the touching squares are a group"); + Assert.That(kept[1].Value.surfaces[0].hotspot.group, Is.EqualTo(a), "the same one"); + Assert.That(kept[2].Value.surfaces[0].hotspot.group, Is.Zero, "the one apart is by itself"); + } + + [Test] + public void TwoCoplanarSquares_WithoutALink_HaveNoBorderWhereTheyMeet() + { + BorderOnB1(32); + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0))); + using var patched = new NativeList>(Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, default); + foreach (var buffer in patched) buffers.Add(buffer); + foreach (var uv in UVsAt(ref brushes[0].brushRenderBuffer.Value.surfaces[0], new float3(0, 1, 0))) + Assert.That(uv.x, Is.EqualTo(0.75f).Within(1e-5f), $"the left one: {uv}"); + } + + [Test] + public void TwoSquaresRoundACorner_ConnectedByHand_HaveNoBorderAlongTheFold() + { + BorderOnB1(32); + var front = Quad(new float3(0, 0, -1), new float3(1, 0, 0), new float3(-2, 0, 0), new float3(0, 0, 0), new float3(0, 2, 0), new float3(-2, 2, 0)); + var side = Quad(new float3(1, 0, 0), new float3(0, 0, 1), new float3(0, 0, 0), new float3(0, 0, 2), new float3(0, 2, 2), new float3(0, 2, 0)); + using var brushes = Brushes(front, side); + using var patched = new NativeList>(Allocator.Temp); + using var links = new NativeArray(new[] { Link(1, true) }, Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, links); + foreach (var buffer in patched) buffers.Add(buffer); + // along the fold, halfway up, both faces have the interior's u (the middle of B1), not a side of the tile + foreach (var uv in UVsAt(ref brushes[0].brushRenderBuffer.Value.surfaces[0], new float3(0, 0.125f, 0))) + Assert.That(uv.x, Is.EqualTo(0.75f).Within(1e-4f), $"the front: {uv}"); + foreach (var uv in UVsAt(ref brushes[1].brushRenderBuffer.Value.surfaces[0], new float3(0, 0.125f, 0))) + Assert.That(uv.x, Is.EqualTo(0.75f).Within(1e-4f), $"the side: {uv}"); + } + + [Test] + public void TwoCoplanarSquaresInDifferentFrames_ConnectedByHand_AreOneGroup() + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(-1, 0, 0))); + Assert.That(GroupsOf(brushes, Link(1, true)), Is.EqualTo(1)); + } + + [Test] + public void TwoCoplanarSquaresSharingAnEdge_DisconnectedByHand_AreNoGroup() + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0))); + Assert.That(GroupsOf(brushes, Link(1, false)), Is.EqualTo(0)); + } + + [Test] + public void TheNewestLink_Counts() + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0))); + Assert.That(GroupsOf(brushes, Link(1, true), Link(2, false)), Is.EqualTo(0), "disconnected last"); + Assert.That(GroupsOf(brushes, Link(2, true), Link(1, false)), Is.EqualTo(1), "connected last"); + } + + [Test] + public void TwoSquaresRoundACorner_ConnectedByHand_WrapOneTileRoundIt() + { + // a box's front (x -2..0, facing -z) and its right side (z 0..2, facing +x), sharing the edge x = 0, z = 0 + var front = Quad(new float3(0, 0, -1), new float3(1, 0, 0), new float3(-2, 0, 0), new float3(0, 0, 0), new float3(0, 2, 0), new float3(-2, 2, 0)); + var side = Quad(new float3(1, 0, 0), new float3(0, 0, 1), new float3(0, 0, 0), new float3(0, 0, 2), new float3(0, 2, 2), new float3(0, 2, 0)); + using var brushes = Brushes(front, side); + using var patched = new NativeList>(Allocator.Temp); + using var links = new NativeArray(new[] { Link(1, true) }, Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, links); + foreach (var buffer in patched) buffers.Add(buffer); + // unfolded, 4 x 2: B1, u 0.5..1, v 0.75..1; the side continues where the front ends + AssertUV(new float2(0.5f, 0.75f), UVAt(ref brushes[0].brushRenderBuffer.Value.surfaces[0], new float3(-2, 0, 0)), "the front's bottom left"); + AssertUV(new float2(0.75f, 0.75f), UVAt(ref brushes[0].brushRenderBuffer.Value.surfaces[0], new float3(0, 0, 0)), "the front's bottom right"); + AssertUV(new float2(0.75f, 0.75f), UVAt(ref brushes[1].brushRenderBuffer.Value.surfaces[0], new float3(0, 0, 0)), "the side's bottom left"); + AssertUV(new float2(1, 1), UVAt(ref brushes[1].brushRenderBuffer.Value.surfaces[0], new float3(0, 2, 2)), "the side's top right"); + // and its corners where they were + ref var sideSurface = ref brushes[1].brushRenderBuffer.Value.surfaces[0]; + for (int v = 0; v < sideSurface.renderVertices.Length; v++) + Assert.That(sideSurface.renderVertices[v].position.x, Is.EqualTo(0), $"vertex {v} stays on the side"); + } + + // A box's four sides, each 1 x 2, connected round it by hand: front, right, back, left + static readonly float3[][] kRing = + { + new[] { new float3(-0.5f, 0, -0.5f), new float3(0.5f, 0, -0.5f), new float3(0.5f, 2, -0.5f), new float3(-0.5f, 2, -0.5f) }, + new[] { new float3(0.5f, 0, -0.5f), new float3(0.5f, 0, 0.5f), new float3(0.5f, 2, 0.5f), new float3(0.5f, 2, -0.5f) }, + new[] { new float3(0.5f, 0, 0.5f), new float3(-0.5f, 0, 0.5f), new float3(-0.5f, 2, 0.5f), new float3(0.5f, 2, 0.5f) }, + new[] { new float3(-0.5f, 0, 0.5f), new float3(-0.5f, 0, -0.5f), new float3(-0.5f, 2, -0.5f), new float3(-0.5f, 2, 0.5f) }, + }; + static readonly float3[] kRingNormals = { new(0, 0, -1), new(1, 0, 0), new(0, 0, 1), new(-1, 0, 0) }; + static readonly float3[] kRingRights = { new(1, 0, 0), new(0, 0, 1), new(-1, 0, 0), new(0, 0, -1) }; + + [TestCase(0)] + [TestCase(2)] + public void ARingOfConnectedSurfaces_KeepsItsSeamAtTheOldestLink(int oldest) + { + var sides = new BlobAssetReference[4]; + for (int i = 0; i < 4; i++) + sides[i] = Quad(kRingNormals[i], kRingRights[i], kRing[i]); + using var brushes = Brushes(sides); + // side i and the next share edge i; the oldest link is edge oldest's + var links = new HotspotLink[4]; + for (int e = 0, order = 2; e < 4; e++) + links[e] = new HotspotLink { brushA = BrushID(e), brushB = BrushID((e + 1) % 4), order = e == oldest ? 1 : order++, connection = 1 }; + using var linkArray = new NativeArray(links, Allocator.Temp); + using var patched = new NativeList>(Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, linkArray); + foreach (var buffer in patched) buffers.Add(buffer); + for (int e = 0; e < 4; e++) + { + // edge e runs up from the second corner of side e (the first of side e + 1) + var corner = kRing[e][1]; + var a = UVAt(ref brushes[e].brushRenderBuffer.Value.surfaces[0], corner); + var b = UVAt(ref brushes[(e + 1) % 4].brushRenderBuffer.Value.surfaces[0], corner); + if (e == oldest) + Assert.That(math.distance(a, b), Is.GreaterThan(1e-3f), $"edge {e}, the oldest link: the seam ({a} and {b})"); + else + Assert.That(math.distance(a, b), Is.LessThan(1e-5f), $"edge {e}: continuous ({a} and {b})"); + } + } + + [Test] + public void TwoSquaresRoundACorner_ByThemselves_AreNoGroup() + { + var front = Quad(new float3(0, 0, -1), new float3(1, 0, 0), new float3(-2, 0, 0), new float3(0, 0, 0), new float3(0, 2, 0), new float3(-2, 2, 0)); + var side = Quad(new float3(1, 0, 0), new float3(0, 0, 1), new float3(0, 0, 0), new float3(0, 0, 2), new float3(0, 2, 2), new float3(0, 2, 0)); + using var brushes = Brushes(front, side); + Assert.That(GroupsOf(brushes), Is.EqualTo(0)); + } + + [Test] + public void ALinkBetweenSquaresThatDoNotTouch_DoesNothing() + { + using var brushes = Brushes(Square(-3, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0))); + Assert.That(GroupsOf(brushes, Link(1, true)), Is.EqualTo(0)); + } + + static float2 UVAt(ref ChiselSurfaceRenderBuffer surface, float3 position) + { + for (int i = 0; i < surface.renderVertices.Length; i++) + if (math.distance(surface.renderVertices[i].position, position) < 1e-5f) + return surface.renderVertices[i].uv0; + Assert.Fail($"no vertex at {position}"); + return default; + } + + [Test] + public void TwoCoplanarSquaresSharingAnEdge_AreOneGroup() + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0))); + var groupOf = new UnsafeHashMap(4, Allocator.Temp); + var unfoldOf = new UnsafeHashMap(4, Allocator.Temp); + using var groups = new NativeList(Allocator.Temp); + using var lines = new NativeList(Allocator.Temp); + using var halfPlanes = new NativeList(Allocator.Temp); + using var cells = new NativeList(Allocator.Temp); + HotspotGroups.Find(brushes, tiles.AsArray(), layouts, default, default, ref groupOf, ref unfoldOf, groups, lines, halfPlanes, cells); + Assert.That(groups.Length, Is.EqualTo(1), "groups"); + Assert.That(groupOf.ContainsKey(new int2(0, 0)) && groupOf.ContainsKey(new int2(1, 0)), Is.True, "both in it"); + Assert.That(unfoldOf.Count, Is.EqualTo(0), "coplanar: nothing unfolded"); + groupOf.Dispose(); + unfoldOf.Dispose(); + } + + [Test] + public void TwoCoplanarSquaresSharingAnEdge_TakeOneTileAcrossBoth() + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0))); + using var patched = new NativeList>(Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, default); + foreach (var buffer in patched) buffers.Add(buffer); + // 4 x 2 together: B1, u 0.5..1, v 0.75..1 + AssertUV(new float2(0.5f, 0.75f), UVAt(ref brushes[0].brushRenderBuffer.Value.surfaces[0], new float3(-2, 0, 0)), "bottom left"); + AssertUV(new float2(0.75f, 0.75f), UVAt(ref brushes[0].brushRenderBuffer.Value.surfaces[0], new float3(0, 0, 0)), "the left's bottom right"); + AssertUV(new float2(0.75f, 0.75f), UVAt(ref brushes[1].brushRenderBuffer.Value.surfaces[0], new float3(0, 0, 0)), "the right's bottom left"); + AssertUV(new float2(1, 1), UVAt(ref brushes[1].brushRenderBuffer.Value.surfaces[0], new float3(2, 2, 0)), "top right"); + } + + [Test] + public void TwoCoplanarSquaresSharingAnEdge_AreMarkedAsOneGroupForTheDiagnostics() + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0)), Square(4, new float3(1, 0, 0))); + using var patched = new NativeList>(Allocator.Temp); + OutputHotspotSections.CutModel(brushes, patched, tiles.AsArray(), layouts, default); + foreach (var buffer in patched) buffers.Add(buffer); + var left = brushes[0].brushRenderBuffer.Value.surfaces[0].hotspot.group; + var right = brushes[1].brushRenderBuffer.Value.surfaces[0].hotspot.group; + var apart = brushes[2].brushRenderBuffer.Value.surfaces[0].hotspot.group; + Assert.That(left, Is.GreaterThan(0), "in a group"); + Assert.That(right, Is.EqualTo(left), "the same one"); + Assert.That(apart, Is.EqualTo(0), "the one that touches neither is by itself"); + } + + [Test] + public void TwoCoplanarSquaresInDifferentFrames_AreNotConnected() + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(-1, 0, 0))); + var groupOf = new UnsafeHashMap(4, Allocator.Temp); + var unfoldOf = new UnsafeHashMap(4, Allocator.Temp); + using var groups = new NativeList(Allocator.Temp); + using var lines = new NativeList(Allocator.Temp); + using var halfPlanes = new NativeList(Allocator.Temp); + using var cells = new NativeList(Allocator.Temp); + HotspotGroups.Find(brushes, tiles.AsArray(), layouts, default, default, ref groupOf, ref unfoldOf, groups, lines, halfPlanes, cells); + Assert.That(groups.Length, Is.EqualTo(0)); + groupOf.Dispose(); + unfoldOf.Dispose(); + } + + static void AssertUV(float2 expected, float2 actual, string what) + { + Assert.That(math.distance(expected, actual), Is.LessThan(1e-5f), $"{what}: expected {expected}, got {actual}"); + } + + // The model's groups found and matched with a cache, as the pipeline's jobs do; what each match was made from + (int groups, int hits, int misses, float2 uv) MatchWithCache(NativeList brushes, NativeParallelHashMap cache) + { + var state = FindState.Create(Allocator.Temp); + var groupOf = new UnsafeHashMap(4, Allocator.Temp); + var unfoldOf = new UnsafeHashMap(4, Allocator.Temp); + var bordersOf = new UnsafeParallelMultiHashMap(4, Allocator.Temp); + using var groups = new NativeList(Allocator.Temp); + using var lines = new NativeList(Allocator.Temp); + using var halfPlanes = new NativeList(Allocator.Temp); + using var cells = new NativeList(Allocator.Temp); + using var links = new NativeArray(0, Allocator.Temp); + try + { + HotspotGroups.Prepare(brushes, tiles.AsArray(), layouts, links, ref state); + var inputs = HotspotGroups.InputsKey(tiles.AsArray(), layouts, default); + int hits = 0, misses = 0; + for (int i = 0; i < state.matches.Length; i++) + { + var match = HotspotGroups.Match(state.leads[i], brushes.AsArray(), tiles.AsArray(), layouts, default, bordersOf, state, cache, inputs); + if (match.owned) misses++; else hits++; + state.matches[i] = match; + } + HotspotGroups.Assemble(ref state, ref groupOf, ref unfoldOf, groups, lines, halfPlanes, cells, cache); + var uv = groups.Length > 0 ? new float2(groups[0].uvMatrix.c3.x, groups[0].uvMatrix.c3.y) : float2.zero; + return (groups.Length, hits, misses, uv); + } + finally + { + state.Dispose(); groupOf.Dispose(); unfoldOf.Dispose(); bordersOf.Dispose(); + } + } + + static void DisposeCache(NativeParallelHashMap cache) + { + foreach (var entry in cache) + entry.Value.match.Dispose(); + cache.Dispose(); + } + + [Test] + public void AGroupMatchedAgain_WithTheSameInputs_IsTakenFromTheCache() + { + var cache = new NativeParallelHashMap(4, Allocator.Temp); + try + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0)), Square(10, new float3(1, 0, 0)), Square(12, new float3(1, 0, 0))); + var first = MatchWithCache(brushes, cache); + Assert.That((first.groups, first.hits, first.misses), Is.EqualTo((2, 0, 2)), "the first time every group is matched"); + Assert.That(cache.Count(), Is.EqualTo(2)); + var again = MatchWithCache(brushes, cache); + Assert.That((again.groups, again.hits, again.misses), Is.EqualTo((2, 2, 0)), "then taken from the cache"); + Assert.That(again.uv, Is.EqualTo(first.uv), "as it was matched"); + } + finally + { + DisposeCache(cache); + } + } + + [Test] + public void AGroupWhoseMemberChanged_IsMatchedAgain_AndTheOtherIsNot() + { + var cache = new NativeParallelHashMap(4, Allocator.Temp); + try + { + using (var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0)), Square(10, new float3(1, 0, 0)), Square(12, new float3(1, 0, 0)))) + MatchWithCache(brushes, cache); + using (var moved = Brushes(Square(-2.5f, new float3(1, 0, 0)), Square(-0.5f, new float3(1, 0, 0)), Square(10, new float3(1, 0, 0)), Square(12, new float3(1, 0, 0)))) + { + var after = MatchWithCache(moved, cache); + Assert.That((after.groups, after.hits, after.misses), Is.EqualTo((2, 1, 1)), "only the moved group is matched again"); + } + Assert.That(cache.Count(), Is.EqualTo(2), "the old match of the moved group is forgotten"); + } + finally + { + DisposeCache(cache); + } + } + + [Test] + public void ALayoutEdit_MatchesEveryGroupAgain() + { + var cache = new NativeParallelHashMap(4, Allocator.Temp); + try + { + using var brushes = Brushes(Square(-2, new float3(1, 0, 0)), Square(0, new float3(1, 0, 0))); + MatchWithCache(brushes, cache); + BorderOnB1(32); + var after = MatchWithCache(brushes, cache); + Assert.That((after.hits, after.misses), Is.EqualTo((0, 1))); + } + finally + { + DisposeCache(cache); + } + } + } +} diff --git a/Core/Tests/HotspotGroupsTests.cs.meta b/Core/Tests/HotspotGroupsTests.cs.meta new file mode 100644 index 00000000..55b2afc0 --- /dev/null +++ b/Core/Tests/HotspotGroupsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1fcc903e9ec8a5c4491b6b9a0c42d077 \ No newline at end of file diff --git a/Core/Tests/HotspotLayoutCutsTests.cs b/Core/Tests/HotspotLayoutCutsTests.cs new file mode 100644 index 00000000..8e56f4c0 --- /dev/null +++ b/Core/Tests/HotspotLayoutCutsTests.cs @@ -0,0 +1,302 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class HotspotLayoutCutsTests + { + ChiselHotspotLayout layout; + + [SetUp] + public void SetUp() + { + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(1024, 512); + layout.ResetCuts(); + } + + [TearDown] + public void TearDown() { Object.DestroyImmediate(layout); } + + static Vector2Int P(int x, int y) => new(x, y); + + HotspotTile Tile(int id) => layout.tiles[layout.IndexOfTile(id)]; + + static long DoubledArea(Vector2Int[] points) + { + long area = 0; + for (int i = 0; i < points.Length; i++) + area += (long)points[i].x * points[(i + 1) % points.Length].y - (long)points[(i + 1) % points.Length].x * points[i].y; + return System.Math.Abs(area); + } + + void AssertCovered() + { + Assert.That(layout.tiles.Sum(t => DoubledArea(t.points)), Is.EqualTo(2L * layout.size.x * layout.size.y), "the tiles cover the texture once"); + Assert.That(layout.tiles.Select(t => t.id).Distinct().Count(), Is.EqualTo(layout.tiles.Count), "tile ids are unique"); + } + + [Test] + public void ANewLayout_IsOneTileOverTheTexture() + { + Assert.That(layout.tiles.Count, Is.EqualTo(1)); + Assert.That(layout.tiles[0].points, Is.EqualTo(new[] { P(0, 0), P(1024, 0), P(1024, 512), P(0, 512) })); + Assert.That(layout.TileAt(new Vector2(700, 300)), Is.EqualTo(layout.tiles[0].id)); + } + + [Test] + public void SplittingATile_GivesTilesThatCoverIt_TheFirstKeepsItsIdAndTheOthersItsProperties() + { + var tile = layout.tiles[0]; tile.flags = HotspotTileFlags.AllowMirror; tile.border = 8; layout.tiles[0] = tile; + Assert.That(layout.Split(1, HotspotCutKind.Vertical, 512), Is.True); + Assert.That(layout.Split(1, HotspotCutKind.Horizontal, 128), Is.True); + Assert.That(layout.tiles.Count, Is.EqualTo(3)); + Assert.That(Tile(1).points, Is.EqualTo(new[] { P(0, 0), P(512, 0), P(512, 128), P(0, 128) }), "the first part keeps the id"); + var right = layout.TileAt(new Vector2(800, 300)); + Assert.That(Tile(right).points, Is.EqualTo(new[] { P(512, 0), P(1024, 0), P(1024, 512), P(512, 512) })); + Assert.That(Tile(right).flags, Is.EqualTo(HotspotTileFlags.AllowMirror)); + Assert.That(Tile(right).border, Is.EqualTo(8)); + AssertCovered(); + + Assert.That(layout.Split(1, HotspotCutKind.Vertical, 512), Is.False, "a line on the tile's edge"); + Assert.That(layout.Split(1, HotspotCutKind.Horizontal, 300), Is.False, "a line outside the tile"); + } + + [Test] + public void DiagonalsAndApexes_GiveTriangles_ThatAreNotCutFurther() + { + layout.Split(1, HotspotCutKind.Vertical, 256); + layout.Split(1, HotspotCutKind.Slash, 0); + var right = layout.TileAt(new Vector2(600, 300)); + layout.Split(right, HotspotCutKind.Apex, 640); + Assert.That(layout.tiles.Count(t => t.points.Length == 3), Is.EqualTo(5)); + Assert.That(layout.tiles.Count, Is.EqualTo(5)); + AssertCovered(); + + Assert.That(layout.TileAt(new Vector2(10, 10)), Is.EqualTo(1), "the upper left triangle of the slash"); + var lowerRight = layout.TileAt(new Vector2(250, 500)); + Assert.That(Tile(lowerRight).points, Is.EquivalentTo(new[] { P(256, 0), P(256, 512), P(0, 512) })); + var middle = layout.TileAt(new Vector2(640, 400)); + Assert.That(Tile(middle).points, Is.EquivalentTo(new[] { P(640, 0), P(1024, 512), P(256, 512) })); + Assert.That(layout.Split(middle, HotspotCutKind.Vertical, 640), Is.False, "a triangle is not cut"); + } + + [Test] + public void MovingALine_MovesTheTilesBesideIt_AndStopsAtTheLinesInsideThem() + { + layout.Split(1, HotspotCutKind.Vertical, 512); + var right = layout.TileAt(new Vector2(800, 100)); + layout.Split(right, HotspotCutKind.Vertical, 768); + layout.Split(1, HotspotCutKind.Apex, 256); + var lines = new List(); + layout.GetLines(lines); + var middle = lines.First(l => l.a.x == 512 && l.b.x == 512).node; + + Assert.That(layout.MoveRange(middle, out var min, out var max), Is.True); + Assert.That((min, max), Is.EqualTo((257, 767)), "between the apex on its left and the line on its right"); + Assert.That(layout.MoveCut(middle, 400), Is.True); + Assert.That(Tile(right).points, Is.EqualTo(new[] { P(400, 0), P(768, 0), P(768, 512), P(400, 512) })); + Assert.That(layout.MoveCut(middle, 2000), Is.True); + Assert.That(Tile(right).points[0], Is.EqualTo(P(767, 0)), "it stops short of the line inside"); + AssertCovered(); + + layout.GetLines(lines); + var diagonal = layout.ParentOf(layout.NodeOfTile(1)); + Assert.That(layout.cuts[diagonal].kind, Is.EqualTo(HotspotCutKind.Apex)); + Assert.That(layout.MoveCut(diagonal, 10), Is.True, "an apex moves along the top"); + Assert.That(layout.Split(right, HotspotCutKind.Slash, 0), Is.True); + Assert.That(layout.MoveRange(layout.ParentOf(layout.NodeOfTile(right)), out _, out _), Is.False, "a diagonal does not move"); + } + + [Test] + public void RemovingALine_MergesTheTilesBesideIt_IntoTheLargestOfThem() + { + layout.Split(1, HotspotCutKind.Vertical, 256); + var right = layout.TileAt(new Vector2(800, 100)); + layout.Split(right, HotspotCutKind.Horizontal, 384); + var lowerRight = layout.TileAt(new Vector2(800, 450)); + Assert.That(layout.tiles.Count, Is.EqualTo(3)); + + // the larger tile is the first of the two + Assert.That(layout.RemoveCut(layout.ParentOf(layout.NodeOfTile(right))), Is.True); + Assert.That(layout.tiles.Count, Is.EqualTo(2)); + Assert.That(layout.IndexOfTile(right), Is.GreaterThanOrEqualTo(0), "the larger tile is kept"); + Assert.That(layout.IndexOfTile(lowerRight), Is.LessThan(0)); + Assert.That(Tile(right).points, Is.EqualTo(new[] { P(256, 0), P(1024, 0), P(1024, 512), P(256, 512) })); + AssertCovered(); + + Assert.That(layout.RemoveCut(0), Is.True, "the first line"); + Assert.That(layout.tiles.Count, Is.EqualTo(1)); + Assert.That(layout.cuts.Count, Is.EqualTo(1), "nothing is left of the removed lines"); + AssertCovered(); + } + + [Test] + public void ANewTextureSize_KeepsTheLinesWhereTheyAreOnTheTexture() + { + layout.Split(1, HotspotCutKind.Vertical, 256); + layout.Split(1, HotspotCutKind.Horizontal, 128); + layout.Resize(new Vector2Int(2048, 1024)); + Assert.That(Tile(1).points, Is.EqualTo(new[] { P(0, 0), P(512, 0), P(512, 256), P(0, 256) })); + AssertCovered(); + } + + static HotspotTile Given(int id, params int[] xy) + { + var points = new Vector2Int[xy.Length / 2]; + for (int i = 0; i < points.Length; i++) + points[i] = P(xy[i * 2], xy[i * 2 + 1]); + return new HotspotTile { id = id, name = "given " + id, points = points }; + } + + [Test] + public void TilesGivenWithoutCuts_GetTheirCuts_WhenTheyAreAGrid() + { + layout.size = new Vector2Int(1024, 512); + layout.cuts.Clear(); + layout.tiles = new List + { + Given(1, 0, 0, 512, 0, 512, 256, 0, 256), + Given(2, 0, 256, 256, 256, 0, 512), // the slash's upper left triangle + Given(3, 256, 256, 256, 512, 0, 512), // its lower right + Given(4, 512, 0, 640, 0, 512, 128), // an apex at 640 over 512..1024 x 0..128 + Given(5, 640, 0, 1024, 128, 512, 128), + Given(6, 640, 0, 1024, 0, 1024, 128), + // 256..512 x 256..512 and 512..1024 x 128..512 are not given + }; + Assert.That(layout.BuildCuts(out var error), Is.True, error); + Assert.That(layout.tiles.Count, Is.EqualTo(8), "the parts no tile covers are tiles too"); + Assert.That(layout.tiles.Count(t => (t.flags & HotspotTileFlags.Disabled) != 0), Is.EqualTo(2), "disabled"); + AssertCovered(); + Assert.That(Tile(1).points, Is.EqualTo(new[] { P(0, 0), P(512, 0), P(512, 256), P(0, 256) }), "given tiles keep where they are"); + Assert.That(Tile(5).points, Is.EquivalentTo(new[] { P(640, 0), P(1024, 128), P(512, 128) })); + Assert.That(layout.TileAt(new Vector2(100, 300)), Is.EqualTo(2)); + Assert.That(layout.TileAt(new Vector2(250, 500)), Is.EqualTo(3)); + } + + [Test] + public void TilesGivenWithoutCuts_KeepNoCuts_WhenTheyAreNotAGrid() + { + // a pinwheel: four rectangles around a square, no line goes all the way across + layout.size = new Vector2Int(3, 3); + layout.cuts.Clear(); + layout.tiles = new List + { + Given(1, 0, 0, 2, 0, 2, 1, 0, 1), Given(2, 2, 0, 3, 0, 3, 2, 2, 2), Given(3, 1, 2, 3, 2, 3, 3, 1, 3), + Given(4, 0, 1, 1, 1, 1, 3, 0, 3), Given(5, 1, 1, 2, 1, 2, 2, 1, 2), + }; + Assert.That(layout.BuildCuts(out var error), Is.False); + Assert.That(error, Is.Not.Null.And.Not.Empty); + Assert.That(layout.HasCuts, Is.False); + Assert.That(layout.tiles.Count, Is.EqualTo(5), "the tiles stay as they were given"); + Assert.That(layout.TileAt(new Vector2(1.5f, 1.5f)), Is.EqualTo(5)); + } + + [Test] + public void ADrawnShape_StaysOverTheCuts_WhenTheirLinesChange() + { + Assert.That(layout.Split(1, HotspotCutKind.Vertical, 512), Is.True); + var hexagon = new[] { P(128, 0), P(384, 0), P(512, 222), P(384, 444), P(128, 444), P(0, 222) }; + var shape = layout.AddShape(hexagon); + Assert.That(shape, Is.Not.Zero); + Assert.That(layout.IsDrawn(shape), Is.True); + Assert.That(layout.IsDrawn(1), Is.False, "the cuts' tiles are not drawn"); + Assert.That(ChiselHotspotLayout.IsShaped(Tile(shape)), Is.True); + + Assert.That(layout.Split(1, HotspotCutKind.Horizontal, 256), Is.True); + var lines = new List(); + layout.GetLines(lines); + var vertical = lines.First(l => l.a.x == 512 && l.b.x == 512).node; + Assert.That(layout.MoveCut(vertical, 600), Is.True); + Assert.That(layout.IndexOfTile(shape), Is.GreaterThanOrEqualTo(0), "kept when lines are added and moved"); + Assert.That(Tile(shape).points, Is.EqualTo(hexagon), "its points are its own"); + Assert.That(layout.RemoveCut(vertical), Is.True); + Assert.That(layout.IndexOfTile(shape), Is.GreaterThanOrEqualTo(0), "kept when lines are removed"); + Assert.That(layout.tiles.Count, Is.EqualTo(2)); + } + + [Test] + public void ADrawnShape_IsFoundBeforeTheTileUnderIt_ExceptInItsHoles() + { + var square = layout.AddShape(new[] { P(100, 100), P(400, 100), P(400, 400), P(100, 400) }); + var triangle = layout.AddShape(new[] { P(600, 100), P(900, 100), P(600, 400) }); + Assert.That(layout.AddHole(square, new[] { P(200, 200), P(300, 200), P(300, 300), P(200, 300) }), Is.True); + Assert.That(layout.AddHole(1, new[] { P(10, 10), P(20, 10), P(20, 20) }), Is.False, "the cuts' tiles take no holes"); + Assert.That(layout.TileAt(new Vector2(150, 150)), Is.EqualTo(square)); + Assert.That(layout.TileAt(new Vector2(250, 250)), Is.EqualTo(1), "its hole shows the tile under it"); + Assert.That(layout.TileAt(new Vector2(650, 150)), Is.EqualTo(triangle)); + Assert.That(layout.TileAt(new Vector2(850, 350)), Is.EqualTo(1), "outside the triangle, inside its bounds"); + Assert.That(layout.AddShape(new[] { P(0, 0), P(10, 0), P(20, 0) }), Is.Zero, "no area"); + + Assert.That(layout.RemoveShape(1), Is.False, "the cuts' tiles go with their lines"); + Assert.That(layout.RemoveShape(square), Is.True); + Assert.That(layout.TileAt(new Vector2(150, 150)), Is.EqualTo(1)); + } + + [Test] + public void ADrawnShape_HasItsCornersMovedAddedAndRemoved_AndMovesAsAWhole() + { + var square = layout.AddShape(new[] { P(100, 100), P(400, 100), P(400, 400), P(100, 400) }); + layout.AddHole(square, new[] { P(200, 200), P(300, 200), P(300, 300) }); + Assert.That(layout.MoveShapePoint(square, -1, 2, P(500, 450)), Is.True); + Assert.That(Tile(square).points[2], Is.EqualTo(P(500, 450))); + Assert.That(layout.InsertShapePoint(square, -1, 0, P(250, 50)), Is.True); + Assert.That(Tile(square).points, Is.EqualTo(new[] { P(100, 100), P(250, 50), P(400, 100), P(500, 450), P(100, 400) })); + Assert.That(layout.RemoveShapePoint(square, -1, 1), Is.True); + Assert.That(Tile(square).points.Length, Is.EqualTo(4)); + Assert.That(layout.RemoveShapePoint(square, 0, 0), Is.False, "a hole keeps three corners"); + Assert.That(layout.MoveShapePoint(square, 0, 2, P(250, 200)), Is.False, "no corner leaves its loop without area"); + Assert.That(layout.MoveShape(square, P(10, -20)), Is.True); + Assert.That(Tile(square).points[0], Is.EqualTo(P(110, 80))); + Assert.That(Tile(square).holes[0].points[0], Is.EqualTo(P(210, 180)), "its holes with it"); + Assert.That(layout.RemoveShapeHole(square, 0), Is.True); + Assert.That(Tile(square).holes, Is.Null); + Assert.That(layout.MoveShapePoint(1, -1, 0, P(5, 5)), Is.False, "the cuts' tiles follow their lines"); + } + + [Test] + public void ANewTextureSize_MovesDrawnShapesAndTheirHoles() + { + var square = layout.AddShape(new[] { P(100, 100), P(400, 100), P(400, 400), P(100, 400) }); + layout.AddHole(square, new[] { P(200, 200), P(300, 200), P(300, 300), P(200, 300) }); + layout.Resize(new Vector2Int(2048, 256)); + Assert.That(Tile(square).points, Is.EqualTo(new[] { P(200, 50), P(800, 50), P(800, 200), P(200, 200) })); + Assert.That(Tile(square).holes[0].points, Is.EqualTo(new[] { P(400, 100), P(600, 100), P(600, 150), P(400, 150) })); + } + + [Test] + public void ShapedTiles_StayOverTheGrid_WhenItsCutsAreFound() + { + layout.cuts.Clear(); + layout.tiles.Clear(); + layout.tiles.Add(new HotspotTile { id = 1, name = "L", points = new[] { P(0, 0), P(512, 0), P(512, 512), P(0, 512) } }); + layout.tiles.Add(new HotspotTile { id = 2, name = "R", points = new[] { P(512, 0), P(1024, 0), P(1024, 512), P(512, 512) } }); + layout.tiles.Add(new HotspotTile { id = 3, name = "hex", points = new[] { P(128, 0), P(384, 0), P(512, 222), P(384, 444), P(128, 444), P(0, 222) } }); + Assert.That(layout.BuildCuts(out var error), Is.True, error); + Assert.That(layout.HasCuts, Is.True); + Assert.That(layout.IsDrawn(3), Is.True); + Assert.That(layout.IsDrawn(1), Is.False); + Assert.That(layout.TileAt(new Vector2(256, 222)), Is.EqualTo(3)); + Assert.That(layout.TileAt(new Vector2(800, 222)), Is.EqualTo(2)); + } + + [Test] + public void TheTestSheetsJson_IsAGrid() + { + const string json = "{\"format\": \"chisel-hotspot-layout\", \"width\": 1024, \"height\": 512, \"origin\": \"top-left\", \"tiles\": [" + + "{\"name\": \"A\", \"points\": [0, 0, 512, 0, 512, 512, 0, 512], \"tags\": [\"rotate\"], \"border\": 16}," + + "{\"name\": \"Tr1\", \"points\": [512, 0, 1024, 0, 512, 512], \"tags\": []}," + + "{\"name\": \"Tr2\", \"points\": [1024, 0, 1024, 512, 512, 512], \"tags\": []}]}"; + Assert.That(ChiselHotspotLayout.TryReadJson(json, layout, out var error), Is.True, error); + Assert.That(layout.HasCuts, Is.True); + Assert.That(layout.cuts[0].kind, Is.EqualTo(HotspotCutKind.Vertical)); + Assert.That(layout.cuts[layout.cuts[0].second].kind, Is.EqualTo(HotspotCutKind.Slash)); + Assert.That(layout.tiles[0].border, Is.EqualTo(16)); + Assert.That(layout.tiles[0].flags, Is.EqualTo(HotspotTileFlags.AllowRotation)); + AssertCovered(); + } + } +} diff --git a/Core/Tests/HotspotLayoutCutsTests.cs.meta b/Core/Tests/HotspotLayoutCutsTests.cs.meta new file mode 100644 index 00000000..e5a7e10d --- /dev/null +++ b/Core/Tests/HotspotLayoutCutsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f03be382917fa4d4a976cddcb04c7485 \ No newline at end of file diff --git a/Core/Tests/HotspotLayoutImportTests.cs b/Core/Tests/HotspotLayoutImportTests.cs new file mode 100644 index 00000000..5a692202 --- /dev/null +++ b/Core/Tests/HotspotLayoutImportTests.cs @@ -0,0 +1,144 @@ +using System.Linq; +using NUnit.Framework; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class HotspotLayoutImportTests + { + ChiselHotspotLayout layout; + + [SetUp] + public void SetUp() { layout = ScriptableObject.CreateInstance(); } + + [TearDown] + public void TearDown() { Object.DestroyImmediate(layout); } + + static Vector2Int P(int x, int y) => new(x, y); + + HotspotTile Named(string name) => layout.tiles.Single(t => t.name == name); + + void AssertCovered() + { + long area = 0; + foreach (var tile in layout.tiles) + { + var p = tile.points; + for (int i = 0; i < p.Length; i++) + area += (long)p[i].x * p[(i + 1) % p.Length].y - (long)p[(i + 1) % p.Length].x * p[i].y; + } + Assert.That(System.Math.Abs(area), Is.EqualTo(2L * layout.size.x * layout.size.y), "the tiles cover the texture once"); + } + + [Test] + public void AValveRectFile_IsRead_WithItsFlags() + { + const string rect = "// a comment\n" + + "Rectangles\n{\n" + + "\trectangle\n\t{\n\t\tmin\t\t\"0 0\"\n\t\tmax\t\t\"512 32\"\n\t\ttile 1\n\t}\n" + + "\t\"rectangle\"\n\t{\n\t\t\"min\" \"512 0\"\n\t\t\"max\" \"1024 32\"\n\t\treflect 1\n\t\trotate 1\n\t\talt 1\n\t}\n" + + "\trectangle\n\t{\n\t\tmin \"0 32\"\n\t\tmax \"1024 64\"\n\t\ttile 1\n\t}\n" + + "}\n"; + Assert.That(ChiselHotspotLayout.TryReadRect(rect, new Vector2Int(1024, 512), layout, out var error), Is.True, error); + Assert.That(layout.size, Is.EqualTo(new Vector2Int(1024, 512))); + Assert.That(layout.tiles.Count(t => (t.flags & HotspotTileFlags.Disabled) == 0), Is.EqualTo(3), "the three rectangles"); + var first = layout.tiles.Single(t => t.id == 1); + Assert.That(first.points, Is.EqualTo(new[] { P(0, 0), P(512, 0), P(512, 32), P(0, 32) })); + Assert.That(first.flags, Is.EqualTo(HotspotTileFlags.None), "tile, but it does not reach across the texture"); + Assert.That(layout.tiles.Single(t => t.id == 2).flags, Is.EqualTo(HotspotTileFlags.AllowMirror | HotspotTileFlags.AllowRotation | HotspotTileFlags.Alternate)); + Assert.That(layout.tiles.Single(t => t.id == 3).flags, Is.EqualTo(HotspotTileFlags.RepeatU), "tile, and it reaches across the texture"); + Assert.That(layout.HasCuts, Is.True, "the rectangles are a grid; the rest of the texture is a disabled tile"); + AssertCovered(); + + Assert.That(ChiselHotspotLayout.TryReadRect(rect, Vector2Int.zero, layout, out error), Is.False, "a .rect file has no size of its own"); + Assert.That(ChiselHotspotLayout.TryReadRect("Rectangles { }", new Vector2Int(64, 64), layout, out _), Is.False, "no rectangles"); + } + + [Test] + public void AnSvg_IsRead_RectsAndPolygons_WithNamesTagsAndBorders() + { + const string svg = "" + + "" + + "" + + "" + + "" + + "" + + ""; + Assert.That(ChiselHotspotLayout.TryReadSvg(svg, layout, out var error), Is.True, error); + Assert.That(layout.size, Is.EqualTo(new Vector2Int(1024, 512))); + Assert.That(layout.tiles.Count, Is.EqualTo(5)); + Assert.That(Named("panel").points, Is.EqualTo(new[] { P(0, 0), P(512, 0), P(512, 512), P(0, 512) })); + Assert.That(Named("panel").flags, Is.EqualTo(HotspotTileFlags.AllowRotation)); + Assert.That(Named("panel").border, Is.EqualTo(16)); + Assert.That(Named("round").flags, Is.EqualTo(HotspotTileFlags.Radial), "named by its inkscape label, tagged by data-tags"); + Assert.That(Named("upper").points.Length, Is.EqualTo(3)); + Assert.That(Named("strip").flags, Is.EqualTo(HotspotTileFlags.RepeatU), "a closed polygon, tags in any case"); + Assert.That(layout.HasCuts, Is.True); + AssertCovered(); + + const string transformed = ""; + Assert.That(ChiselHotspotLayout.TryReadSvg(transformed, layout, out error), Is.False); + Assert.That(error, Does.Contain("transform")); + Assert.That(ChiselHotspotLayout.TryReadSvg("", layout, out _), Is.False, "not an SVG"); + } + + [Test] + public void AnSvg_LinksATileToItsInsideCornerPieces() + { + const string svg = "" + + "" + + "" + + "" + + ""; + Assert.That(ChiselHotspotLayout.TryReadSvg(svg, layout, out var error), Is.True, error); + Assert.That(Named("trim").insideCorners, Is.EqualTo(Named("corners").id)); + Assert.That(Named("corners").flags, Is.EqualTo(HotspotTileFlags.InsideCorners)); + Assert.That(Named("one").flags, Is.EqualTo(HotspotTileFlags.InsideCornerTurned)); + Assert.That(ChiselHotspotLayout.TryReadSvg(svg.Replace("data-inside-corners=\"corners\"", "data-inside-corners=\"none\""), layout, out _), Is.False, "a link to no tile"); + } + + [Test] + public void AnSvg_IsRead_PolygonsOfAnyShape_AndPathsWithHoles_AsShapedTiles() + { + const string svg = "" + + "" + + "" + + ""; + Assert.That(ChiselHotspotLayout.TryReadSvg(svg, layout, out var error), Is.True, error); + Assert.That(layout.tiles.Count, Is.GreaterThanOrEqualTo(2)); + Assert.That(Named("hex").points.Length, Is.EqualTo(6), "six corners kept"); + Assert.That(Named("hex").holes, Is.Null.Or.Empty); + var frame = Named("frame"); + Assert.That(frame.points, Is.EqualTo(new[] { P(512, 0), P(1024, 0), P(1024, 512), P(512, 512) }), "the path's first loop"); + Assert.That(frame.holes.Length, Is.EqualTo(1), "its second loop a hole"); + Assert.That(frame.holes[0].points, Is.EqualTo(new[] { P(640, 128), P(896, 128), P(896, 384), P(640, 384) }), "relative moves and lines"); + Assert.That(frame.border, Is.EqualTo(8)); + // shaped tiles are not a grid: kept without lines + Assert.That(layout.HasCuts, Is.False); + Assert.That(ChiselHotspotLayout.TryReadSvg("", layout, out error), Is.False); + Assert.That(error, Does.Contain("straight lines")); + } + + [Test] + public void AColourMask_IsRead_OneTilePerColour() + { + // 8 x 4, the bottom row first: red on the left half, green at the top right, black (no tile) at the bottom right + var red = new Color32(255, 0, 0, 255); var green = new Color32(0, 255, 0, 255); var black = new Color32(0, 0, 0, 255); + var pixels = new Color32[8 * 4]; + for (int row = 0; row < 4; row++) + for (int x = 0; x < 8; x++) + pixels[row * 8 + x] = x < 4 ? red : (row >= 2 ? green : black); + Assert.That(ChiselHotspotLayout.TryReadColorMask(pixels, 8, 4, layout, out var error), Is.True, error); + Assert.That(layout.size, Is.EqualTo(new Vector2Int(8, 4))); + Assert.That(Named("#FF0000").points, Is.EqualTo(new[] { P(0, 0), P(4, 0), P(4, 4), P(0, 4) })); + Assert.That(Named("#00FF00").points, Is.EqualTo(new[] { P(4, 0), P(8, 0), P(8, 2), P(4, 2) }), "the top right, from the top"); + Assert.That(layout.tiles.Count, Is.EqualTo(3), "and the black part as a disabled tile"); + AssertCovered(); + + pixels[0] = green; + Assert.That(ChiselHotspotLayout.TryReadColorMask(pixels, 8, 4, layout, out error), Is.False, "green is not one rectangle now"); + Assert.That(error, Does.Contain("00FF00")); + } + } +} diff --git a/Core/Tests/HotspotLayoutImportTests.cs.meta b/Core/Tests/HotspotLayoutImportTests.cs.meta new file mode 100644 index 00000000..cbd577f3 --- /dev/null +++ b/Core/Tests/HotspotLayoutImportTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 639a63202e58c8847a9a14f3e914213b \ No newline at end of file diff --git a/Core/Tests/HotspotMatcherTests.cs b/Core/Tests/HotspotMatcherTests.cs new file mode 100644 index 00000000..a8b7ffe7 --- /dev/null +++ b/Core/Tests/HotspotMatcherTests.cs @@ -0,0 +1,607 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class HotspotMatcherTests + { + // A 2048 sheet at 256 pixels a unit, cut like part of the test sheet (SourceAssets~/HotspotTest) + static ChiselHotspotLayout Layout(params HotspotTile[] tiles) + { + var layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List(tiles); + return layout; + } + + static HotspotTile Rect(int id, string name, int x, int y, int w, int h, HotspotTileFlags flags = HotspotTileFlags.None) + { + return new HotspotTile { id = id, name = name, flags = flags, + points = new[] { new Vector2Int(x, y), new Vector2Int(x + w, y), new Vector2Int(x + w, y + h), new Vector2Int(x, y + h) } }; + } + + static HotspotTile Triangle(int id, string name, Vector2Int a, Vector2Int b, Vector2Int c, HotspotTileFlags flags = HotspotTileFlags.None) + { + return new HotspotTile { id = id, name = name, flags = flags, points = new[] { a, b, c } }; + } + + static ChiselHotspotLayout TestSheet() + { + return Layout( + Rect(1, "A1", 0, 0, 512, 512), + Rect(2, "A2", 512, 0, 512, 512), + Rect(3, "B1", 1024, 0, 1024, 512), + Rect(4, "C1", 0, 512, 1024, 256), + Rect(5, "D1", 1024, 512, 256, 256), + Rect(6, "E1", 1280, 512, 256, 512), + Rect(7, "G1", 1536, 512, 128, 128), + Rect(8, "R1", 0, 1024, 512, 512, HotspotTileFlags.Radial), + Rect(9, "X", 512, 1024, 1024, 1024, HotspotTileFlags.Disabled), + // a right triangle 256 x 128, its right angle at the bottom right as the texture shows it + Triangle(10, "Tr6", new Vector2Int(1792, 1024), new Vector2Int(1792, 1152), new Vector2Int(1536, 1152))); + } + + sealed class Tiles : System.IDisposable + { + public NativeList list = new(Allocator.Temp); + public HotspotLayoutData data; + public ChiselHotspotLayout layout; + public Tiles(ChiselHotspotLayout layout) { this.layout = layout; HotspotMatcher.AddTiles(layout, list, out data); } + public string NameOf(int index) => index < 0 ? "none" : layout.tiles.Find(t => t.id == list[index].id).name; + public void Dispose() { list.Dispose(); Object.DestroyImmediate(layout); } + } + + // A wall facing -z from (x0, y0) to (x1, y1) + static HotspotShape Wall(float width, float height) + { + using var positions = new NativeArray(new[] { new float3(0, 0, 0), new float3(width, 0, 0), new float3(width, height, 0), new float3(0, height, 0) }, Allocator.Temp); + return HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + } + + static float2 UV(float4x4 mapping, float3 position) { return math.mul(mapping, new float4(position, 1)).xy; } + + static void AreEqual(float2 expected, float2 actual, string what) + { + Assert.That(math.distance(expected, actual), Is.LessThan(1e-5f), $"{what}: expected {expected}, got {actual}"); + } + + [Test] + public void AnotherSheetsTile_IsOneVariation_WithoutTheLayoutsMaterials() + { + // two identical tiles of the layout's own, drawn with two materials, and one more on another sheet: 2 x 2 + 1 + var own = Layout(Rect(1, "A1", 0, 0, 512, 512), Rect(2, "A2", 512, 0, 512, 512)); + var sheet = Layout(Rect(1, "S1", 0, 0, 512, 512)); + var tiles = new NativeList(Allocator.Temp); + try + { + HotspotMatcher.AddTiles(own, tiles, out var data); + HotspotMatcher.AddTiles(sheet, tiles, out _); + var t = tiles[2]; t.sheet = 2; t.sheetIndex = 1; tiles[2] = t; + data.tileCount = tiles.Length - data.tileStart; + data.variantCount = 2; + using var corners = new NativeArray(new[] { new float3(0, 0, 0), new float3(2, 0, 0), new float3(2, 2, 0), new float3(0, 2, 0) }, Allocator.Temp); + var shape = HotspotMatcher.ShapeOf(corners, new float3(1, 0, 0), new float3(0, 1, 0)); + var seen = new HashSet<(int tile, int variant)>(); + for (uint seed = 0; seed < 200; seed++) + { + var choice = HotspotMatcher.Choose(tiles.AsArray(), data, shape, seed, 0); + Assert.That(choice.variations, Is.EqualTo(5)); + if (choice.tile == 2) + Assert.That(choice.variant, Is.Zero, "the other sheet's tile keeps its own material"); + seen.Add((choice.tile, choice.variant)); + } + Assert.That(seen.Count, Is.EqualTo(5), "every variation is a different choice: " + string.Join(", ", seen)); + } + finally + { + tiles.Dispose(); + Object.DestroyImmediate(own); + Object.DestroyImmediate(sheet); + } + } + + [Test] + public void ALayoutsJson_LinksATileToItsInsideCornerPieces() + { + const string json = "{\"format\": \"chisel-hotspot-layout\", \"width\": 1024, \"height\": 1024, \"tiles\": [" + + "{\"name\": \"B1\", \"points\": [0, 0, 512, 0, 512, 256, 0, 256], \"border\": 16, \"insideCorners\": \"In\"}," + + "{\"name\": \"In\", \"points\": [512, 0, 544, 0, 544, 32, 512, 32], \"tags\": [\"inside-corners\"]}," + + "{\"name\": \"In1\", \"points\": [544, 0, 560, 0, 560, 16, 544, 16], \"tags\": [\"inside-corner-turned\"]}]}"; + var layout = ScriptableObject.CreateInstance(); + try + { + Assert.That(ChiselHotspotLayout.TryReadJson(json, layout, out var error), Is.True, error); + Assert.That(layout.tiles[0].insideCorners, Is.EqualTo(layout.tiles[1].id)); + Assert.That(layout.tiles[1].flags, Is.EqualTo(HotspotTileFlags.InsideCorners)); + Assert.That(layout.tiles[2].flags, Is.EqualTo(HotspotTileFlags.InsideCornerTurned)); + Assert.That(ChiselHotspotLayout.TryReadJson(json.Replace("\"In\"}", "\"Nothing\"}"), layout, out _), Is.False, "a link to no tile"); + } + finally + { + Object.DestroyImmediate(layout); + } + } + + [Test] + public void ALayout_ReadsTheJsonTheTestSheetWrites() + { + const string json = "{\"format\": \"chisel-hotspot-layout\", \"version\": 1, \"width\": 2048, \"height\": 1024, \"origin\": \"top-left\", \"tiles\": [" + + "{\"name\": \"A1\", \"shape\": \"rectangle\", \"points\": [0, 0, 512, 0, 512, 512, 0, 512], \"tags\": []}," + + "{\"name\": \"Tr1\", \"shape\": \"triangle\", \"points\": [512, 0, 640, 0, 512, 128], \"tags\": [\"mirror\"]}," + + "{\"name\": \"T1\", \"shape\": \"rectangle\", \"points\": [0, 512, 2048, 512, 2048, 640, 0, 640], \"tags\": [\"repeat-u\", \"radial\"]}]," + + "\"cuts\": [\"y\", [[512, {\"name\": \"A1\"}]]]}"; + var layout = ScriptableObject.CreateInstance(); + try + { + Assert.That(ChiselHotspotLayout.TryReadJson(json, layout, out var error), Is.True, error); + Assert.That(layout.size, Is.EqualTo(new Vector2Int(2048, 1024))); + Assert.That(layout.tiles.Count, Is.EqualTo(3)); + Assert.That(layout.tiles[0].points, Is.EqualTo(new[] { new Vector2Int(0, 0), new Vector2Int(512, 0), new Vector2Int(512, 512), new Vector2Int(0, 512) })); + Assert.That(layout.tiles[1].points.Length, Is.EqualTo(3), "the triangle's corners"); + Assert.That(layout.tiles[1].flags, Is.EqualTo(HotspotTileFlags.AllowMirror)); + Assert.That(layout.tiles[2].flags, Is.EqualTo(HotspotTileFlags.RepeatU | HotspotTileFlags.Radial)); + Assert.That(new HashSet { layout.tiles[0].id, layout.tiles[1].id, layout.tiles[2].id }.Count, Is.EqualTo(3), "ids are unique"); + Assert.That(ChiselHotspotLayout.TryReadJson("{\"format\": \"something else\"}", layout, out _), Is.False, "another format"); + } + finally + { + Object.DestroyImmediate(layout); + } + } + + [Test] + public void TheTiles_AreYUpAndCounterClockwise_WhicheverWayTheirCornersWereGiven() + { + var layout = Layout(Rect(1, "clockwise", 0, 0, 512, 256), + Triangle(2, "counter-clockwise on screen", new Vector2Int(0, 512), new Vector2Int(0, 768), new Vector2Int(256, 768))); + using var tiles = new Tiles(layout); + for (int t = 0; t < tiles.list.Length; t++) + { + var tile = tiles.list[t]; + var area = 0.0f; + for (int i = 0; i < tile.cornerCount; i++) + { + var a = tile.Corner(i); var b = tile.Corner((i + 1) % tile.cornerCount); + area += a.x * b.y - b.x * a.y; + } + Assert.That(area, Is.GreaterThan(0), tiles.NameOf(t) + " is not counter-clockwise"); + } + Assert.That(tiles.list[0].min, Is.EqualTo(new float2(0, 1792)), "the rectangle's bottom left, y up"); + Assert.That(tiles.list[0].max, Is.EqualTo(new float2(512, 2048)), "the rectangle's top right, y up"); + } + + // A hexagon 512 x 444 pixels (points up and down are flat), from the top left of the texture at (x, y) + static HotspotTile Hexagon(int id, string name, int x, int y) + { + return new HotspotTile { id = id, name = name, points = new[] { + new Vector2Int(x + 128, y), new Vector2Int(x + 384, y), new Vector2Int(x + 512, y + 222), + new Vector2Int(x + 384, y + 444), new Vector2Int(x + 128, y + 444), new Vector2Int(x, y + 222) } }; + } + + // A surface facing -z drawing these corners as a fan, in units + static HotspotShape Fan(params float2[] corners) + { + var points = new float3[corners.Length]; + for (int i = 0; i < corners.Length; i++) + points[i] = new float3(corners[i], 0); + var fan = new int[(corners.Length - 2) * 3]; + for (int i = 1; i + 1 < corners.Length; i++) + { + fan[(i - 1) * 3] = 0; fan[(i - 1) * 3 + 1] = i; fan[(i - 1) * 3 + 2] = i + 1; + } + using var positions = new NativeArray(points, Allocator.Temp); + using var indices = new NativeArray(fan, Allocator.Temp); + HotspotMatcher.Frame(new float3(0, 0, -1), out var right, out var up); + var shape = HotspotMatcher.ShapeOf(positions, right, up); + HotspotMatcher.MaskOf(ref shape, positions, indices, right, up); + return shape; + } + + // The hexagon tile's shape at 256 pixels a unit: 2 x 1.734 units + static readonly float2[] kHexagonSurface = { new(0.5f, 0), new(1.5f, 0), new(2, 0.867f), new(1.5f, 1.734f), new(0.5f, 1.734f), new(0, 0.867f) }; + + [Test] + public void AShapedTile_IsTakenByASurfaceShapedLikeIt_BeforeARectangle() + { + var layout = Layout(Rect(1, "A1", 0, 0, 512, 444), Hexagon(2, "Hex", 1024, 0)); + using var tiles = new Tiles(layout); + Assert.That(tiles.list[1].shaped, Is.True, "six corners: shaped"); + Assert.That(tiles.list[0].shaped, Is.False); + var hexagon = Fan(kHexagonSurface); + Assert.That(HotspotMatcher.Overlap(hexagon, tiles.list[1]), Is.GreaterThan(0.9f), "alike"); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, hexagon, 0, 0); + Assert.That(tiles.NameOf(choice.tile), Is.EqualTo("Hex")); + // a rectangle of the same size is not shaped like it: the rectangle tile + var rectangle = Fan(new float2(0, 0), new float2(2, 0), new float2(2, 1.734f), new float2(0, 1.734f)); + Assert.That(HotspotMatcher.Overlap(rectangle, tiles.list[1]), Is.LessThan(HotspotMatcher.kShapeMatch)); + choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, rectangle, 0, 0); + Assert.That(tiles.NameOf(choice.tile), Is.EqualTo("A1")); + } + + [Test] + public void AHoledTile_IsTakenByAWallWithAHoleLikeIt() + { + // a 512 square frame with a 256 square hole in its middle, beside a plain 512 square + var frame = Rect(2, "Frame", 1024, 0, 512, 512); + frame.holes = new[] { new HotspotTileHole { points = new[] { new Vector2Int(1152, 128), new Vector2Int(1408, 128), new Vector2Int(1408, 384), new Vector2Int(1152, 384) } } }; + var layout = Layout(Rect(1, "Square", 0, 0, 512, 512), frame); + using var tiles = new Tiles(layout); + Assert.That(tiles.list[1].shaped, Is.True, "a hole: shaped"); + // a 2 x 2 wall with a 1 x 1 window in its middle, as four quads round it + using var positions = new NativeArray(new[] { + new float3(0, 0, 0), new float3(2, 0, 0), new float3(2, 2, 0), new float3(0, 2, 0), + new float3(0.5f, 0.5f, 0), new float3(1.5f, 0.5f, 0), new float3(1.5f, 1.5f, 0), new float3(0.5f, 1.5f, 0) }, Allocator.Temp); + using var indices = new NativeArray(new[] { 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6, 3, 0, 4, 3, 4, 7 }, Allocator.Temp); + HotspotMatcher.Frame(new float3(0, 0, -1), out var right, out var up); + var wall = HotspotMatcher.ShapeOf(positions, right, up); + HotspotMatcher.MaskOf(ref wall, positions, indices, right, up); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, wall, 0, 0); + Assert.That(tiles.NameOf(choice.tile), Is.EqualTo("Frame")); + // a wall without the window: the plain square + choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Fan(new float2(0, 0), new float2(2, 0), new float2(2, 2), new float2(0, 2)), 0, 0); + Assert.That(tiles.NameOf(choice.tile), Is.EqualTo("Square")); + } + + [Test] + public void ALayoutsPadding_LeavesItsPixelsOutAlongEveryTilesEdges_AndTheBorderKeepsItsInnerEdge() + { + var bordered = Rect(1, "bordered", 0, 0, 512, 256); + bordered.border = 32; + var layout = Layout(bordered, Triangle(2, "triangle", new Vector2Int(0, 512), new Vector2Int(0, 768), new Vector2Int(256, 768)), + Rect(3, "tiny", 1024, 0, 3, 3)); + layout.padding = 2; + using var tiles = new Tiles(layout); + Assert.That(tiles.list[0].min, Is.EqualTo(new float2(2, 1794)), "the rectangle's bottom left, 2 pixels in"); + Assert.That(tiles.list[0].max, Is.EqualTo(new float2(510, 2046)), "its top right, 2 pixels in"); + Assert.That(tiles.list[0].border, Is.EqualTo(30), "its border 2 pixels narrower: its inner edge where it was"); + // the triangle's corners: each edge 2 pixels in, the right angle at (0, 1280) y up + var triangle = tiles.list[1]; + Assert.That(triangle.min.x, Is.EqualTo(2).Within(1e-3f), "its left edge 2 pixels in"); + Assert.That(triangle.min.y, Is.EqualTo(1280 + 2).Within(1e-3f), "its bottom edge 2 pixels in"); + Assert.That(tiles.list[2].max - tiles.list[2].min, Is.EqualTo(new float2(3, 3)), "a tile too small for it keeps its corners"); + } + + [TestCase(0, 0, -1, 1, 0, 0, 0, 1, 0, TestName = "AWallFacingMinusZ_ReadsWithRightAlongPlusX")] + [TestCase(1, 0, 0, 0, 0, 1, 0, 1, 0, TestName = "AWallFacingPlusX_ReadsWithRightAlongPlusZ")] + [TestCase(0, 1, 0, 1, 0, 0, 0, 0, 1, TestName = "AFloor_ReadsWithUpAlongPlusZ")] + [TestCase(0, -1, 0, -1, 0, 0, 0, 0, 1, TestName = "ACeiling_ReadsWithUpAlongPlusZ_FromBelow")] + [TestCase(0.6f, 0.8f, 0, 0, 0, 1, -0.8f, 0.6f, 0, TestName = "ASlopeFacingPlusX_ReadsUpItsSlope")] + [TestCase(0, 0.8f, -0.6f, 1, 0, 0, 0, 0.6f, 0.8f, TestName = "ASlopeFacingMinusZ_ReadsUpItsSlope")] + [TestCase(0.6f, -0.8f, 0, 0, 0, 1, 0.8f, 0.6f, 0, TestName = "AnOverhangFacingPlusX_ReadsUpItsSlope")] + public void TheFrame(float nx, float ny, float nz, float rx, float ry, float rz, float ux, float uy, float uz) + { + HotspotMatcher.Frame(new float3(nx, ny, nz), out var right, out var up); + Assert.That(math.distance(right, new float3(rx, ry, rz)), Is.LessThan(1e-6f), $"right: {right}"); + Assert.That(math.distance(up, new float3(ux, uy, uz)), Is.LessThan(1e-6f), $"up: {up}"); + } + + [Test] + public void AShape_IsATriangle_WhenThreeCornersAreLeft_AndOtherwiseItsBounds() + { + var wall = Wall(4, 2); + Assert.That(wall.cornerCount, Is.EqualTo(0), "a rectangle"); + Assert.That(wall.max - wall.min, Is.EqualTo(new float2(4, 2))); + + // a triangle with a point on one of its edges (a T junction the CSG leaves) and one inside + using var positions = new NativeArray(new[] { new float3(0, 0, 0), new float3(1, 0, 0), new float3(2, 0, 0), new float3(2, 1, 0), new float3(1.5f, 0.25f, 0) }, Allocator.Temp); + var triangle = HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + Assert.That(triangle.cornerCount, Is.EqualTo(3)); + var corners = new HashSet { triangle.c0, triangle.c1, triangle.c2 }; + Assert.That(corners, Is.EquivalentTo(new[] { new float2(0, 0), new float2(2, 0), new float2(2, 1) })); + var c0 = triangle.c0; var c1 = triangle.c1; var c2 = triangle.c2; + Assert.That((c1.x - c0.x) * (c2.y - c0.y) - (c1.y - c0.y) * (c2.x - c0.x), Is.GreaterThan(0), "counter-clockwise"); + } + + [TestCase(4, 2, "B1", TestName = "A4x2Wall_Takes_The2To1Tile")] + [TestCase(1, 1, "D1", TestName = "A1x1Wall_Takes_The256Square")] + [TestCase(0.5f, 0.5f, "G1", TestName = "AHalfUnitWall_Takes_The128Square")] + [TestCase(1, 2, "E1", TestName = "A1x2Wall_Takes_TheTallTile")] + [TestCase(4, 1, "C1", TestName = "A4x1Wall_Takes_The4To1Tile")] + public void ARectangle_TakesTheTileClosestInShapeThenSize(float width, float height, string expected) + { + using var tiles = new Tiles(TestSheet()); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(width, height), 0, 0); + Assert.That(tiles.NameOf(choice.tile), Is.EqualTo(expected)); + } + + [TestCase(90, false, 0, 1, 0, -1, 0, 0, TestName = "TheFrame_TurnedAQuarter_ReadsWithRightAlongUp")] + [TestCase(180, false, -1, 0, 0, 0, -1, 0, TestName = "TheFrame_TurnedHalfway_IsUpsideDown")] + [TestCase(0, true, -1, 0, 0, 0, 1, 0, TestName = "TheFrame_Mirrored_ReadsWithRightAlongMinusX")] + [TestCase(90, true, 0, -1, 0, -1, 0, 0, TestName = "TheFrame_TurnedThenMirrored")] + public void TheFrame_OfAWallFacingMinusZ(float rotation, bool mirror, float rx, float ry, float rz, float ux, float uy, float uz) + { + HotspotMatcher.Frame(new float3(0, 0, -1), rotation, mirror, out var right, out var up); + Assert.That(math.distance(right, new float3(rx, ry, rz)), Is.LessThan(1e-6f), $"right: {right}"); + Assert.That(math.distance(up, new float3(ux, uy, uz)), Is.LessThan(1e-6f), $"up: {up}"); + } + + [TestCase(0, 0, -1)] + [TestCase(1, 0, 0)] + [TestCase(0, 1, 0)] + [TestCase(0.6f, 0, 0.8f)] + public void TheFrame_OfAnUnturnedBrush_IsTheTreesFrame(float nx, float ny, float nz) + { + var normal = new float3(nx, ny, nz); + HotspotMatcher.Frame(normal, out var right, out var up); + HotspotMatcher.Frame(normal, normal, float4x4.Translate(new float3(5, -3, 2)), 0, false, out var brushRight, out var brushUp); + Assert.That(math.distance(brushRight, right), Is.LessThan(1e-6f), $"right: {brushRight}"); + Assert.That(math.distance(brushUp, up), Is.LessThan(1e-6f), $"up: {brushUp}"); + } + + [Test] + public void TheFrame_TurnsWithItsBrush() + { + // a wall facing -z on a brush rolled 30 degrees around z: up is the brush's up + var nodeToTree = float4x4.TRS(new float3(1, 2, 3), quaternion.RotateZ(math.radians(30)), new float3(1)); + HotspotMatcher.Frame(new float3(0, 0, -1), new float3(0, 0, -1), nodeToTree, 0, false, out var right, out var up); + Assert.That(math.distance(up, new float3(-0.5f, math.sqrt(3) / 2, 0)), Is.LessThan(1e-6f), $"up: {up}"); + Assert.That(math.distance(right, new float3(math.sqrt(3) / 2, 0.5f, 0)), Is.LessThan(1e-6f), $"right: {right}"); + } + + [Test] + public void TheFrame_OfAStretchedBrush_StaysOrthonormal() + { + // a sloped face scaled unevenly: its normal in the tree is no longer the local one turned + var localNormal = math.normalize(new float3(0, 1, 1)); + var nodeToTree = float4x4.TRS(float3.zero, quaternion.RotateY(0.3f), new float3(1, 3, 0.5f)); + var normal = math.normalize(math.mul(math.transpose(math.inverse(nodeToTree)), new float4(localNormal, 0)).xyz); + HotspotMatcher.Frame(normal, localNormal, nodeToTree, 17, false, out var right, out var up); + Assert.That(math.length(right), Is.EqualTo(1).Within(1e-6f)); + Assert.That(math.length(up), Is.EqualTo(1).Within(1e-6f)); + Assert.That(math.dot(right, up), Is.EqualTo(0).Within(1e-6f)); + Assert.That(math.dot(right, normal), Is.EqualTo(0).Within(1e-6f)); + Assert.That(math.dot(up, normal), Is.EqualTo(0).Within(1e-6f)); + } + + [Test] + public void AWallTurnedAQuarter_TakesTheTileTurnedWalls_Take() + { + using var tiles = new Tiles(TestSheet()); + // 2 x 1 turned a quarter is 1 x 2 in its frame: the tall tile + HotspotMatcher.Frame(new float3(0, 0, -1), 90, false, out var right, out var up); + using var positions = new NativeArray(new[] { new float3(0, 0, 0), new float3(2, 0, 0), new float3(2, 1, 0), new float3(0, 1, 0) }, Allocator.Temp); + var shape = HotspotMatcher.ShapeOf(positions, right, up); + Assert.That(math.distance(shape.max - shape.min, new float2(1, 2)), Is.LessThan(1e-6f), $"{shape.max - shape.min}"); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, 0, 0).tile), Is.EqualTo("E1")); + } + + [TestCase(2f, 2f, 1f)] + [TestCase(0.5f, 2f, 4f)] + public void AWallAtADensity_TakesTheTileAWallThatManyTimesItsSizeTakes(float density, float width, float height) + { + using var tiles = new Tiles(TestSheet()); + var dense = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(width * density, height * density), 0, 0); + var asked = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(width, height), 0, 0, density: density); + Assert.That(tiles.NameOf(asked.tile), Is.EqualTo(tiles.NameOf(dense.tile))); + Assert.That(asked.scale, Is.EqualTo(dense.scale).Within(1e-5f)); + } + + [Test] + public void IdenticalTiles_AreToldApartByTheSeed_TheSameSeedAlwaysTheSameTile() + { + using var tiles = new Tiles(TestSheet()); + var shape = Wall(2, 2); + var seen = new HashSet(); + for (uint seed = 0; seed < 16; seed++) + { + var first = tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, seed, 0).tile); + Assert.That(first, Is.EqualTo("A1").Or.EqualTo("A2")); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, seed, 0).tile), Is.EqualTo(first), $"seed {seed} changed its mind"); + seen.Add(first); + } + Assert.That(seen, Is.EquivalentTo(new[] { "A1", "A2" }), "16 seeds took only one of the two identical tiles"); + } + + [Test] + public void WithVariantMaterials_TheSeedPicksATileAndAMaterial_AndOneMaterialPicksTheSameTilesAsBefore() + { + using var tiles = new Tiles(TestSheet()); + var shape = Wall(2, 2); + var three = tiles.data; three.variantCount = 3; + var seen = new HashSet<(string, int)>(); + for (uint seed = 0; seed < 64; seed++) + { + var alone = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, seed, 0); + Assert.That(alone.variant, Is.Zero); + Assert.That(alone.variations, Is.EqualTo(2), "two identical tiles"); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), three, shape, seed, 0); + Assert.That(choice.variations, Is.EqualTo(6), "two tiles times three materials"); + Assert.That(choice.variant, Is.InRange(0, 2)); + seen.Add((tiles.NameOf(choice.tile), choice.variant)); + } + Assert.That(seen.Count, Is.EqualTo(6), "64 seeds reach every tile with every material"); + var pinned = HotspotMatcher.Choose(tiles.list.AsArray(), three, shape, 5, tiles.layout.tiles.Find(t => t.name == "A1").id); + Assert.That(pinned.variations, Is.EqualTo(3), "a pinned tile still takes any of the materials"); + Assert.That(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, 5, tiles.layout.tiles.Find(t => t.name == "A1").id).variations, + Is.EqualTo(1), "a pinned tile with one material: nothing to vary"); + } + + [Test] + public void APinnedTile_IsKept_EvenWhenAnotherFitsBetter_AndADisabledOne() + { + using var tiles = new Tiles(TestSheet()); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(2, 2), 0, 4).tile), Is.EqualTo("C1")); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(2, 2), 0, 9).tile), Is.EqualTo("X"), "pinned to a disabled tile"); + } + + [Test] + public void DisabledRadialAndTriangleTiles_AreNotTakenByARectangle() + { + using var tiles = new Tiles(TestSheet()); + for (float size = 0.25f; size <= 16; size *= 2) + { + foreach (var shape in new[] { Wall(size, size), Wall(size * 2, size), Wall(size, size * 2) }) + { + var name = tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, 7, 0).tile); + Assert.That(name, Is.Not.EqualTo("X").And.Not.EqualTo("R1").And.Not.EqualTo("Tr6"), $"a {shape.max - shape.min} wall"); + } + } + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(2, 2), 0, 0, radial: true).tile), Is.EqualTo("R1"), "a radial surface takes the radial tile"); + } + + [Test] + public void ATileThatMayTurn_IsTurned_WhenThatFitsBetter() + { + using var tiles = new Tiles(Layout(Rect(1, "square", 0, 0, 512, 512), Rect(2, "tall, may turn", 512, 0, 512, 1024, HotspotTileFlags.AllowRotation))); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(4, 2), 0, 0); + Assert.That(tiles.NameOf(choice.tile), Is.EqualTo("tall, may turn")); + Assert.That(choice.rotated, Is.True); + + using var fixedTiles = new Tiles(Layout(Rect(1, "square", 0, 0, 512, 512), Rect(2, "tall", 512, 0, 512, 1024))); + Assert.That(fixedTiles.NameOf(HotspotMatcher.Choose(fixedTiles.list.AsArray(), fixedTiles.data, Wall(4, 2), 0, 0).tile), Is.EqualTo("square"), "a tile that may not turn"); + } + + [Test] + public void ARectangle_MapsItsBoundsOntoTheTile_UpStaysUp() + { + using var tiles = new Tiles(TestSheet()); + var shape = Wall(4, 2); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, 0, 0); + var mapping = HotspotMatcher.Mapping(tiles.list[choice.tile], tiles.data, shape, choice, new float3(0, 0, -1)); + // B1 is pixels 1024..2048 x 0..512 from the top: u 0.5..1, v 0.75..1 + AreEqual(new float2(0.5f, 0.75f), UV(mapping, new float3(0, 0, 0)), "bottom left"); + AreEqual(new float2(1.0f, 0.75f), UV(mapping, new float3(4, 0, 0)), "bottom right"); + AreEqual(new float2(0.5f, 1.0f), UV(mapping, new float3(0, 2, 0)), "top left"); + AreEqual(new float2(1.0f, 1.0f), UV(mapping, new float3(4, 2, 0)), "top right"); + } + + [Test] + public void ARectangleTurned_MapsWithoutMirroring() + { + using var tiles = new Tiles(Layout(Rect(1, "tall, may turn", 0, 0, 512, 1024, HotspotTileFlags.AllowRotation))); + var shape = Wall(4, 2); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, 0, 0); + Assert.That(choice.rotated, Is.True); + var mapping = HotspotMatcher.Mapping(tiles.list[choice.tile], tiles.data, shape, choice, new float3(0, 0, -1)); + var a = UV(mapping, new float3(0, 0, 0)); var b = UV(mapping, new float3(4, 0, 0)); var c = UV(mapping, new float3(0, 2, 0)); + Assert.That((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x), Is.GreaterThan(0), "the texture is mirrored"); + } + + // A right triangle 1 x 0.5 facing -z, its right angle in a corner of its bounds: 0 bottom right, 1 top left + static HotspotShape RightTriangle(int corner) + { + var points = corner == 0 ? new[] { new float3(0, 0, 0), new float3(1, 0, 0), new float3(1, 0.5f, 0) } + : new[] { new float3(0, 0.5f, 0), new float3(0, 0, 0), new float3(1, 0.5f, 0) }; + using var positions = new NativeArray(points, Allocator.Temp); + return HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + } + + // Two right triangle tiles 256 x 128 cut from one rectangle by the diagonal from its bottom left to its top right + static ChiselHotspotLayout TwoRightTriangles(HotspotTileFlags flags = HotspotTileFlags.None) + { + return Layout(Triangle(1, "right angle top left", new Vector2Int(0, 0), new Vector2Int(256, 0), new Vector2Int(0, 128), flags), + Triangle(2, "right angle bottom right", new Vector2Int(256, 0), new Vector2Int(256, 128), new Vector2Int(0, 128), flags)); + } + + [Test] + public void ATriangle_KeepsTheTexturesUp_TakingTheTileTurnedTheSameWay() + { + using var tiles = new Tiles(TwoRightTriangles()); + for (uint seed = 0; seed < 16; seed++) + { + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, RightTriangle(0), seed, 0).tile), Is.EqualTo("right angle bottom right"), $"seed {seed}"); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, RightTriangle(1), seed, 0).tile), Is.EqualTo("right angle top left"), $"seed {seed}"); + } + } + + [Test] + public void ATriangleTileThatMayTurn_IsTurnedToFit() + { + using var tiles = new Tiles(Layout(Triangle(1, "right angle top left, may turn", new Vector2Int(0, 0), new Vector2Int(256, 0), new Vector2Int(0, 128), HotspotTileFlags.AllowRotation))); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, RightTriangle(0), 0, 0); + Assert.That(choice.score, Is.LessThan(1e-4f), "turned half a turn it fits exactly"); + } + + [Test] + public void ATilesOwnPixelsPerUnit_DecidesTheSizeItFits() + { + var layout = Layout(Rect(1, "512 square", 0, 0, 512, 512), Rect(2, "512 square at 128 a unit", 512, 0, 512, 512)); + var tile = layout.tiles[1]; tile.pixelsPerUnit = 128; layout.tiles[1] = tile; + using var tiles = new Tiles(layout); + // 512 pixels are 2 units at the layout's 256 a unit, and 4 at the tile's own 128 + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(2, 2), 0, 0).tile), Is.EqualTo("512 square")); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(4, 4), 0, 0).tile), Is.EqualTo("512 square at 128 a unit")); + } + + // A regular polygon of this many corners around (0, 0), facing -z, stretched along x + static HotspotShape Polygon(int corners, float radius, float stretch = 1) + { + var points = new float3[corners]; + for (int i = 0; i < corners; i++) + { + var angle = 2 * math.PI * i / corners; + points[i] = new float3(math.cos(angle) * radius * stretch, math.sin(angle) * radius, 0); + } + using var positions = new NativeArray(points, Allocator.Temp); + return HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + } + + [Test] + public void ARoundSurface_IsFound_AndTakesTheRadialTile() + { + Assert.That(Polygon(24, 1).radial, Is.True, "24 corners"); + Assert.That(Polygon(8, 1).radial, Is.True, "8 corners"); + Assert.That(Polygon(6, 1).radial, Is.False, "a hexagon"); + Assert.That(Wall(2, 2).radial, Is.False, "a square"); + Assert.That(Polygon(24, 1, 2).radial, Is.False, "an ellipse"); + using var tiles = new Tiles(TestSheet()); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Polygon(24, 1), 0, 0).tile), Is.EqualTo("R1")); + } + + [Test] + public void ARoundSurface_WithoutRadialTiles_TakesAnotherTile() + { + using var tiles = new Tiles(Layout(Rect(1, "A1", 0, 0, 512, 512), Rect(2, "B1", 1024, 0, 1024, 512))); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Polygon(24, 1), 0, 0).tile), Is.EqualTo("A1")); + } + + [Test] + public void AnAlternateTile_IsOnlyTakenPinned() + { + using var tiles = new Tiles(Layout(Rect(1, "A1", 0, 0, 512, 512, HotspotTileFlags.Alternate), Rect(2, "B1", 1024, 0, 1024, 512))); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(2, 2), 0, 0).tile), Is.EqualTo("B1")); + Assert.That(tiles.NameOf(HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(2, 2), 0, 1).tile), Is.EqualTo("A1")); + } + + [Test] + public void ASurfaceFarLargerThanAnyTile_RepeatsATile_AsALastResort() + { + using var tiles = new Tiles(TestSheet()); + // 40 x 3 units: the tiles are at most 4 x 2; stretched, the best of them would be magnified about six times + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(40, 3), 0, 0); + Assert.That(choice.repeatX * math.max(1, choice.repeatY), Is.GreaterThan(1), $"{tiles.NameOf(choice.tile)} was not repeated"); + Assert.That(choice.scale, Is.GreaterThan(HotspotMatcher.kLastResortScale)); + // the fewest repeats: B1 (4 x 2) ten times by two, not the small tiles hundreds of times + Assert.That(tiles.NameOf(choice.tile), Is.EqualTo("B1")); + Assert.That(new int2(choice.repeatX, choice.repeatY), Is.EqualTo(new int2(10, 2))); + Assert.That(choice.lastResort, Is.True, "it says it was a last resort"); + // a 4 x 2 wall fits B1 and is not repeated + var fits = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, Wall(4, 2), 0, 0); + Assert.That(math.max(1, fits.repeatX) * math.max(1, fits.repeatY), Is.EqualTo(1)); + Assert.That(fits.lastResort, Is.False); + } + + [Test] + public void ATriangle_TakesATriangleTile_CornerToCorner() + { + using var tiles = new Tiles(TestSheet()); + // the same right triangle as the tile, 1 x 0.5 units, its right angle at the bottom right + using var positions = new NativeArray(new[] { new float3(0, 0, 0), new float3(1, 0, 0), new float3(1, 0.5f, 0) }, Allocator.Temp); + var shape = HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + Assert.That(shape.cornerCount, Is.EqualTo(3)); + var choice = HotspotMatcher.Choose(tiles.list.AsArray(), tiles.data, shape, 0, 0); + Assert.That(tiles.NameOf(choice.tile), Is.EqualTo("Tr6")); + Assert.That(choice.score, Is.LessThan(1e-4f), "the same shape and size"); + var mapping = HotspotMatcher.Mapping(tiles.list[choice.tile], tiles.data, shape, choice, new float3(0, 0, -1)); + // Tr6: (1792, 1024) top right, (1792, 1152) bottom right - the right angle -, (1536, 1152) bottom left, from the top + AreEqual(new float2(1536f / 2048, (2048f - 1152) / 2048), UV(mapping, new float3(0, 0, 0)), "the sharp corner"); + AreEqual(new float2(1792f / 2048, (2048f - 1152) / 2048), UV(mapping, new float3(1, 0, 0)), "the right angle"); + AreEqual(new float2(1792f / 2048, (2048f - 1024) / 2048), UV(mapping, new float3(1, 0.5f, 0)), "the top"); + } + } +} diff --git a/Core/Tests/HotspotMatcherTests.cs.meta b/Core/Tests/HotspotMatcherTests.cs.meta new file mode 100644 index 00000000..a126dbf2 --- /dev/null +++ b/Core/Tests/HotspotMatcherTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3498b3ea44f9fa3439a21ecce00535a8 \ No newline at end of file diff --git a/Core/Tests/HotspotRepeatTests.cs b/Core/Tests/HotspotRepeatTests.cs new file mode 100644 index 00000000..005e358e --- /dev/null +++ b/Core/Tests/HotspotRepeatTests.cs @@ -0,0 +1,224 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class HotspotRepeatTests + { + // A 2048 sheet at 256 pixels a unit with the given tiles + sealed class Sheet : System.IDisposable + { + public ChiselHotspotLayout layout; + public NativeList tiles = new(Allocator.Temp); + public HotspotLayoutData data; + public HotspotSections sections = HotspotSections.Create(Allocator.Temp); + + public Sheet(params HotspotTile[] tiles) + { + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List(tiles); + HotspotMatcher.AddTiles(layout, this.tiles, out data); + } + + public HotspotChoice Choose(in HotspotShape shape) => HotspotMatcher.Choose(tiles.AsArray(), data, shape, 0, 0); + + public void Dispose() + { + sections.Dispose(); + tiles.Dispose(); + Object.DestroyImmediate(layout); + } + } + + static HotspotTile Rect(int id, int x, int y, int w, int h, HotspotTileFlags flags = HotspotTileFlags.None, int border = 0) + { + return new HotspotTile { id = id, name = "tile " + id, flags = flags, border = border, + points = new[] { new Vector2Int(x, y), new Vector2Int(x + w, y), new Vector2Int(x + w, y + h), new Vector2Int(x, y + h) } }; + } + + // 2048 x 128 pixels at the bottom of the sheet: 8 x 0.5 units, repeating across + static HotspotTile Strip(HotspotTileFlags flags = HotspotTileFlags.RepeatU) => Rect(1, 0, 1920, 2048, 128, flags); + + static HotspotShape Wall(float width, float height) + { + using var positions = new NativeArray(new[] { new float3(0, 0, 0), new float3(width, 0, 0), new float3(width, height, 0), new float3(0, height, 0) }, Allocator.Temp); + return HotspotMatcher.ShapeOf(positions, new float3(0, 0, -1)); + } + + [TestCase(40, 5, TestName = "A40UnitStrip_Repeats5Times")] + [TestCase(41, 5, TestName = "A41UnitStrip_Repeats5Times_StretchedALittle")] + [TestCase(2, 1, TestName = "A2UnitStrip_TakesItOnce")] + public void AStrip_RepeatsTheTile_AsManyTimesAsFitAtItsHeight(float length, int repeats) + { + using var sheet = new Sheet(Strip()); + var choice = sheet.Choose(Wall(length, 0.5f)); + Assert.That(choice.tile, Is.EqualTo(0)); + Assert.That(math.max(1, choice.repeatX), Is.EqualTo(repeats)); + Assert.That(math.max(1, choice.repeatY), Is.EqualTo(1)); + } + + [Test] + public void ATileThatRepeats_FitsBetter_ThanOneThatStretches() + { + using var sheet = new Sheet(Strip()); + using var stretched = new Sheet(Strip(HotspotTileFlags.None)); + Assert.That(sheet.Choose(Wall(40, 0.5f)).score, Is.LessThan(1e-4f)); + Assert.That(stretched.Choose(Wall(40, 0.5f)).score, Is.GreaterThan(1)); + } + + [Test] + public void ATileThatRepeatsBothWays_RepeatsAtItsDensity() + { + using var sheet = new Sheet(Rect(1, 0, 0, 256, 256, HotspotTileFlags.RepeatU | HotspotTileFlags.RepeatV)); + var choice = sheet.Choose(Wall(3, 2)); + Assert.That(new int2(choice.repeatX, choice.repeatY), Is.EqualTo(new int2(3, 2))); + } + + [Test] + public void EachRepeat_IsTheWholeTile_AndTheSeamsHaveAVertexForEachSide() + { + using var sheet = new Sheet(Strip()); + var shape = Wall(40, 0.5f); + var choice = sheet.Choose(shape); + Assert.That(sheet.sections.Build(sheet.tiles[0], sheet.data, shape, choice), Is.True); + Assert.That(sheet.sections.cells.Length, Is.EqualTo(5)); + // u across the strip's 2048 pixels, v 0 .. 128 / 2048 at the bottom of the sheet + Assert.That(math.distance(sheet.sections.UV(new double2(4, 0.25)), new float2(0.5f, 0.03125f)), Is.LessThan(1e-6f)); + Assert.That(math.distance(sheet.sections.UV(new double2(12, 0.25)), new float2(0.5f, 0.03125f)), Is.LessThan(1e-6f), "the second repeat"); + Assert.That(sheet.sections.UV(new double2(8 - 1e-6, 0)).x, Is.EqualTo(1).Within(1e-5f), "the end of the first"); + Assert.That(sheet.sections.UV(new double2(8 + 1e-6, 0)).x, Is.EqualTo(0).Within(1e-5f), "the start of the second"); + + using var indices = new NativeList(Allocator.Temp) { 0, 1, 2, 0, 2, 3 }; + using var collider = new NativeList(Allocator.Temp) { new float3(0, 0, 0), new float3(40, 0, 0), new float3(40, 0.5f, 0), new float3(0, 0.5f, 0) }; + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in collider) + { + select.Add(new SelectVertex { position = p }); + render.Add(new RenderVertex { position = p, normal = new float3(0, 0, -1) }); + } + var right = new float3(1, 0, 0); var up = new float3(0, 1, 0); + sheet.sections.Split(right, up, new float3(0, 0, -1), indices, collider, select, render); + sheet.sections.AssignUVs(indices, collider, select, render, right, up); + Assert.That(render.Length, Is.EqualTo(collider.Length)); + Assert.That(select.Length, Is.EqualTo(collider.Length)); + for (int t = 0; t < indices.Length; t += 3) + { + var a = render[indices[t]].uv0; var b = render[indices[t + 1]].uv0; var c = render[indices[t + 2]].uv0; + var minU = math.min(a.x, math.min(b.x, c.x)); var maxU = math.max(a.x, math.max(b.x, c.x)); + Assert.That(minU, Is.GreaterThanOrEqualTo(-1e-5f), $"triangle {t / 3} reaches out of the tile"); + Assert.That(maxU, Is.LessThanOrEqualTo(1 + 1e-5f), $"triangle {t / 3} reaches out of the tile"); + // the tile's u for each corner, from where it is in its repeat + foreach (var i in new[] { indices[t], indices[t + 1], indices[t + 2] }) + { + var x = render[i].position.x; + var within = x / 8 - math.floor(x / 8); + var u = render[i].uv0.x; + Assert.That(math.abs(u - within) < 1e-4f || (within < 1e-4f && math.abs(u - 1) < 1e-4f), $"a vertex at x {x} has u {u}"); + } + } + var atSeam = 0; + for (int i = 0; i < render.Length; i++) + atSeam += math.abs(render[i].position.x - 8) < 1e-5f && render[i].position.y == 0 ? 1 : 0; + Assert.That(atSeam, Is.EqualTo(2), "the seam's bottom corner, once for each side"); + } + + static HotspotSectionInput Moved(Sheet sheet, in HotspotShape shape, float2 offset) + { + var choice = sheet.Choose(shape); + return HotspotSections.Input(sheet.tiles[choice.tile], sheet.data, shape, choice, new float3(1, 0, 0), new float3(0, 1, 0), 1, offset); + } + + [Test] + public void RepeatsMovedAlongHalfARepeat_StartHalfWayAlongTheTile() + { + using var sheet = new Sheet(Strip()); + Assert.That(sheet.sections.Build(Moved(sheet, Wall(40, 0.5f), new float2(0.5f, 0))), Is.True); + // five 8 unit repeats moved along by 4: half of one, four whole ones, half of one + Assert.That(sheet.sections.cells.Length, Is.EqualTo(6)); + Assert.That(sheet.sections.UV(new double2(0, 0.25)).x, Is.EqualTo(0.5f).Within(1e-6f), "the start"); + Assert.That(sheet.sections.UV(new double2(2, 0.25)).x, Is.EqualTo(0.75f).Within(1e-6f)); + Assert.That(sheet.sections.UV(new double2(4 - 1e-6, 0)).x, Is.EqualTo(1).Within(1e-5f), "the end of the first"); + Assert.That(sheet.sections.UV(new double2(4 + 1e-6, 0)).x, Is.EqualTo(0).Within(1e-5f), "the start of the second"); + Assert.That(sheet.sections.UV(new double2(40, 0.25)).x, Is.EqualTo(0.5f).Within(1e-6f), "the end"); + // whole repeats moved along are where they were + Assert.That(sheet.sections.Build(Moved(sheet, Wall(40, 0.5f), new float2(3, 0))), Is.True); + Assert.That(sheet.sections.cells.Length, Is.EqualTo(5)); + Assert.That(sheet.sections.UV(new double2(2, 0.25)).x, Is.EqualTo(0.25f).Within(1e-6f)); + } + + [Test] + public void ASurfaceOneRepeatLong_MovedAlong_IsCutInTwo_ButNotAlongTheAxisThatDoesNotRepeat() + { + using var sheet = new Sheet(Strip()); + var input = Moved(sheet, Wall(8, 0.5f), new float2(0.25f, 0.5f)); + Assert.That(input.offset, Is.EqualTo(new float2(0.25f, 0)), "the strip does not repeat up"); + Assert.That(sheet.sections.Build(input), Is.True); + Assert.That(sheet.sections.cells.Length, Is.EqualTo(2)); + Assert.That(sheet.sections.UV(new double2(0, 0.25)).x, Is.EqualTo(0.25f).Within(1e-6f)); + Assert.That(sheet.sections.UV(new double2(6 + 1e-6, 0.25)).x, Is.EqualTo(0).Within(1e-5f), "the seam, 6 along"); + Assert.That(sheet.sections.UV(new double2(8, 0.25)).x, Is.EqualTo(0.25f).Within(1e-6f), "the end"); + } + + [Test] + public void PartialRepeats_KeepTheirSize_AndTheLastIsCutOff() + { + using var sheet = new Sheet(Strip(HotspotTileFlags.RepeatU | HotspotTileFlags.PartialRepeats)); + var shape = Wall(20, 0.5f); + var choice = sheet.Choose(shape); + // 8 units a repeat: 8, 8 and half of the third + Assert.That(choice.repeatX, Is.EqualTo(3)); + Assert.That(choice.lastRepeat.x, Is.EqualTo(0.5f).Within(1e-6f)); + Assert.That(choice.score, Is.LessThan(1e-4f), "nothing is stretched"); + Assert.That(sheet.sections.Build(sheet.tiles[0], sheet.data, shape, choice), Is.True); + Assert.That(sheet.sections.UV(new double2(17, 0.25)).x, Is.EqualTo(0.125f).Within(1e-6f), "a unit into the third repeat"); + Assert.That(sheet.sections.UV(new double2(20, 0.25)).x, Is.EqualTo(0.5f).Within(1e-6f), "the end: half way along the tile"); + + // a whole number of repeats is not cut + var whole = sheet.Choose(Wall(16, 0.5f)); + Assert.That(whole.repeatX, Is.EqualTo(2)); + } + + [Test] + public void ABorderThatRepeatsPartially_KeepsItsRepeatsTheirSize() + { + using var sheet = new Sheet(Rect(1, 1024, 0, 1024, 512, HotspotTileFlags.RepeatBorder | HotspotTileFlags.PartialRepeats, 32)); + var shape = Wall(16, 2); + Assert.That(sheet.sections.Build(sheet.tiles[0], sheet.data, shape, sheet.Choose(shape)), Is.True); + // the bottom strip: 15.75 units of 3.75 unit repeats, the fifth a fifth of one + const double start = 0.125, period = 3.75; + for (int r = 0; r < 5; r++) + Assert.That(sheet.sections.UV(new double2(start + period * r + 1e-6, 0.0625)).x * 2048, Is.EqualTo(1024 + 32).Within(0.01f), $"repeat {r} starts at the strip's start"); + Assert.That(sheet.sections.UV(new double2(start + period * 4.1, 0.0625)).x * 2048, Is.EqualTo(1024 + 32 + 96).Within(0.01f), "a tenth into the fifth"); + } + + [Test] + public void ABorderThatRepeats_RepeatsAlongTheEdges_AtItsPixelWidth() + { + // 1024 x 512, border 32: its strip along the bottom is 960 pixels long + using var sheet = new Sheet(Rect(1, 1024, 0, 1024, 512, HotspotTileFlags.RepeatBorder, 32)); + var shape = Wall(16, 2); + var choice = sheet.Choose(shape); + Assert.That(sheet.sections.Build(sheet.tiles[0], sheet.data, shape, choice), Is.True); + // the bottom strip is 16 - 2 x 0.125 = 15.75 units; the tile's 960 pixels at the band's 32 pixels an eighth of + // a unit are 3.75 units: 4 repeats of 3.9375 + const double start = 0.125, period = 15.75 / 4; + for (int r = 0; r < 4; r++) + { + var uvStart = sheet.sections.UV(new double2(start + period * r + 1e-6, 0.0625)); + var uvMiddle = sheet.sections.UV(new double2(start + period * (r + 0.5), 0.0625)); + Assert.That(uvStart.x * 2048, Is.EqualTo(1024 + 32).Within(0.01f), $"repeat {r} starts at the start of the tile's strip"); + Assert.That(uvMiddle.x * 2048, Is.EqualTo(1024 + 32 + 480).Within(0.01f), $"repeat {r} has the middle of the strip in its middle"); + Assert.That(uvMiddle.y * 2048, Is.EqualTo(1536 + 16).Within(0.01f), "half way across the band"); + } + // the left strip is 1.75 units, the tile's 448 pixels make 1.75 units: once + Assert.That(sheet.sections.UV(new double2(0.0625, 1)).y * 2048, Is.EqualTo(1792).Within(0.01f)); + } + } +} diff --git a/Core/Tests/HotspotRepeatTests.cs.meta b/Core/Tests/HotspotRepeatTests.cs.meta new file mode 100644 index 00000000..c7336feb --- /dev/null +++ b/Core/Tests/HotspotRepeatTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f7bb64ea060f12d47ab4b2b4776e8408 \ No newline at end of file diff --git a/Core/Tests/HotspotStoreTests.cs b/Core/Tests/HotspotStoreTests.cs new file mode 100644 index 00000000..fce9f273 --- /dev/null +++ b/Core/Tests/HotspotStoreTests.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + // The registry of the hotspot layouts surfaces are drawn with (ChiselHotspotStore) + [TestFixture] + public class HotspotStoreTests + { + ChiselHotspotLayout first, second; + + static ChiselHotspotLayout Layout(params int[] widths) + { + var layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List(); + for (int i = 0; i < widths.Length; i++) + layout.tiles.Add(new HotspotTile { id = i + 1, name = "T" + i, points = new[] { new Vector2Int(0, i * 512), new Vector2Int(widths[i], i * 512), + new Vector2Int(widths[i], i * 512 + 256), new Vector2Int(0, i * 512 + 256) } }); + return layout; + } + + [SetUp] + public void SetUp() + { + first = Layout(512, 1024); + second = Layout(256); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(first); + Object.DestroyImmediate(second); + } + + [Test] + public void ALayout_IsRegisteredOnce_AndNoLayoutIsNoId() + { + var a = ChiselHotspotStore.Register(first); + Assert.That(a, Is.GreaterThan(0)); + Assert.That(ChiselHotspotStore.Register(first), Is.EqualTo(a), "the same layout, the same id"); + var b = ChiselHotspotStore.Register(second); + Assert.That(b, Is.Not.EqualTo(a).And.GreaterThan(0), "another layout, another id"); + Assert.That(ChiselHotspotStore.Register(null), Is.Zero, "none"); + } + + [Test] + public void TheTilesOfALayout_AreWhereItsEntryPoints() + { + var a = ChiselHotspotStore.Register(first); + var b = ChiselHotspotStore.Register(second); + using var tiles = new NativeList(Allocator.Temp); + using var layouts = new NativeList(Allocator.Temp); + ChiselHotspotStore.Gather(tiles, layouts); + Assert.That(layouts.Length, Is.GreaterThanOrEqualTo(System.Math.Max(a, b))); + Assert.That(layouts[a - 1].tileCount, Is.EqualTo(2), "two tiles"); + Assert.That(layouts[b - 1].tileCount, Is.EqualTo(1), "one tile"); + Assert.That(layouts[b - 1].tileStart, Is.Not.EqualTo(layouts[a - 1].tileStart), "not on top of each other"); + Assert.That(layouts[a - 1].tileStart + layouts[a - 1].tileCount, Is.LessThanOrEqualTo(tiles.Length)); + } + + [Test] + public void AnEditedLayout_ChangesTheGatheredHashAndItsOwnLayoutHashOnly() + { + var a = ChiselHotspotStore.Register(first); + var b = ChiselHotspotStore.Register(second); + using var tiles = new NativeList(Allocator.Temp); + using var layouts = new NativeList(Allocator.Temp); + using var before = new NativeList(Allocator.Temp); + using var after = new NativeList(Allocator.Temp); + var gathered = ChiselHotspotStore.Gather(tiles, layouts); + ChiselHotspotStore.LayoutHashes(before); + Assert.That(ChiselHotspotStore.Gather(tiles, layouts), Is.EqualTo(gathered), "nothing changed, the same hash"); + + var tile = first.tiles[0]; + tile.border = 32; + first.tiles[0] = tile; + Assert.That(ChiselHotspotStore.Gather(tiles, layouts), Is.Not.EqualTo(gathered), "a tile's border"); + ChiselHotspotStore.LayoutHashes(after); + Assert.That(after[a - 1], Is.Not.EqualTo(before[a - 1]), "the edited layout"); + Assert.That(after[b - 1], Is.EqualTo(before[b - 1]), "the other layout is not mapped again"); + } + + [Test] + public void ALayoutThatIsGone_IsKeptAsNothing() + { + var a = ChiselHotspotStore.Register(first); + var other = Layout(128); + var c = ChiselHotspotStore.Register(other); + Object.DestroyImmediate(other); + using var tiles = new NativeList(Allocator.Temp); + using var layouts = new NativeList(Allocator.Temp); + using var hashes = new NativeList(Allocator.Temp); + ChiselHotspotStore.Gather(tiles, layouts); + ChiselHotspotStore.LayoutHashes(hashes); + Assert.That(layouts[c - 1].tileCount, Is.Zero, "no tiles"); + Assert.That(hashes[c - 1], Is.Zero, "no hash"); + Assert.That(layouts[a - 1].tileCount, Is.EqualTo(2), "the others are as they were"); + } + + [Test] + public void BudgetsPutBackByAnUndo_AreFoundOnce() + { + var repeats = ChiselProjectSettings.HotspotMaxRepeats; + var triangles = ChiselProjectSettings.HotspotMaxTriangles; + try + { + ChiselHotspotStore.SettingsChanged(); + Assert.That(ChiselHotspotStore.CheckSettings(), Is.False, "nothing changed"); + ChiselProjectSettings.HotspotMaxTriangles = triangles + 1000; + Assert.That(ChiselHotspotStore.CheckSettings(), Is.True, "the budget changed without a word"); + Assert.That(ChiselHotspotStore.CheckSettings(), Is.False, "told once"); + ChiselProjectSettings.HotspotMaxRepeats = repeats + 7; + Assert.That(ChiselHotspotStore.CheckSettings(), Is.True, "the other budget"); + } + finally + { + ChiselProjectSettings.HotspotMaxRepeats = repeats; + ChiselProjectSettings.HotspotMaxTriangles = triangles; + ChiselHotspotStore.SettingsChanged(); + } + } + } +} diff --git a/Core/Tests/HotspotStoreTests.cs.meta b/Core/Tests/HotspotStoreTests.cs.meta new file mode 100644 index 00000000..e57f9007 --- /dev/null +++ b/Core/Tests/HotspotStoreTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 25cccc0b05f04f4c9ea79223a5c54f55 diff --git a/Core/Tests/InternedPlanesTests.cs b/Core/Tests/InternedPlanesTests.cs new file mode 100644 index 00000000..1f71b6db --- /dev/null +++ b/Core/Tests/InternedPlanesTests.cs @@ -0,0 +1,274 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class InternedPlanesTests + { + static float4 Plane(float3 normal, float3 pointOn) + { + var n = math.normalize(normal); + return new float4(n, -math.dot(n, pointOn)); + } + + [Test] + public void IdenticalPlanes_ShareOneId() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var on = new float3(0, 5, 0); + var p = Plane(new float3(0, 1, 0), on); + + var a = table.Add(p, on, out bool flippedA); + var b = table.Add(p, on, out bool flippedB); + + Assert.That(b, Is.EqualTo(a)); + Assert.That(flippedA, Is.False); + Assert.That(flippedB, Is.False); + Assert.That(table.Count, Is.EqualTo(1), "an identical plane must not create a second entry"); + } + + [Test] + public void OppositeFacingPlane_SharesIdAndReportsFlipped() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var on = new float3(0, 5, 0); + var up = Plane(new float3(0, 1, 0), on); + var down = Plane(new float3(0, -1, 0), on); + + var a = table.Add(up, on, out bool flippedA); + var b = table.Add(down, on, out bool flippedB); + + Assert.That(b, Is.EqualTo(a), "a face and the face it abuts lie on ONE plane"); + Assert.That(flippedA, Is.False); + Assert.That(flippedB, Is.True, "the caller must be told, or its inside/outside test inverts"); + Assert.That(table.Count, Is.EqualTo(1)); + } + + [Test] + public void PlanesWithinTolerance_Share_OutsideTolerance_Differ() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var onBase = new float3(10, 0, 0); + var baseline = Plane(new float3(1, 0, 0), onBase); + + // shifted by less than kPlaneDAlignEpsilon (0.0006), and by clearly more (0.0184) + var onWithin = new float3(10 + 0.0004f, 0, 0); + var onWithout = new float3(10 + 0.0184f, 0, 0); + + var idBase = table.Add(baseline, onBase, out _); + var idWithin = table.Add(Plane(new float3(1, 0, 0), onWithin), onWithin, out _); + var idWithout = table.Add(Plane(new float3(1, 0, 0), onWithout), onWithout, out _); + + Assert.That(idWithin, Is.EqualTo(idBase)); + Assert.That(idWithout, Is.Not.EqualTo(idBase), + "0.0184 apart is two genuinely different walls; merging them would delete geometry"); + Assert.That(table.Count, Is.EqualTo(2)); + } + + [Test] + public void UnnormalizedInput_MatchesNormalized() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var on = new float3(0, 0, 3); + var p = Plane(new float3(0, 0, 1), on); + + var a = table.Add(p, on, out _); + var b = table.Add(p * 7.5f, on, out bool flipped); // same plane, arbitrary scale + + Assert.That(b, Is.EqualTo(a)); + Assert.That(flipped, Is.False); + Assert.That(table.Count, Is.EqualTo(1)); + } + + [Test] + public void NegativelyScaledInput_MatchesAndReportsFlipped() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var on = new float3(0, 0, 3); + var p = Plane(new float3(0, 0, 1), on); + + var a = table.Add(p, on, out _); + var b = table.Add(p * -2.0f, on, out bool flipped); + + Assert.That(b, Is.EqualTo(a)); + Assert.That(flipped, Is.True); + } + + // Two near-identical normals either side of a hemisphere rule's boundary still intern together + [Test] + public void NormalsStraddlingAHemisphereBoundary_StillShareOneId() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var on = new float3(0, 3.0053f, 3.0053f); + var na = math.normalize(new float3(+1e-8f, 0.70710678f, 0.70710678f)); + var nb = math.normalize(new float3(-1e-8f, 0.70710678f, 0.70710678f)); + + var idA = table.Add(Plane(na, on), on, out _); + var idB = table.Add(Plane(nb, on), on, out _); + + Assert.That(idB, Is.EqualTo(idA)); + Assert.That(table.Count, Is.EqualTo(1)); + } + + [Test] + public void ParallelButDistinctPlanes_StayDistinct() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var on0 = new float3(0, 0, 0); + var on1 = new float3(1, 0, 0); + + var id0 = table.Add(Plane(new float3(1, 0, 0), on0), on0, out _); + var id1 = table.Add(Plane(new float3(1, 0, 0), on1), on1, out _); + var id0b = table.Add(Plane(new float3(-1, 0, 0), on0), on0, out bool flipped); + + Assert.That(id1, Is.Not.EqualTo(id0), "two walls a metre apart are two planes"); + Assert.That(id0b, Is.EqualTo(id0)); + Assert.That(flipped, Is.True); + Assert.That(table.Count, Is.EqualTo(2)); + } + + const float kTilt = 4e-4f; + static float4 TiltedThroughOrigin() { return Plane(new float3(0, 1, kTilt), float3.zero); } + static float3 OnTiltedAt(float z) { return new float3(0, -kTilt * z, z); } + + [Test] + public void TiltIsInsideTheAngularTolerance_SoOnlyThePointTestCanRejectIt() + { + var flat = Plane(new float3(0, 1, 0), float3.zero); + var deviation = 1.0 - math.abs(math.dot(flat.xyz, TiltedThroughOrigin().xyz)); + Assert.That(deviation, Is.LessThan(InternedPlanes.kMaxNormalDeviation), + "if this ever fails the two tests below stop testing what they claim"); + // and the far point really is on the tilted plane, not the flat one + Assert.That(math.abs(InternedPlanes.Distance(TiltedThroughOrigin(), OnTiltedAt(100))), + Is.LessThan(1e-5)); + Assert.That(math.abs(InternedPlanes.Distance(flat, OnTiltedAt(100))), + Is.GreaterThan(CSGConstants.kPlaneDAlignEpsilon)); + } + + [Test] + public void NearlyParallelPlanes_ThatDivergeFarFromOrigin_AreNotMerged() + { + using var table = new InternedPlanes(8, Allocator.Temp); + + var idFlat = table.Add(Plane(new float3(0, 1, 0), float3.zero), float3.zero, out _); + var idTilted = table.Add(TiltedThroughOrigin(), OnTiltedAt(100), out _); + + Assert.That(idTilted, Is.Not.EqualTo(idFlat), + "they agree exactly at the origin but are 0.04 apart where the geometry is"); + Assert.That(table.Count, Is.EqualTo(2)); + } + + [Test] + public void AllSuppliedPoints_MustLieOnTheMatchedPlane() + { + var flat = Plane(new float3(0, 1, 0), float3.zero); + + // vouching only for the shared point at the origin accepts the match ... + using (var nearOnly = new InternedPlanes(8, Allocator.Temp)) + { + var idFlat = nearOnly.Add(flat, float3.zero, out _); + var idTilt = nearOnly.Add(TiltedThroughOrigin(), float3.zero, out _); + Assert.That(idTilt, Is.EqualTo(idFlat), + "at the origin the two planes really are indistinguishable"); + } + + // ... but vouching for the whole face must not, because part of it is 0.04 away + using (var wholeFace = new InternedPlanes(8, Allocator.Temp)) + { + var idFlat = wholeFace.Add(flat, float3.zero, out _); + using var pts = new NativeArray(new[] { float3.zero, OnTiltedAt(100) }, Allocator.Temp); + var idBoth = wholeFace.Add(TiltedThroughOrigin(), pts, out _); + Assert.That(idBoth, Is.Not.EqualTo(idFlat), + "a face is only on a plane if ALL of it is on that plane"); + } + } + + [Test] + public void MatchedPlane_StaysWithinToleranceOfTheCallersPoints() + { + using var table = new InternedPlanes(16, Allocator.Temp); + var on0 = new float3(3, 7, 11); + var id = table.Add(Plane(new float3(0.3f, 0.9f, 0.31f), on0), on0, out _); + + // a slightly different plane through a nearby point that still matches + var on1 = new float3(3.0001f, 7.0001f, 11.0001f); + var id2 = table.Add(Plane(new float3(0.3f, 0.9f, 0.31f), on1), on1, out _); + + Assert.That(id2, Is.EqualTo(id), "these are the same wall"); + var stored = table[id]; + Assert.That(math.abs(InternedPlanes.Distance(stored, on1)), + Is.LessThanOrEqualTo(CSGConstants.kPlaneDAlignEpsilon), + "the invariant the whole design rests on: the shared plane still passes through the face"); + } + + [Test] + public void TryFind_DoesNotInsert() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var on = new float3(0, 2, 0); + var p = Plane(new float3(0, 1, 0), on); + + Assert.That(table.TryFind(p, on, out _, out _), Is.False); + Assert.That(table.Count, Is.EqualTo(0)); + + var id = table.Add(p, on, out _); + Assert.That(table.TryFind(p, on, out int found, out _), Is.True); + Assert.That(found, Is.EqualTo(id)); + Assert.That(table.Count, Is.EqualTo(1)); + } + + [Test] + public void StoredPlane_IsNormalizedAndRetrievable() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var on = new float3(0, 3, 0); + var id = table.Add(new float4(0, 4, 0, -12), on, out _); // normal length 4 + + var stored = table[id]; + Assert.That(math.length(stored.xyz), Is.EqualTo(1f).Within(1e-5f)); + Assert.That(stored.w, Is.EqualTo(-3f).Within(1e-5f)); + } + + [Test] + public void ManyDistinctPlanes_AllGetDistinctIds_AndReAddingFindsThem() + { + using var table = new InternedPlanes(64, Allocator.Temp); + const int kCount = 200; + var ids = new int[kCount]; + for (int i = 0; i < kCount; i++) + { + var on = new float3(i * 0.5f, 0, 0); + ids[i] = table.Add(Plane(new float3(1, 0, 0), on), on, out _); + } + + Assert.That(table.Count, Is.EqualTo(kCount), "hash chaining must not merge distinct planes"); + for (int i = 1; i < kCount; i++) + Assert.That(ids[i], Is.Not.EqualTo(ids[i - 1])); + + for (int i = 0; i < kCount; i++) + { + var on = new float3(i * 0.5f, 0, 0); + Assert.That(table.Add(Plane(new float3(1, 0, 0), on), on, out _), Is.EqualTo(ids[i])); + } + Assert.That(table.Count, Is.EqualTo(kCount), "re-adding must find, not grow"); + } + + [Test] + public void Clear_EmptiesTheTableAndItsHashChains() + { + using var table = new InternedPlanes(8, Allocator.Temp); + var onA = new float3(0, 1, 0); + var onB = new float3(1, 0, 0); + table.Add(Plane(new float3(0, 1, 0), onA), onA, out _); + table.Add(Plane(new float3(1, 0, 0), onB), onB, out _); + Assert.That(table.Count, Is.EqualTo(2)); + + table.Clear(); + Assert.That(table.Count, Is.EqualTo(0)); + Assert.That(table.TryFind(Plane(new float3(0, 1, 0), onA), onA, out _, out _), Is.False, + "a stale hash chain would hand back an id into an empty list"); + } + } +} diff --git a/Core/Tests/InternedPlanesTests.cs.meta b/Core/Tests/InternedPlanesTests.cs.meta new file mode 100644 index 00000000..e24e0890 --- /dev/null +++ b/Core/Tests/InternedPlanesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f959a05e78e75e4ea9640e0990891a8 \ No newline at end of file diff --git a/Core/Tests/LightmapUVLayoutTests.cs b/Core/Tests/LightmapUVLayoutTests.cs new file mode 100644 index 00000000..91346a63 --- /dev/null +++ b/Core/Tests/LightmapUVLayoutTests.cs @@ -0,0 +1,220 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class LightmapUVLayoutTests + { + static readonly LightmapUVSettings kSettings = new() { texelsPerUnit = 10, paddingTexels = 2 }; + + static LightmapChart Chart(float2 min, float2 size, ulong key, LightmapChartMode mode = LightmapChartMode.Texels) + { + return new LightmapChart { rect = new float4(min, min + size), mode = mode, key = key }; + } + + static int Layout(LightmapChart[] charts, LightmapUVSettings settings, out LightmapChartPlacement[] placements) + { + using var chartArray = new NativeArray(charts, Allocator.Temp); + using var placementArray = new NativeArray(charts.Length, Allocator.Temp); + var side = LightmapUVLayout.Layout(chartArray, settings, placementArray); + placements = placementArray.ToArray(); + return side; + } + + // The texels a chart covers in the layout: the rectangle its corners go to + static float4 Covered(LightmapChart chart, LightmapChartPlacement placement) + { + var a = placement.Place(chart.rect.xy, chart.rect); + var b = placement.Place(chart.rect.zw, chart.rect); + var c = placement.Place(chart.rect.xw, chart.rect); + var d = placement.Place(chart.rect.zy, chart.rect); + return new float4(math.min(math.min(a, b), math.min(c, d)), math.max(math.max(a, b), math.max(c, d))); + } + + static LightmapChart[] RandomCharts(uint seed, int count, bool outliers = true) + { + var random = new Random(seed); + var charts = new LightmapChart[count]; + for (int i = 0; i < count; i++) + { + // Mostly small surfaces, some long thin ones and a few large ones, as a level has + var size = random.NextFloat2(new float2(0.01f), new float2(2)); + if (outliers && random.NextInt(8) == 0) size.x *= 10; + if (outliers && random.NextInt(20) == 0) size *= 5; + charts[i] = Chart(random.NextFloat2(new float2(-50), new float2(50)), size, (ulong)i + 1); + } + return charts; + } + + [Test] + public void Charts_StayInsideTheSquare_AndKeepThePaddingBetweenThem([Values(1u, 2u, 3u, 4u, 5u)] uint seed) + { + var charts = RandomCharts(seed, 300); + var side = Layout(charts, kSettings, out var placements); + Assert.That(side, Is.GreaterThan(0)); + + var covered = new float4[charts.Length]; + for (int i = 0; i < charts.Length; i++) + { + covered[i] = Covered(charts[i], placements[i]); + Assert.That(math.all(covered[i].xy >= -1e-3f) && math.all(covered[i].zw <= side + 1e-3f), Is.True, + $"chart {i} at {covered[i]} is outside the {side} texel square"); + } + for (int i = 0; i < charts.Length; i++) + { + for (int j = i + 1; j < charts.Length; j++) + { + // Apart by at least the padding along one axis + var gap = math.max(covered[j].xy - covered[i].zw, covered[i].xy - covered[j].zw); + Assert.That(math.cmax(gap), Is.GreaterThanOrEqualTo(kSettings.paddingTexels - 1e-3f), + $"charts {i} ({covered[i]}) and {j} ({covered[j]}) are closer than the padding"); + } + } + } + + // A texel per tenth of a unit: a 2 by 1 chart spans 20 by 10 texels; one standing up is laid on its long side + [Test] + public void TexelsFollowTheSize_AndATallChartLiesOnItsLongSide() + { + var charts = new[] { Chart(new float2(3, 4), new float2(2, 1), 1), Chart(new float2(-7, 1), new float2(0.5f, 3), 2) }; + Layout(charts, kSettings, out var placements); + + var wide = Covered(charts[0], placements[0]); + Assert.That(wide.zw - wide.xy, Is.EqualTo(new float2(20, 10)).Using(new Float2Comparer(1e-3f))); + Assert.IsFalse(placements[0].rotated); + + var tall = Covered(charts[1], placements[1]); + Assert.IsTrue(placements[1].rotated); + Assert.That(tall.zw - tall.xy, Is.EqualTo(new float2(30, 5)).Using(new Float2Comparer(1e-3f))); + } + + // A surface that gives off light gets one texel, however large it is + [Test] + public void ASingleTexelChart_FitsInOneTexel() + { + var charts = new[] { Chart(new float2(10, 20), new float2(8, 3), 1, LightmapChartMode.SingleTexel), Chart(new float2(0, 0), new float2(1, 1), 2) }; + Layout(charts, kSettings, out var placements); + + var single = Covered(charts[0], placements[0]); + Assert.That(math.cmax(single.zw - single.xy), Is.EqualTo(1).Within(1e-4f), "it spans exactly one texel along its long side"); + Assert.That(math.floor(single.xy), Is.EqualTo(math.floor(single.zw - 1e-4f)), "within one texel of the grid"); + } + + // A surface without a lightmap gets no cell: nothing is laid out for it + [Test] + public void AChartWithoutLightmap_GetsNoTexels() + { + var none = new[] { Chart(new float2(1, 2), new float2(3, 4), 1, LightmapChartMode.None) }; + Assert.AreEqual(0, Layout(none, kSettings, out var placements)); + Assert.That(placements[0].Place(new float2(2, 3), none[0].rect), Is.EqualTo(float2.zero)); + + var withOther = new[] { none[0], Chart(new float2(0, 0), new float2(1, 1), 2) }; + var side = Layout(withOther, kSettings, out _); + Assert.AreEqual(10 + 2, side, "only the other chart's 10 texels and its padding"); + } + + // The layout depends on the charts, not on the order they come in: a bake stays valid when the mesh is built again + [Test] + public void TheSameCharts_GetTheSamePlaces_InAnyOrder() + { + var charts = RandomCharts(7, 200); + // Many of the same size, which only their keys can order + for (int i = 0; i < charts.Length; i += 3) + charts[i].rect = new float4(charts[i].rect.xy, charts[i].rect.xy + new float2(0.5f, 0.5f)); + var side = Layout(charts, kSettings, out var placements); + + var shuffled = (LightmapChart[])charts.Clone(); + var random = new Random(99); + for (int i = shuffled.Length - 1; i > 0; i--) + { + var j = random.NextInt(i + 1); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + var shuffledSide = Layout(shuffled, kSettings, out var shuffledPlacements); + + Assert.AreEqual(side, shuffledSide); + for (int i = 0; i < shuffled.Length; i++) + { + var original = (int)shuffled[i].key - 1; + Assert.That(shuffledPlacements[i].origin, Is.EqualTo(placements[original].origin), $"chart with key {shuffled[i].key}"); + } + } + + // Charts with the same size and the same key (two identical surfaces) are told apart by the order they come in: + // each still gets a cell of its own, the first the first one + [Test] + public void ChartsOfTheSameSizeAndKey_EachGetACell_InTheOrderTheyCome() + { + var charts = new[] { Chart(new float2(0, 0), new float2(1, 1), 5), Chart(new float2(3, 0), new float2(1, 1), 5), Chart(new float2(6, 0), new float2(1, 1), 5) }; + Layout(charts, kSettings, out var placements); + Layout(charts, kSettings, out var again); + for (int i = 0; i < charts.Length; i++) + { + Assert.That(again[i].origin, Is.EqualTo(placements[i].origin), $"chart {i} the same again"); + for (int j = i + 1; j < charts.Length; j++) + Assert.That(placements[i].origin, Is.Not.EqualTo(placements[j].origin), $"charts {i} and {j} share a cell"); + } + Assert.That(placements[0].origin.y < placements[1].origin.y || + (placements[0].origin.y == placements[1].origin.y && placements[0].origin.x < placements[1].origin.x), Is.True, "the first comes first"); + Assert.That(placements[1].origin.y < placements[2].origin.y || + (placements[1].origin.y == placements[2].origin.y && placements[1].origin.x < placements[2].origin.x), Is.True, "the second before the third"); + } + + // Many small charts fill most of the square they get (one very long chart would make the square at least that wide) + [Test] + public void TheSquare_IsMostlyFilled() + { + var charts = RandomCharts(11, 2000, outliers: false); + var side = Layout(charts, kSettings, out var placements); + var used = 0.0; + for (int i = 0; i < charts.Length; i++) + { + var covered = Covered(charts[i], placements[i]); + used += (math.ceil(covered.z - covered.x) + kSettings.paddingTexels) * (math.ceil(covered.w - covered.y) + kSettings.paddingTexels); + } + Assert.That(used / ((double)side * side), Is.GreaterThan(0.6), $"cells use {used} of {side}x{side} texels"); + } + + // A chart longer than any lightmap could hold still stays inside its own cell, with fewer texels per unit + [Test] + public void AHugeChart_StillFitsItsCell() + { + var charts = new[] { Chart(new float2(0, 0), new float2(10000, 2), 1), Chart(new float2(5, 5), new float2(1, 1), 2) }; + var side = Layout(charts, kSettings, out var placements); + var huge = Covered(charts[0], placements[0]); + var small = Covered(charts[1], placements[1]); + Assert.That(huge.z - huge.x, Is.LessThanOrEqualTo(LightmapUVLayout.kMaxChartTexels + 1e-2f)); + Assert.That(math.all(huge.zw <= side) && math.all(small.zw <= side), Is.True); + var gap = math.max(small.xy - huge.zw, huge.xy - small.zw); + Assert.That(math.cmax(gap), Is.GreaterThanOrEqualTo(kSettings.paddingTexels - 1e-2f), "the charts stay apart"); + } + + [Test] + public void OutputFlags_SayHowManyTexelsASurfaceGets() + { + Assert.AreEqual(LightmapChartMode.Texels, LightmapUVLayout.ModeOf(SurfaceOutputFlags.Default)); + Assert.AreEqual(LightmapChartMode.None, LightmapUVLayout.ModeOf(SurfaceOutputFlags.NoLightmap)); + Assert.AreEqual(LightmapChartMode.SingleTexel, LightmapUVLayout.ModeOf(SurfaceOutputFlags.SingleLightmapTexel)); + Assert.AreEqual(LightmapChartMode.None, LightmapUVLayout.ModeOf(SurfaceOutputFlags.NoLightmap | SurfaceOutputFlags.SingleLightmapTexel), "no lightmap wins"); + } + + // The settings a model can't use fall back to Unity's defaults + [Test] + public void UnusableSettings_FallBackToTheDefaults() + { + var usable = new LightmapUVSettings { texelsPerUnit = 0, paddingTexels = float.NaN }.Usable; + Assert.AreEqual(LightmapUVSettings.kDefaultTexelsPerUnit, usable.texelsPerUnit); + Assert.AreEqual(LightmapUVSettings.kDefaultPaddingTexels, usable.paddingTexels); + } + + sealed class Float2Comparer : System.Collections.Generic.IEqualityComparer + { + readonly float tolerance; + public Float2Comparer(float tolerance) { this.tolerance = tolerance; } + public bool Equals(float2 a, float2 b) => math.all(math.abs(a - b) <= tolerance); + public int GetHashCode(float2 value) => 0; + } + } +} diff --git a/Core/Tests/LightmapUVLayoutTests.cs.meta b/Core/Tests/LightmapUVLayoutTests.cs.meta new file mode 100644 index 00000000..977561e2 --- /dev/null +++ b/Core/Tests/LightmapUVLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4b8f56a0970e45bd8bc12b6d42e7b465 diff --git a/Core/Tests/LoopEdgeSplitterTests.cs b/Core/Tests/LoopEdgeSplitterTests.cs new file mode 100644 index 00000000..71f7dc1b --- /dev/null +++ b/Core/Tests/LoopEdgeSplitterTests.cs @@ -0,0 +1,325 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class LoopEdgeSplitterTests + { + const float kEdgeEps = 0.001f; + + static UnsafeList Edges(params (int a, int b)[] e) + { + var list = new UnsafeList(math.max(1, e.Length), Allocator.Temp); + for (int i = 0; i < e.Length; i++) + list.Add(new Edge { index1 = (ushort)e[i].a, index2 = (ushort)e[i].b }); + return list; + } + + static NativeArray Positions(params float3[] p) + { + var arr = new NativeArray(p.Length, Allocator.Temp); + for (int i = 0; i < p.Length; i++) arr[i] = p[i]; + return arr; + } + + static NativeArray AllIndices(int count) + { + var arr = new NativeArray(count, Allocator.Temp); + for (int i = 0; i < count; i++) arr[i] = (ushort)i; + return arr; + } + + static bool HasEdge(in UnsafeList edges, int a, int b) + { + for (int i = 0; i < edges.Length; i++) + if (edges[i].index1 == a && edges[i].index2 == b) return true; + return false; + } + + [Test] + public void SplitsEdgeAtMidpointVertex() + { + // vertices: 0=(0,0,0) 1=(2,0,0) 2=(1,0,0) on the 0->1 segment. + var pos = Positions(new float3(0, 0, 0), new float3(2, 0, 0), new float3(1, 0, 0)); + var cand = AllIndices(3); + var edges = Edges((0, 1)); + + LoopEdgeSplitter.SplitEdgesAtVertices(ref edges, in pos, in cand, cand.Length, kEdgeEps); + + Assert.That(edges.Length, Is.EqualTo(2)); + Assert.That(HasEdge(in edges, 0, 2), Is.True); + Assert.That(HasEdge(in edges, 2, 1), Is.True); + edges.Dispose(); pos.Dispose(); cand.Dispose(); + } + + [Test] + public void SplitsEdgeAtTwoVerticesInOrder() + { + // 0=(0,0,0) 1=(3,0,0); on-segment 2=(2,0,0) and 3=(1,0,0) (given out of order). + var pos = Positions(new float3(0, 0, 0), new float3(3, 0, 0), new float3(2, 0, 0), new float3(1, 0, 0)); + var cand = AllIndices(4); + var edges = Edges((0, 1)); + + LoopEdgeSplitter.SplitEdgesAtVertices(ref edges, in pos, in cand, cand.Length, kEdgeEps); + + // Expect 0 -> 3 -> 2 -> 1 (sorted along the edge). + Assert.That(edges.Length, Is.EqualTo(3)); + Assert.That(HasEdge(in edges, 0, 3), Is.True); + Assert.That(HasEdge(in edges, 3, 2), Is.True); + Assert.That(HasEdge(in edges, 2, 1), Is.True); + edges.Dispose(); pos.Dispose(); cand.Dispose(); + } + + [Test] + public void DoesNotSplitWhenVertexOffSegment() + { + var pos = Positions(new float3(0, 0, 0), new float3(2, 0, 0), new float3(1, 1, 0)); // off the line + var cand = AllIndices(3); + var edges = Edges((0, 1)); + + LoopEdgeSplitter.SplitEdgesAtVertices(ref edges, in pos, in cand, cand.Length, kEdgeEps); + + Assert.That(edges.Length, Is.EqualTo(1)); + Assert.That(HasEdge(in edges, 0, 1), Is.True); + edges.Dispose(); pos.Dispose(); cand.Dispose(); + } + + [Test] + public void DoesNotSplitAtEndpoint() + { + // vertex 2 coincides with endpoint 1 - must not produce a zero-length edge. + var pos = Positions(new float3(0, 0, 0), new float3(2, 0, 0), new float3(2, 0, 0)); + var cand = AllIndices(3); + var edges = Edges((0, 1)); + + LoopEdgeSplitter.SplitEdgesAtVertices(ref edges, in pos, in cand, cand.Length, kEdgeEps); + + Assert.That(edges.Length, Is.EqualTo(1)); + Assert.That(HasEdge(in edges, 0, 1), Is.True); + edges.Dispose(); pos.Dispose(); cand.Dispose(); + } + + [Test] + public void SharedEdgeIsSplitIdenticallyInBothDirections() + { + // The crack-safety property: an edge shared by two surfaces appears as (0->1) on one and + // (1->0) on the other. Both must gain vertex 2, or the surfaces crack apart. + var pos = Positions(new float3(0, 0, 0), new float3(2, 0, 0), new float3(1, 0, 0)); + var cand = AllIndices(3); + + var fwd = Edges((0, 1)); + var rev = Edges((1, 0)); + LoopEdgeSplitter.SplitEdgesAtVertices(ref fwd, in pos, in cand, cand.Length, kEdgeEps); + LoopEdgeSplitter.SplitEdgesAtVertices(ref rev, in pos, in cand, cand.Length, kEdgeEps); + + Assert.That(HasEdge(in fwd, 0, 2), Is.True); + Assert.That(HasEdge(in fwd, 2, 1), Is.True); + Assert.That(HasEdge(in rev, 1, 2), Is.True); + Assert.That(HasEdge(in rev, 2, 0), Is.True); + fwd.Dispose(); rev.Dispose(); pos.Dispose(); cand.Dispose(); + } + + [Test] + public void TJunctionQuadBecomesValidSimpleLoop() + { + // Quad 0-1-2-3 with vertex 4 on edge 1->2: split, it is the simple pentagon 0->1->4->2->3->0 + var pos = Positions( + new float3(0, 0, 0), // 0 + new float3(2, 0, 0), // 1 + new float3(2, 2, 0), // 2 + new float3(0, 2, 0), // 3 + new float3(2, 1, 0)); // 4 on edge 1->2 + var cand = AllIndices(5); + var edges = Edges((0, 1), (1, 2), (2, 3), (3, 0)); + + LoopEdgeSplitter.SplitEdgesAtVertices(ref edges, in pos, in cand, cand.Length, kEdgeEps); + + Assert.That(edges.Length, Is.EqualTo(5)); + Assert.That(HasEdge(in edges, 1, 4), Is.True); + Assert.That(HasEdge(in edges, 4, 2), Is.True); + Assert.That(LoopValidation.Classify(in edges, 5, out _), Is.EqualTo(LoopDefect.None)); + edges.Dispose(); pos.Dispose(); cand.Dispose(); + } + + [Test] + public void EmptyOrNoCandidates_LeavesEdgesUnchanged() + { + var pos = Positions(new float3(0, 0, 0), new float3(2, 0, 0)); + var cand = AllIndices(2); + var edges = Edges((0, 1)); + LoopEdgeSplitter.SplitEdgesAtVertices(ref edges, in pos, in cand, cand.Length, kEdgeEps); + Assert.That(edges.Length, Is.EqualTo(1)); + edges.Dispose(); pos.Dispose(); cand.Dispose(); + } + + // RemoveAntiparallelEdgePairs (figure-eights and slits) + + [Test] + public void RemovesAntiparallelPair_LeavingValidCycle() + { + // Valid quad 55-21-11-2 plus the slit 3<->55 at hub vertex 55. + var loop = Edges((3, 55), (55, 21), (21, 11), (11, 2), (2, 55), (55, 3)); + LoopEdgeSplitter.RemoveAntiparallelEdgePairs(ref loop); + + Assert.That(loop.Length, Is.EqualTo(4)); + Assert.That(HasEdge(in loop, 3, 55), Is.False); + Assert.That(HasEdge(in loop, 55, 3), Is.False); + Assert.That(LoopValidation.Classify(in loop, 56, out _), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + [Test] + public void RemovesMultipleAntiparallelPairs() + { + // Triangle 0-1-2 with two slits: 0<->3 and 1<->4. + var loop = Edges((0, 1), (1, 2), (2, 0), (0, 3), (3, 0), (1, 4), (4, 1)); + LoopEdgeSplitter.RemoveAntiparallelEdgePairs(ref loop); + + Assert.That(loop.Length, Is.EqualTo(3)); + Assert.That(LoopValidation.Classify(in loop, 5, out _), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + [Test] + public void LeavesCleanLoopUntouched() + { + var loop = Edges((0, 1), (1, 2), (2, 3), (3, 0)); + LoopEdgeSplitter.RemoveAntiparallelEdgePairs(ref loop); + Assert.That(loop.Length, Is.EqualTo(4)); + Assert.That(LoopValidation.Classify(in loop, 4, out _), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + // RemoveSimpleChords (forks and sinks) + + [Test] + public void RemovesInteriorChord_LeavingValidPolygon() + { + // Hexagon 0-9-7-1-2-3-0 plus the one-way diagonal 7->0. + var loop = Edges((0, 9), (9, 7), (7, 1), (1, 2), (2, 3), (3, 0), (7, 0)); + LoopEdgeSplitter.RemoveSimpleChords(ref loop); + + Assert.That(loop.Length, Is.EqualTo(6)); + Assert.That(HasEdge(in loop, 7, 0), Is.False); + Assert.That(LoopValidation.Classify(in loop, 10, out _), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + [Test] + public void RemoveSimpleChords_LeavesCleanLoopUntouched() + { + var loop = Edges((0, 1), (1, 2), (2, 3), (3, 0)); + LoopEdgeSplitter.RemoveSimpleChords(ref loop); + Assert.That(loop.Length, Is.EqualTo(4)); + Assert.That(LoopValidation.Classify(in loop, 4, out _), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + [Test] + public void RemoveSimpleChords_LeavesTangledLoopUntouched() + { + // A bowtie (two triangles sharing vertex 0) isn't a single chord, so it is left alone + var loop = Edges((0, 1), (1, 2), (2, 0), (0, 3), (3, 4), (4, 0)); + LoopEdgeSplitter.RemoveSimpleChords(ref loop); + Assert.That(loop.Length, Is.EqualTo(6)); + loop.Dispose(); + } + + // CloseSingleOpenChain (open chains) + + [Test] + public void ClosesSingleOpenChain_IntoValidPolygon() + { + // Open path 0->1->2->3 (the quad lost its closing edge 3->0). + var loop = Edges((0, 1), (1, 2), (2, 3)); + var closed = LoopEdgeSplitter.CloseSingleOpenChain(ref loop); + Assert.That(closed, Is.True); + Assert.That(loop.Length, Is.EqualTo(4)); + Assert.That(HasEdge(in loop, 3, 0), Is.True); + Assert.That(LoopValidation.Classify(in loop, 4, out _), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + [Test] + public void CloseSingleOpenChain_LeavesClosedLoopUntouched() + { + var loop = Edges((0, 1), (1, 2), (2, 3), (3, 0)); + var closed = LoopEdgeSplitter.CloseSingleOpenChain(ref loop); + Assert.That(closed, Is.False); + Assert.That(loop.Length, Is.EqualTo(4)); + loop.Dispose(); + } + + [Test] + public void CloseSingleOpenChain_RefusesDisconnected_CyclePlusDanglingEdge() + { + // Triangle 0-1-2 plus a dangling edge 3->4: two dangling ends, but two components, so 4->3 isn't closed + var loop = Edges((0, 1), (1, 2), (2, 0), (3, 4)); + var closed = LoopEdgeSplitter.CloseSingleOpenChain(ref loop); + Assert.That(closed, Is.False); + Assert.That(loop.Length, Is.EqualTo(4)); + loop.Dispose(); + } + + [Test] + public void CloseSingleOpenChain_RefusesFragmented() + { + // Two separate open paths (4 dangling ends) - not a single chain. + var loop = Edges((0, 1), (1, 2), (5, 6), (6, 7)); + var closed = LoopEdgeSplitter.CloseSingleOpenChain(ref loop); + Assert.That(closed, Is.False); + loop.Dispose(); + } + + [Test] + public void RemoveTinyComponents_DropsStrayFragments_KeepsMainAndHoles() + { + // Outer quad 0-1-2-3, a hole triangle 4-5-6 and a stray one-edge fragment 7-8: only the stray is dropped + var loop = Edges((0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 4), (7, 8)); + var removed = LoopEdgeSplitter.RemoveTinyComponents(ref loop); + Assert.That(removed, Is.True); + Assert.That(loop.Length, Is.EqualTo(7)); + Assert.That(HasEdge(in loop, 7, 8), Is.False); + Assert.That(HasEdge(in loop, 4, 5), Is.True); // hole preserved + loop.Dispose(); + } + + [Test] + public void RemoveTinyComponents_ThenClose_RecoversFragmentedChain() + { + // A main open chain 0-1-2-3 missing its close, plus a stray edge 5-6. + var loop = Edges((0, 1), (1, 2), (2, 3), (5, 6)); + LoopEdgeSplitter.RemoveTinyComponents(ref loop); // drops 5-6 (1-edge component) + var closed = LoopEdgeSplitter.CloseSingleOpenChain(ref loop); // closes 0-1-2-3 + Assert.That(closed, Is.True); + Assert.That(LoopValidation.Classify(in loop, 4, out _), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + [Test] + public void RemoveTinyComponents_LeavesCleanLoopUntouched() + { + var loop = Edges((0, 1), (1, 2), (2, 3), (3, 0)); + var removed = LoopEdgeSplitter.RemoveTinyComponents(ref loop); + Assert.That(removed, Is.False); + Assert.That(loop.Length, Is.EqualTo(4)); + loop.Dispose(); + } + + [Test] + public void ClosesOpenChain_WithInconsistentEdgeDirections() + { + // Undirected simple path 0-1-2-3 but with some edges reversed (the hole-flip case): + // 0->1, 2->1, 2->3. Must reconstruct a consistently-wound closed cycle over {0,1,2,3}. + var loop = Edges((0, 1), (2, 1), (2, 3)); + var closed = LoopEdgeSplitter.CloseSingleOpenChain(ref loop); + Assert.That(closed, Is.True); + Assert.That(loop.Length, Is.EqualTo(4)); + Assert.That(LoopValidation.Classify(in loop, 4, out _), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + } +} diff --git a/Core/Tests/LoopEdgeSplitterTests.cs.meta b/Core/Tests/LoopEdgeSplitterTests.cs.meta new file mode 100644 index 00000000..e447380a --- /dev/null +++ b/Core/Tests/LoopEdgeSplitterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 95079d4998ee3e640b31432c3d99b711 \ No newline at end of file diff --git a/Core/Tests/LoopSplitCapacityTests.cs b/Core/Tests/LoopSplitCapacityTests.cs new file mode 100644 index 00000000..a6468fa3 --- /dev/null +++ b/Core/Tests/LoopSplitCapacityTests.cs @@ -0,0 +1,204 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Entities; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class LoopSplitCapacityTests + { + const float kHalf = 5f; + + static BlobAssetReference Planes(float4[] planes) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var array = builder.Allocate(ref root.treeSpacePlanes, planes.Length); + for (int i = 0; i < planes.Length; i++) + array[i] = planes[i]; + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + // Six planes, with outward normals (n·p + w <= 0 inside). + static float4[] Diamond(float r) + { + var s = math.sqrt(0.5f); + return new[] + { + new float4( s, 0, s, -r * s), + new float4( s, 0, -s, -r * s), + new float4(-s, 0, s, -r * s), + new float4(-s, 0, -s, -r * s), + new float4( 0, -1, 0, -1), + new float4( 0, 1, 0, -1), + }; + } + + // The brush the square is a face of. + static float4[] SquareBrush() + { + return new[] + { + new float4(-1, 0, 0, -kHalf), new float4(1, 0, 0, -kHalf), + new float4( 0, -1, 0, -1), new float4(0, 1, 0, -1), + new float4( 0, 0, -1, -kHalf), new float4(0, 0, 1, -kHalf), + }; + } + + static float3[] Square() + { + return new[] + { + new float3(-kHalf, 0, -kHalf), new float3(kHalf, 0, -kHalf), + new float3( kHalf, 0, kHalf), new float3(-kHalf, 0, kHalf), + }; + } + + // The square's outline with extra points on every side at +-offset from the side's middle, in loop order. + static float3[] SplitSquare(params float[] offsets) + { + var sorted = new float[offsets.Length * 2]; + for (int i = 0; i < offsets.Length; i++) + { + sorted[i] = -offsets[i]; + sorted[offsets.Length + i] = offsets[i]; + } + System.Array.Sort(sorted); + + var ring = new System.Collections.Generic.List(); + ring.Add(new float3(-kHalf, 0, -kHalf)); + foreach (var t in sorted) ring.Add(new float3(t, 0, -kHalf)); // along z = -5, x rising + ring.Add(new float3(kHalf, 0, -kHalf)); + foreach (var t in sorted) ring.Add(new float3(kHalf, 0, t)); // along x = +5, z rising + ring.Add(new float3(kHalf, 0, kHalf)); + for (int i = sorted.Length - 1; i >= 0; i--) + ring.Add(new float3(sorted[i], 0, kHalf)); // along z = +5, x falling + ring.Add(new float3(-kHalf, 0, kHalf)); + for (int i = sorted.Length - 1; i >= 0; i--) + ring.Add(new float3(-kHalf, 0, sorted[i])); // along x = -5, z falling + return ring.ToArray(); + } + + // A closed loop through the ring's points, in a list allocated with exactly the given capacity request. + static UnsafeList Loop(HashedVertices vertices, float3[] ring, int capacity) + { + Assert.That(capacity, Is.GreaterThanOrEqualTo(ring.Length)); + var edges = new UnsafeList(capacity, Allocator.Temp); + var first = vertices.AddNoResize(ring[0]); + var previous = first; + for (int i = 1; i < ring.Length; i++) + { + var current = vertices.AddNoResize(ring[i]); + edges.AddNoResize(new Edge { index1 = previous, index2 = current }); + previous = current; + } + edges.AddNoResize(new Edge { index1 = previous, index2 = first }); + return edges; + } + + [Test] + public void AFaceCutByManyBrushes_OutgrowsTheEdgeListTheJobAllocates() + { + const int kBrushes = 6; + using var vertices = new HashedVertices(64, Allocator.Temp); + // Sized as the job sizes a base polygon's list: its own edges plus four per intersecting brush. + var edges = Loop(vertices, Square(), 4 + kBrushes * 4); + var capacity = edges.Capacity; + + for (int b = 0; b < kBrushes; b++) + { + var diamond = Planes(Diamond(5.5f + 0.3f * b)); + default(FindLoopOverlapIntersectionsJob).FindBasePolygonPlaneIntersections( + ref diamond.Value.treeSpacePlanes, WeldIncidenceFilter.Disabled, ref edges, vertices); + diamond.Dispose(); + } + + Assert.That(edges.Length, Is.EqualTo(4 + kBrushes * 8), "every brush crosses each side of the square twice"); + Assert.That(edges.Length, Is.GreaterThan(capacity), "the fixture must outgrow the list, or it proves nothing"); + Assert.That(LoopValidation.IsValid(in edges, vertices.Length), Is.True, "the face is still one closed loop"); + edges.Dispose(); + } + + [Test] + public void AnIntersectionLoopCutByManyLoops_OutgrowsTheEdgeListCopyFromAllocates() + { + const int kBrushes = 6; + var planes = new NativeArray>(kBrushes, Allocator.Temp); + for (int b = 0; b < kBrushes; b++) + planes[b] = Planes(Diamond(5.5f + 0.3f * b)); + + using var vertices = new HashedVertices(64, Allocator.Temp); + // Sized as CopyFrom sizes an intersection loop: its own edges plus four per loop of the brush. + var edges = Loop(vertices, Square(), 4 + kBrushes * 4); + var capacity = edges.Capacity; + + for (int b = 0; b < kBrushes; b++) + { + default(FindLoopOverlapIntersectionsJob).FindLoopPlaneIntersections( + planes, WeldIncidenceFilter.Disabled, b, vertices, ref edges); + } + + Assert.That(edges.Length, Is.EqualTo(4 + kBrushes * 8), "every brush crosses each side of the square twice"); + Assert.That(edges.Length, Is.GreaterThan(capacity), "the fixture must outgrow the list, or it proves nothing"); + Assert.That(LoopValidation.IsValid(in edges, vertices.Length), Is.True, "the loop is still one closed loop"); + + edges.Dispose(); + for (int b = 0; b < kBrushes; b++) + planes[b].Dispose(); + planes.Dispose(); + } + + [Test] + public void ABrushCrossingTwicePerPlane_DoesNotOverflowTheVertexList() + { + const int kPlanes = 6, kCrossings = 8; + var diamond = Planes(Diamond(6f)); + using var vertices = new HashedVertices(64, Allocator.Temp); + var edges = Loop(vertices, Square(), 16); + + // Room for one new vertex per plane, not one per crossing: the eight crossings still fit + var y = 100f; + while (vertices.Capacity - vertices.Length >= kCrossings) + { + vertices.AddNoResize(new float3(0, y, 0)); // unrelated vertices, far apart and far from the face + y += 1f; + } + Assert.That(vertices.Capacity - vertices.Length, Is.GreaterThanOrEqualTo(kPlanes), + "the fixture must leave room for one vertex per plane, or the old reservation would have grown it"); + var before = vertices.Length; + + default(FindLoopOverlapIntersectionsJob).FindBasePolygonPlaneIntersections( + ref diamond.Value.treeSpacePlanes, WeldIncidenceFilter.Disabled, ref edges, vertices); + + Assert.That(vertices.Length - before, Is.EqualTo(kCrossings), "one new vertex per crossing"); + Assert.That(edges.Length, Is.EqualTo(4 + kCrossings)); + Assert.That(LoopValidation.IsValid(in edges, vertices.Length), Is.True); + + edges.Dispose(); + diamond.Dispose(); + } + + [Test] + public void ManyVerticesLyingOnALoop_OutgrowItsEdgeList() + { + var brush = Planes(SquareBrush()); + using var vertices = new HashedVertices(64, Allocator.Temp); + var self = Loop(vertices, Square(), 4); + var capacity = self.Capacity; + // A neighbouring loop that shares the corners and has six more points on every side. + var other = Loop(vertices, SplitSquare(0.5f, 0.8f, 1.1f), 64); + + default(FindLoopOverlapIntersectionsJob).FindLoopVertexOverlaps(ref brush.Value.treeSpacePlanes, ref self, other, vertices); + + Assert.That(self.Length, Is.EqualTo(4 + 4 * 6), "each side is split at the other loop's six points on it"); + Assert.That(self.Length, Is.GreaterThan(capacity), "the fixture must outgrow the list, or it proves nothing"); + Assert.That(LoopValidation.IsValid(in self, vertices.Length), Is.True); + + other.Dispose(); + self.Dispose(); + brush.Dispose(); + } + } +} diff --git a/Core/Tests/LoopSplitCapacityTests.cs.meta b/Core/Tests/LoopSplitCapacityTests.cs.meta new file mode 100644 index 00000000..96cb768d --- /dev/null +++ b/Core/Tests/LoopSplitCapacityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 35b0b5ea3fd1dc84994a8a48fea6ecb5 \ No newline at end of file diff --git a/Core/Tests/LoopValidationTests.cs b/Core/Tests/LoopValidationTests.cs new file mode 100644 index 00000000..7b89bf98 --- /dev/null +++ b/Core/Tests/LoopValidationTests.cs @@ -0,0 +1,150 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class LoopValidationTests + { + // Builds a Temp UnsafeList from (index1, index2) pairs. Caller disposes. + static UnsafeList Loop(params (int a, int b)[] edges) + { + var list = new UnsafeList(edges.Length, Allocator.Temp); + for (int i = 0; i < edges.Length; i++) + list.Add(new Edge { index1 = (ushort)edges[i].a, index2 = (ushort)edges[i].b }); + return list; + } + + static int VertexCount(in UnsafeList edges) + { + int max = 0; + for (int i = 0; i < edges.Length; i++) + { + if (edges[i].index1 > max) max = edges[i].index1; + if (edges[i].index2 > max) max = edges[i].index2; + } + return max + 1; + } + + static LoopDefect Classify(in UnsafeList edges) + => LoopValidation.Classify(in edges, VertexCount(in edges), out _); + + // valid shapes + + [Test] + public void Triangle_IsValid() + { + var loop = Loop((0, 1), (1, 2), (2, 0)); + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + [Test] + public void Quad_IsValid() + { + var loop = Loop((0, 1), (1, 2), (2, 3), (3, 0)); + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + [Test] + public void OuterPlusVertexDisjointHole_IsValid() + { + // A surface with a hole is two vertex-disjoint cycles - legitimate output, + // not a defect, even though it is not a single connected loop. + var loop = Loop((0, 1), (1, 2), (2, 3), (3, 0), // outer + (4, 5), (5, 6), (6, 4)); // hole + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.None)); + loop.Dispose(); + } + + // open chains + + [Test] + public void OpenChain_DanglingEnds_IsOpenChain() + { + // 12-13 10-11 11-12 -> path 10->11->12->13, never closes. + var loop = Loop((12, 13), (10, 11), (11, 12)); + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.OpenChain)); + loop.Dispose(); + } + + [Test] + public void TwoDisjointOpenChains_IsOpenChain() + { + // The boundary fragmented into two unconnected paths. + var loop = Loop((0, 1), (1, 2), // path 0->1->2 + (3, 4), (4, 5)); // path 3->4->5 + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.OpenChain)); + loop.Dispose(); + } + + [Test] + public void DegreeTwoFork_IsOpenChain() + { + var loop = Loop((0, 1), (0, 2), (1, 2)); + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.OpenChain)); + loop.Dispose(); + } + + // pinches and surviving chords + + [Test] + public void BowtieSharedVertices_IsPinch() + { + // 0-5 5-3 2-3 3-0 0-2 -> vertices 0 and 3 reach degree 3. + var loop = Loop((0, 5), (5, 3), (2, 3), (3, 0), (0, 2)); + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.Pinch)); + loop.Dispose(); + } + + [Test] + public void QuadWithInteriorChord_IsPinch() + { + // 2-3 3-0 0-4 4-2 4-3 -> quad 2-3-0-4 plus diagonal 4-3. + var loop = Loop((2, 3), (3, 0), (0, 4), (4, 2), (4, 3)); + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.Pinch)); + loop.Dispose(); + } + + // degenerate and empty + + [Test] + public void SelfEdge_IsDegenerateEdge() + { + var loop = Loop((0, 1), (1, 2), (2, 2)); + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.DegenerateEdge)); + loop.Dispose(); + } + + [Test] + public void FewerThanThreeEdges_IsEmpty() + { + var loop = Loop((0, 1), (1, 0)); + Assert.That(Classify(in loop), Is.EqualTo(LoopDefect.Empty)); + loop.Dispose(); + } + + [Test] + public void Classify_ReportsOffendingVertex() + { + var loop = Loop((0, 5), (5, 3), (2, 3), (3, 0), (0, 2)); + var defect = LoopValidation.Classify(in loop, VertexCount(in loop), out int badVertex); + Assert.That(defect, Is.EqualTo(LoopDefect.Pinch)); + Assert.That(badVertex, Is.EqualTo(0).Or.EqualTo(3)); + loop.Dispose(); + } + + [Test] + public void IsValid_AgreesWithClassify() + { + var good = Loop((0, 1), (1, 2), (2, 0)); + var bad = Loop((0, 1), (1, 2), (2, 3)); + Assert.That(LoopValidation.IsValid(in good, VertexCount(in good)), Is.True); + Assert.That(LoopValidation.IsValid(in bad, VertexCount(in bad)), Is.False); + good.Dispose(); + bad.Dispose(); + } + } +} diff --git a/Core/Tests/LoopValidationTests.cs.meta b/Core/Tests/LoopValidationTests.cs.meta new file mode 100644 index 00000000..a9a0b71d --- /dev/null +++ b/Core/Tests/LoopValidationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 08029dca0ba3cb74d9a6d73baaae4911 \ No newline at end of file diff --git a/Core/Tests/MathExtensionsTests.cs b/Core/Tests/MathExtensionsTests.cs new file mode 100644 index 00000000..89480a72 --- /dev/null +++ b/Core/Tests/MathExtensionsTests.cs @@ -0,0 +1,348 @@ +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using static Chisel.Core.Tests.ChiselTestUtility; + +namespace Chisel.Core.Tests +{ + // Pure geometry helpers used throughout the CSG pipeline (plane/bounds classification, + // point-on-segment tests, line-line intersection, plane fitting, plane transforms, ...). + [TestFixture] + public class MathExtensionsTests + { + [Test] + public void Vector3Equals_RespectsEpsilon() + { + var a = new Vector3(1, 2, 3); + var b = new Vector3(1, 2, 3.0001f); + Assert.That(a.Equals(b, 0.001f), Is.True); + Assert.That(a.Equals(b, 0.00001f), Is.False); + } + + [Test] + public void ClosestTangentAxis_PicksAxisAwayFromDominantComponent() + { + // When z dominates, the chosen tangent axis is x; otherwise z. + AssertApproximately(new float3(1, 0, 0), MathExtensions.ClosestTangentAxis(new float3(0, 0, 1))); + AssertApproximately(new float3(0, 0, 1), MathExtensions.ClosestTangentAxis(new float3(1, 0, 0))); + AssertApproximately(new float3(0, 0, 1), MathExtensions.ClosestTangentAxis(new float3(0, 1, 0))); + } + + [Test] + public void ClosestTangentAxis_IsStableAroundThe45DegreeTie() + { + const float k = 0.70710678f; + var expected = MathExtensions.ClosestTangentAxis(new float3(k, 0, k)); + foreach (var nudge in new[] { 0f, 1e-7f, 1e-5f, 1e-4f, 5e-3f, -1e-4f, -5e-3f }) + { + AssertApproximately(expected, MathExtensions.ClosestTangentAxis(math.normalize(new float3(k, 0, k + nudge)))); + AssertApproximately(expected, MathExtensions.ClosestTangentAxis(math.normalize(new float3(0, k, k + nudge)))); + } + } + + [Test] + public void CalculateTangents_SurviveA45DegreePlaneRefit() + { + // The basis of a 45 degree plane doesn't swing around when the plane is recomputed from slightly different vertices + const float k = 0.70710678f; + MathExtensions.CalculateTangents(new float3(k, 0, k), out float3 tangent, out float3 binormal); + MathExtensions.CalculateTangents(math.normalize(new float3(k - 1e-4f, 0, k + 1e-4f)), out float3 refitTangent, out float3 refitBinormal); + + Assert.That(math.dot(tangent, refitTangent), Is.GreaterThan(0.99f)); + Assert.That(math.dot(binormal, refitBinormal), Is.GreaterThan(0.99f)); + } + + [Test] + public void CalculateTangents_ProducesOrthonormalBasis() + { + var normal = math.normalize(new float3(0.3f, 0.7f, 0.65f)); + MathExtensions.CalculateTangents(normal, out float3 tangent, out float3 binormal); + + Assert.That(math.length(tangent), Is.EqualTo(1f).Within(1e-4f)); + Assert.That(math.length(binormal), Is.EqualTo(1f).Within(1e-4f)); + Assert.That(math.dot(tangent, normal), Is.EqualTo(0f).Within(1e-4f)); + Assert.That(math.dot(binormal, normal), Is.EqualTo(0f).Within(1e-4f)); + Assert.That(math.dot(tangent, binormal), Is.EqualTo(0f).Within(1e-4f)); + } + + // -- Plane vs Bounds classification ------------------------------------------------ + + static readonly Plane kPlaneY0 = new Plane(new Vector3(0, 1, 0), 0f); + + [Test] + public void Intersection_Bounds_Above_IsOutside() + { + var bounds = new Bounds(new Vector3(0, 1.5f, 0), Vector3.one); + Assert.That(kPlaneY0.Intersection(bounds), Is.EqualTo(IntersectionResult.Outside)); + } + + [Test] + public void Intersection_Bounds_Below_IsInside() + { + var bounds = new Bounds(new Vector3(0, -1.5f, 0), Vector3.one); + Assert.That(kPlaneY0.Intersection(bounds), Is.EqualTo(IntersectionResult.Inside)); + } + + [Test] + public void Intersection_Bounds_Straddling_IsIntersecting() + { + var bounds = new Bounds(Vector3.zero, new Vector3(2, 2, 2)); + Assert.That(kPlaneY0.Intersection(bounds), Is.EqualTo(IntersectionResult.Intersecting)); + } + + [Test] + public void Intersection_MinMax_MatchesBoundsOverload() + { + var min = new Vector3(-1, 1, -1); + var max = new Vector3(1, 2, 1); + Assert.That(kPlaneY0.Intersection(min, max), Is.EqualTo(IntersectionResult.Outside)); + } + + [Test] + public void IsInside_And_IsOutside_AreConsistentWithSide() + { + var above = new Bounds(new Vector3(0, 1.5f, 0), Vector3.one); + var below = new Bounds(new Vector3(0, -1.5f, 0), Vector3.one); + + Assert.That(kPlaneY0.IsOutside(above), Is.True); + Assert.That(kPlaneY0.IsInside(above), Is.False); + + Assert.That(kPlaneY0.IsInside(below), Is.True); + Assert.That(kPlaneY0.IsOutside(below), Is.False); + } + + // -- Edge/vertex intersection interpolation --------------------------------------- + + [Test] + public void Intersection_BetweenDistances_FindsZeroCrossing() + { + // distance goes from -1 at (0,0,0) to +1 at (2,0,0) -> crossing at the midpoint. + var point = MathExtensions.Intersection(new Vector3(0, 0, 0), new Vector3(2, 0, 0), -1f, 1f); + AssertApproximately(new Vector3(1, 0, 0), point); + } + + [Test] + public void Intersection_BetweenDistances_IsDirectionIndependent() + { + // Cutting direction is normalized internally so the result is stable when swapped. + var a = MathExtensions.Intersection(new Vector3(0, 0, 0), new Vector3(2, 0, 0), -1f, 3f); + var b = MathExtensions.Intersection(new Vector3(2, 0, 0), new Vector3(0, 0, 0), 3f, -1f); + AssertApproximately(a, b); + } + + // -- Triangle / point helpers ----------------------------------------------------- + + [Test] + public void PointInTriangle_InteriorTrue_ExteriorFalse() + { + var a = new float3(0, 0, 0); + var b = new float3(1, 0, 0); + var c = new float3(0, 1, 0); + Assert.That(MathExtensions.PointInTriangle(new float3(0.25f, 0.25f, 0), a, b, c), Is.True); + Assert.That(MathExtensions.PointInTriangle(new float3(1f, 1f, 0), a, b, c), Is.False); + } + + [Test] + public void GetTriangleArraySize_MatchesTriangularNumbers() + { + Assert.That(MathExtensions.GetTriangleArraySize(1), Is.EqualTo(0)); + Assert.That(MathExtensions.GetTriangleArraySize(2), Is.EqualTo(1)); + Assert.That(MathExtensions.GetTriangleArraySize(4), Is.EqualTo(6)); + Assert.That(MathExtensions.GetTriangleArraySize(5), Is.EqualTo(10)); + } + + [Test] + public void ContainsPoint_InsideAndOutsideSquare() + { + var square = new[] + { + new Vector2(0, 0), new Vector2(1, 0), new Vector2(1, 1), new Vector2(0, 1) + }; + Assert.That(MathExtensions.ContainsPoint(square, new Vector2(0.5f, 0.5f)), Is.True); + Assert.That(MathExtensions.ContainsPoint(square, new Vector2(1.5f, 0.5f)), Is.False); + Assert.That(MathExtensions.ContainsPoint(square, new Vector2(-0.5f, 0.5f)), Is.False); + } + + // -- Projection ------------------------------------------------------------------- + + [Test] + public void ProjectPointLine_ClampsToSegment() + { + var start = new Vector3(0, 0, 0); + var end = new Vector3(1, 0, 0); + AssertApproximately(new Vector3(0.5f, 0, 0), MathExtensions.ProjectPointLine(new Vector3(0.5f, 1, 0), start, end)); + AssertApproximately(new Vector3(1, 0, 0), MathExtensions.ProjectPointLine(new Vector3(2, 1, 0), start, end)); + AssertApproximately(new Vector3(0, 0, 0), MathExtensions.ProjectPointLine(new Vector3(-1, 1, 0), start, end)); + } + + [Test] + public void ProjectPointRay_DoesNotClamp() + { + var start = new Vector3(0, 0, 0); + var direction = new Vector3(1, 0, 0); + AssertApproximately(new Vector3(2, 0, 0), MathExtensions.ProjectPointRay(new Vector3(2, 1, 0), start, direction)); + } + + // -- Point-on-segment / distance -------------------------------------------------- + + [Test] + public void IsPointOnLineSegment_DetectsOnAndOff() + { + var a = new float3(0, 0, 0); + var b = new float3(1, 0, 0); + Assert.That(MathExtensions.IsPointOnLineSegment(new float3(0.5f, 0, 0), a, b, 0.001f, 0.001f), Is.True); + Assert.That(MathExtensions.IsPointOnLineSegment(new float3(0.5f, 0.5f, 0), a, b, 0.001f, 0.001f), Is.False); + // endpoints count as on-segment via the vertex epsilon + Assert.That(MathExtensions.IsPointOnLineSegment(a, a, b, 0.001f, 0.001f), Is.True); + } + + [Test] + public void IsPointOnLineSegmentButNotOnVertex_ExcludesEndpoints() + { + var a = new float3(0, 0, 0); + var b = new float3(1, 0, 0); + Assert.That(MathExtensions.IsPointOnLineSegmentButNotOnVertex(new float3(0.5f, 0, 0), a, b, 0.001f), Is.True); + Assert.That(MathExtensions.IsPointOnLineSegmentButNotOnVertex(a, a, b, 0.001f), Is.False); + Assert.That(MathExtensions.IsPointOnLineSegmentButNotOnVertex(b, a, b, 0.001f), Is.False); + } + + [Test] + public void IsPointOnLineSegment_ProductionEpsilonsRejectNearbyDistinctVertex() + { + var a = new float3(93.90189f, -15.86011f, 38.64029f); // bottom end of the edge + var b = new float3(93.90189f, -13.41992f, 38.64029f); // top end of the edge + var v = new float3(93.90186f, -15.85986f, 38.65869f); // neighbour's vertex, 0.0184 off in z + + Assert.That(MathExtensions.IsPointOnLineSegment(v, a, b, CSGConstants.kSqrVertexEqualEpsilon, CSGConstants.kSqrEdgeDistanceEpsilon), Is.False); + Assert.That(MathExtensions.IsPointOnLineSegmentButNotOnVertex(v, a, b, CSGConstants.kSqrEdgeDistanceEpsilon), Is.False); + + // The un-squared constant would let it through: 0.0184^2 = 0.00034 < 0.0006 + Assert.That(MathExtensions.IsPointOnLineSegment(v, a, b, CSGConstants.kVertexEqualEpsilon, CSGConstants.kEdgeIntersectionEpsilon), Is.True); + + // A vertex on the edge (0.1 mm off, inside the 0.6 mm band) still splits it + var on = new float3(93.90199f, -15.0f, 38.64029f); + Assert.That(MathExtensions.IsPointOnLineSegment(on, a, b, CSGConstants.kSqrVertexEqualEpsilon, CSGConstants.kSqrEdgeDistanceEpsilon), Is.True); + } + + [Test] + public void SqrDistanceFromPointToLineSegment_OnSegmentBeyondAndOn() + { + var a = new float3(0, 0, 0); + var b = new float3(1, 0, 0); + Assert.That(MathExtensions.SqrDistanceFromPointToLineSegment(new float3(0.5f, 0.5f, 0), a, b), Is.EqualTo(0.25f).Within(1e-5f)); + Assert.That(MathExtensions.SqrDistanceFromPointToLineSegment(new float3(2, 0, 0), a, b), Is.EqualTo(1f).Within(1e-5f)); + Assert.That(MathExtensions.SqrDistanceFromPointToLineSegment(new float3(0.5f, 0, 0), a, b), Is.EqualTo(0f).Within(1e-5f)); + } + + // -- Line/line intersection ------------------------------------------------------- + + [Test] + public void LineLineIntersection_CrossingSegments_ReturnsPoint() + { + var ok = MathExtensions.LineLineIntersection( + new Vector3(0, 0, 0), new Vector3(2, 0, 0), + new Vector3(1, -1, 0), new Vector3(1, 1, 0), + out var intersection, 0.01); + Assert.That(ok, Is.True); + AssertApproximately(new Vector3(1, 0, 0), intersection, 1e-3f); + } + + [Test] + public void LineLineIntersection_ParallelSegments_ReturnsFalse() + { + var ok = MathExtensions.LineLineIntersection( + new Vector3(0, 0, 0), new Vector3(1, 0, 0), + new Vector3(0, 1, 0), new Vector3(1, 1, 0), + out _, 0.01); + Assert.That(ok, Is.False); + } + + [Test] + public void LineLineIntersection_SkewSegments_ReturnsFalse() + { + // line 1 is the x axis at z=0; line 2 is vertical at x=0.5, z=1. + // They are not parallel, but never approach within epsilon -> no intersection. + var ok = MathExtensions.LineLineIntersection( + new Vector3(0, 0, 0), new Vector3(1, 0, 0), + new Vector3(0.5f, -1, 1), new Vector3(0.5f, 1, 1), + out _, 0.01); + Assert.That(ok, Is.False); + } + + // -- Plane fitting ---------------------------------------------------------------- + + [Test] + public void CalculatePlane_FitsPolygonPlane() + { + var quad = new[] + { + new Vector3(0, 0, 0), new Vector3(1, 0, 0), + new Vector3(1, 1, 0), new Vector3(0, 1, 0) + }; + var plane = MathExtensions.CalculatePlane(quad); + + // Plane is the z=0 plane: normal parallel to z, all vertices lie on it. + Assert.That(Mathf.Abs(plane.normal.z), Is.EqualTo(1f).Within(1e-4f)); + Assert.That(Mathf.Abs(plane.normal.x), Is.EqualTo(0f).Within(1e-4f)); + Assert.That(Mathf.Abs(plane.normal.y), Is.EqualTo(0f).Within(1e-4f)); + foreach (var v in quad) + Assert.That(IsOnPlane(plane, v), Is.True, $"vertex {v} should lie on the fitted plane"); + } + + // -- Plane transforms ------------------------------------------------------------- + + [Test] + public void Transform_MovesPlaneWithTranslation() + { + var plane = new Plane(new Vector3(0, 1, 0), 0f); // y = 0 + var matrix = Matrix4x4.Translate(new Vector3(0, 5, 0)); + var moved = matrix.Transform(plane); + + // A point on the original plane, pushed through the matrix, lies on the transformed plane. + var onOriginal = new Vector3(3, 0, 7); + var transformed = matrix.MultiplyPoint3x4(onOriginal); + Assert.That(IsOnPlane(moved, transformed), Is.True); + } + + [Test] + public void Transform_HandlesRotationAndTranslation() + { + var plane = new Plane(new Vector3(0, 1, 0), -1f); // y = 1 + var matrix = Matrix4x4.TRS(new Vector3(1, 2, 3), Quaternion.Euler(0, 90, 0), Vector3.one); + var moved = matrix.Transform(plane); + + foreach (var p in new[] { new Vector3(2, 1, 5), new Vector3(-4, 1, 8), new Vector3(0, 1, 0) }) + { + var transformed = matrix.MultiplyPoint3x4(p); + Assert.That(IsOnPlane(moved, transformed, 1e-3f), Is.True, + $"transformed point {transformed} should lie on the transformed plane"); + } + } + + [Test] + public void InverseTransform_IsInverseOfTransform() + { + var plane = new Plane(new Vector3(0, 1, 0), 0f); + var matrix = Matrix4x4.Translate(new Vector3(0, 5, 0)); + var inv = matrix.InverseTransform(plane); + + // InverseTransform pulls the plane the opposite way: a point on the original plane, + // pushed through the inverse matrix, lies on the inverse-transformed plane. + var onOriginal = new Vector3(3, 0, 7); + var transformed = matrix.inverse.MultiplyPoint3x4(onOriginal); + Assert.That(IsOnPlane(inv, transformed), Is.True); + } + + [Test] + public void ProjectPointPlane_ProjectsOntoPlane() + { + // plane y = 0 (Unity distance 0): (0,5,0) -> (0,0,0) + AssertApproximately(new double3(0, 0, 0), + MathExtensions.ProjectPointPlane(new double3(0, 5, 0), new double4(0, 1, 0, 0))); + + // plane y = 2 (Unity distance -2): (0,5,0) -> (0,2,0) + AssertApproximately(new double3(0, 2, 0), + MathExtensions.ProjectPointPlane(new double3(0, 5, 0), new double4(0, 1, 0, -2)), 1e-6); + } + } +} diff --git a/Core/Tests/MathExtensionsTests.cs.meta b/Core/Tests/MathExtensionsTests.cs.meta new file mode 100644 index 00000000..1e018c03 --- /dev/null +++ b/Core/Tests/MathExtensionsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a38eb82477ef34e47adc95eec2eb08ec \ No newline at end of file diff --git a/Core/Tests/MeshManifoldValidationTests.cs b/Core/Tests/MeshManifoldValidationTests.cs new file mode 100644 index 00000000..276ab8c3 --- /dev/null +++ b/Core/Tests/MeshManifoldValidationTests.cs @@ -0,0 +1,252 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class MeshManifoldValidationTests + { + static MeshManifoldReport Classify(float3[] vertices, int[] indices, float weldEpsilon = MeshManifoldValidation.kDefaultWeldEpsilon) + { + var nativeVertices = new NativeArray(vertices, Allocator.Temp); + var nativeIndices = new NativeArray(indices, Allocator.Temp); + var report = MeshManifoldValidation.Classify(nativeVertices, nativeIndices, weldEpsilon); + nativeIndices.Dispose(); + nativeVertices.Dispose(); + return report; + } + + // shapes + + static readonly float3[] kTetrahedronVertices = + { + new float3(0, 0, 0), new float3(1, 0, 0), new float3(0, 1, 0), new float3(0, 0, 1) + }; + + static readonly int[] kTetrahedronIndices = + { + 0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3 + }; + + static readonly float3[] kCubeVertices = + { + new float3(0, 0, 0), new float3(1, 0, 0), new float3(1, 1, 0), new float3(0, 1, 0), + new float3(0, 0, 1), new float3(1, 0, 1), new float3(1, 1, 1), new float3(0, 1, 1) + }; + + // 6 quads, each split into 2 triangles: 18 distinct edges (12 cube edges + 6 face diagonals) + static readonly int[] kCubeIndices = + { + 0, 2, 1, 0, 3, 2, // z = 0 + 4, 5, 6, 4, 6, 7, // z = 1 + 0, 4, 7, 0, 7, 3, // x = 0 + 1, 2, 6, 1, 6, 5, // x = 1 + 0, 1, 5, 0, 5, 4, // y = 0 + 3, 7, 6, 3, 6, 2 // y = 1 + }; + + static void UnsharedCube(float jitter, out float3[] vertices, out int[] indices) + { + var quads = new[] + { + new[] { 0, 3, 2, 1 }, // z = 0 + new[] { 4, 5, 6, 7 }, // z = 1 + new[] { 0, 4, 7, 3 }, // x = 0 + new[] { 1, 2, 6, 5 }, // x = 1 + new[] { 0, 1, 5, 4 }, // y = 0 + new[] { 3, 7, 6, 2 } // y = 1 + }; + vertices = new float3[quads.Length * 4]; + indices = new int[quads.Length * 6]; + var copies = new int[kCubeVertices.Length]; + for (int q = 0; q < quads.Length; q++) + { + int first = q * 4; + for (int c = 0; c < 4; c++) + { + int index = first + c; + int corner = quads[q][c]; + vertices[index] = kCubeVertices[corner] + Stray(copies[corner]++, jitter); + } + int t = q * 6; + indices[t + 0] = first; indices[t + 1] = first + 1; indices[t + 2] = first + 2; + indices[t + 3] = first; indices[t + 4] = first + 2; indices[t + 5] = first + 3; + } + } + + static float3 Stray(int copy, float amount) + { + switch (copy) + { + case 0: return new float3(amount, 0, 0); + case 1: return new float3(0, amount, 0); + default: return new float3(0, 0, amount); + } + } + + static float3[] Concat(float3[] a, float3[] b) + { + var result = new float3[a.Length + b.Length]; + a.CopyTo(result, 0); + b.CopyTo(result, a.Length); + return result; + } + + static int[] Concat(int[] a, int[] b) + { + var result = new int[a.Length + b.Length]; + a.CopyTo(result, 0); + b.CopyTo(result, a.Length); + return result; + } + + // closed shapes + + [Test] + public void Tetrahedron_IsClosedManifold() + { + var report = Classify(kTetrahedronVertices, kTetrahedronIndices); + Assert.IsTrue(report.IsClosedManifold); + Assert.AreEqual(6, report.edgeCount); + Assert.AreEqual(2, report.maxEdgeUseCount); + } + + [Test] + public void Cube_IsClosedManifold() + { + var report = Classify(kCubeVertices, kCubeIndices); + Assert.IsTrue(report.IsClosedManifold); + Assert.AreEqual(8, report.weldedVertexCount); + Assert.AreEqual(12, report.triangleCount); + Assert.AreEqual(18, report.edgeCount); + Assert.AreEqual(0, report.boundaryEdgeCount); + Assert.AreEqual(0, report.nonManifoldEdgeCount); + Assert.AreEqual(0f, report.boundaryEdgeLength); + } + + // holes + + [Test] + public void CubeMissingOneFace_HasFourBoundaryEdges() + { + // drop the two triangles of the z = 0 face; its diagonal vanishes with them + var indices = new int[kCubeIndices.Length - 6]; + System.Array.Copy(kCubeIndices, 6, indices, 0, indices.Length); + + var report = Classify(kCubeVertices, indices); + Assert.IsFalse(report.IsClosedManifold); + Assert.AreEqual(4, report.boundaryEdgeCount); + Assert.AreEqual(17, report.edgeCount); + Assert.AreEqual(0, report.nonManifoldEdgeCount); + // the hole is a unit square, so its rim is four unit-length edges + Assert.AreEqual(4f, report.boundaryEdgeLength, 1e-5f); + Assert.AreEqual(1f, report.longestBoundaryEdgeLength, 1e-5f); + } + + [Test] + public void BoundaryEdgeLength_DistinguishesAHairlineSeamFromAMissingWall() + { + var indices = new int[kCubeIndices.Length - 6]; + System.Array.Copy(kCubeIndices, 6, indices, 0, indices.Length); + + var big = Classify(kCubeVertices, indices, 0f); + + var small = new float3[kCubeVertices.Length]; + for (int i = 0; i < small.Length; i++) + small[i] = kCubeVertices[i] * 0.001f; + var tiny = Classify(small, indices, 0f); + + Assert.AreEqual(big.boundaryEdgeCount, tiny.boundaryEdgeCount); + Assert.Greater(big.boundaryEdgeLength, tiny.boundaryEdgeLength * 100f); + } + + // duplicated and overlapping surfaces + + [Test] + public void DuplicatedTriangle_IsNonManifold() + { + var indices = Concat(kCubeIndices, new[] { 0, 2, 1 }); + + var report = Classify(kCubeVertices, indices); + Assert.IsFalse(report.IsClosedManifold); + Assert.AreEqual(0, report.boundaryEdgeCount); + Assert.AreEqual(3, report.nonManifoldEdgeCount); + Assert.AreEqual(3, report.maxEdgeUseCount); + } + + [Test] + public void DegenerateTriangles_AreCountedAndDoNotContributeEdges() + { + var indices = Concat(kCubeIndices, new[] { 0, 0, 1, 3, 3, 3 }); + + var report = Classify(kCubeVertices, indices); + Assert.AreEqual(2, report.degenerateTriangleCount); + Assert.IsTrue(report.IsClosedManifold); + Assert.AreEqual(18, report.edgeCount); + } + + // welding + + [Test] + public void SeparatelyComputedVertices_AtTheSamePosition_WeldIntoOneClosedSurface() + { + UnsharedCube(1e-6f, out var vertices, out var indices); + + var unwelded = Classify(vertices, indices, 0f); + Assert.AreEqual(24, unwelded.weldedVertexCount); + Assert.AreEqual(30, unwelded.edgeCount); // 6 faces x (4 rim + 1 diagonal) + Assert.AreEqual(24, unwelded.boundaryEdgeCount); // every rim edge belongs to one face only + Assert.IsFalse(unwelded.IsClosedManifold); + + var welded = Classify(vertices, indices, MeshManifoldValidation.kDefaultWeldEpsilon); + Assert.AreEqual(8, welded.weldedVertexCount); + Assert.AreEqual(18, welded.edgeCount); + Assert.IsTrue(welded.IsClosedManifold); + } + + [Test] + public void VerticesFurtherApartThanTheWeldEpsilon_AreNotMerged() + { + // things that are apart are not fused into a manifold + UnsharedCube(1e-3f, out var vertices, out var indices); + + var report = Classify(vertices, indices, MeshManifoldValidation.kDefaultWeldEpsilon); + Assert.AreEqual(24, report.weldedVertexCount); + Assert.AreEqual(24, report.boundaryEdgeCount); + Assert.IsFalse(report.IsClosedManifold); + } + + // T-junctions: no gap, but not stitched + + [Test] + public void TJunction_AddsThreeBoundaryEdges_AlthoughTheSurfaceHasNoGap() + { + var shared = new[] + { + new float3(0, 0, 0), new float3(1, 0, 0), new float3(1, 1, 0), new float3(0, 1, 0), + new float3(2, 0, 0), new float3(2, 1, 0), new float3(1, 0.5f, 0) + }; + + var stitched = new[] { 0, 1, 2, 0, 2, 3, 1, 4, 5, 1, 5, 2 }; + var tJunction = new[] { 0, 1, 6, 0, 6, 2, 0, 2, 3, 1, 4, 5, 1, 5, 2 }; + + var withoutT = Classify(shared, stitched); + var withT = Classify(shared, tJunction); + + Assert.AreEqual(withoutT.boundaryEdgeCount + 3, withT.boundaryEdgeCount); + Assert.AreEqual(0, withT.nonManifoldEdgeCount); + } + + // degenerate input + + [Test] + public void EmptyMesh_ReportsNothingRatherThanFailing() + { + var report = Classify(new float3[0], new int[0]); + Assert.AreEqual(0, report.triangleCount); + Assert.AreEqual(0, report.edgeCount); + Assert.IsTrue(report.IsClosedManifold); + } + } +} diff --git a/Core/Tests/MeshManifoldValidationTests.cs.meta b/Core/Tests/MeshManifoldValidationTests.cs.meta new file mode 100644 index 00000000..78547a1e --- /dev/null +++ b/Core/Tests/MeshManifoldValidationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 050d19ae02bca0647b7b2beeee40c689 \ No newline at end of file diff --git a/Core/Tests/MeshQueryRoutingTests.cs b/Core/Tests/MeshQueryRoutingTests.cs new file mode 100644 index 00000000..1b6a7c7f --- /dev/null +++ b/Core/Tests/MeshQueryRoutingTests.cs @@ -0,0 +1,257 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class MeshQueryRoutingTests + { + readonly static int[] kValidRenderableIndices = { 2, 3, 7, 9, 10, 11, 13, 15 }; + const int kShadowCastingRendererIndex = 2; // ‹[generated-ShadowCasting]›, ie shadow-only + const int kDefaultRendererIndex = 7; // ‹[generated-Renderable|ShadowCasting|ShadowReceiving]› + const int kRenderableRendererIndex = 9; // ‹[generated-Renderable|ExcludedFromGlobalIllumination]› + const int kReceivingRendererIndex = 13; // ‹[generated-Renderable|ShadowReceiving|ExcludedFromGlobalIllumination]› + const int kExcludedRendererIndex = 15; // ‹[generated-Renderable|ShadowCasting|ShadowReceiving|ExcludedFromGlobalIllumination]› + + // Renderable, ShadowCasting, ShadowReceiving, Collidable and ExcludedFromGlobalIllumination; + // Discarded is not a combination a real surface carries alongside the others, so it is left out. + const int kFlagCombinations = 32; + + static bool Matches(SurfaceDestinationFlags destinationFlags, MeshQuery query) + { + return (destinationFlags & query.LayerQueryMask) == query.LayerQuery; + } + + static int RenderIndexOf(MeshQuery query) + { + return query.LayerQuery.RendererIndex(); + } + + static bool HasDebugRenderer(MeshQuery query) + { + foreach (var flags in AssignMeshesJob.kGeneratedDebugRendererFlags) + { + if (flags.Item1 == query.LayerQuery && + flags.Item2 == query.LayerQueryMask) + return true; + } + return false; + } + + // Finds the queries that would claim a surface with these flags, restricted to the ones + // that produce a renderer (so colliders and debug visualization do not count as "rendered"). + static List RenderQueriesFor(SurfaceDestinationFlags destinationFlags) + { + var found = new List(); + foreach (var query in MeshQuery.DefaultQueries) + { + if (query.LayerParameterIndex != SurfaceParameterIndex.RenderMaterial) + continue; + if (Matches(destinationFlags, query)) + found.Add(query); + } + return found; + } + + static int RendererIndexFor(SurfaceDestinationFlags destinationFlags) + { + var queries = RenderQueriesFor(destinationFlags.Normalize()); + Assert.That(queries.Count, Is.EqualTo(1), + $"Expected exactly one renderable query to claim a surface with {destinationFlags}, found {queries.Count}."); + return RenderIndexOf(queries[0]); + } + + [Test] + public void DefaultQueries_EveryQueryHasADestination() + { + foreach (var query in MeshQuery.DefaultQueries) + { + switch (query.LayerParameterIndex) + { + case SurfaceParameterIndex.None: + { + Assert.That(HasDebugRenderer(query), Is.True, + $"{query} is tagged SurfaceParameterIndex.None, so AssignMeshesJob treats it as a " + + $"debug visualization query, but no entry in kGeneratedDebugRendererFlags has that " + + $"query/mask pair - every surface matching it is silently discarded. If this is meant " + + $"to produce a renderable mesh, give it SurfaceParameterIndex.RenderMaterial."); + break; + } + case SurfaceParameterIndex.RenderMaterial: + { + Assert.That(kValidRenderableIndices, Contains.Item(RenderIndexOf(query)), + $"{query} routes to renderables[{RenderIndexOf(query)}], which is an invalid slot."); + break; + } + case SurfaceParameterIndex.PhysicsMaterial: + { + Assert.That(query.LayerQuery & SurfaceDestinationFlags.Collidable, + Is.EqualTo(SurfaceDestinationFlags.Collidable), + $"{query} is tagged PhysicsMaterial but does not ask for collidable surfaces."); + break; + } + default: + { + Assert.Fail($"{query} uses a parameter index AssignMeshesJob does not handle."); + break; + } + } + } + } + + // Every generated renderer has the one query that fills it. + [Test] + public void DefaultQueries_EveryRendererHasOneQuery() + { + foreach (var index in kValidRenderableIndices) + { + var count = 0; + foreach (var query in MeshQuery.DefaultQueries) + { + if (query.LayerParameterIndex == SurfaceParameterIndex.RenderMaterial && RenderIndexOf(query) == index) + count++; + } + Assert.That(count, Is.EqualTo(1), $"renderables[{index}] is filled by {count} queries"); + } + } + + // A surface that casts shadows but isn't rendered reaches ‹[generated-ShadowCasting]›, which casts it with the ForceShadowOnly material + [Test] + public void DefaultQueries_ShadowOnlySurfaceReachesTheShadowCastingRenderer() + { + var shadowOnly = SurfaceDestinationFlags.ShadowCasting | SurfaceDestinationFlags.Collidable; + Assert.That(RendererIndexFor(shadowOnly), Is.EqualTo(kShadowCastingRendererIndex)); + } + + [Test] + public void DefaultQueries_DefaultSurfaceReachesTheRendererInTheBake() + { + Assert.That((int)SurfaceDestinationFlags.Default, Is.EqualTo(15), "surfaces saved before the flag existed say 15"); + Assert.That(RendererIndexFor(SurfaceDestinationFlags.Default), Is.EqualTo(kDefaultRendererIndex)); + } + + [Test] + public void DefaultQueries_SurfaceThatCastsNoShadowsStaysOutOfTheBake() + { + var sky = SurfaceDestinationFlags.Renderable | SurfaceDestinationFlags.Collidable; + Assert.That(RendererIndexFor(sky), Is.EqualTo(kRenderableRendererIndex)); + + var receiving = SurfaceDestinationFlags.RenderShadowsReceiving; + Assert.That(RendererIndexFor(receiving), Is.EqualTo(kReceivingRendererIndex)); + } + + // A surface that casts shadows can be left out of the bake, and then it is drawn by a renderer of its own. + [Test] + public void DefaultQueries_ShadowCasterCanStayOutOfTheBake() + { + var excluded = SurfaceDestinationFlags.Default | SurfaceDestinationFlags.ExcludedFromGlobalIllumination; + Assert.That(RendererIndexFor(excluded), Is.EqualTo(kExcludedRendererIndex)); + } + + // Shadow-only surfaces are solid walls the player never sees, and they block light in a bake as they + // block it in the game, so they take part in one unless they say otherwise. + [Test] + public void DefaultQueries_ShadowOnlySurfaceIsInTheBake() + { + var shadowOnly = SurfaceDestinationFlags.ShadowCasting | SurfaceDestinationFlags.Collidable; + Assert.That(shadowOnly.Normalize() & SurfaceDestinationFlags.ExcludedFromGlobalIllumination, + Is.EqualTo(SurfaceDestinationFlags.None)); + Assert.That(RendererIndexFor(shadowOnly), Is.EqualTo(kShadowCastingRendererIndex)); + } + + [Test] + public void DefaultQueries_EveryDebugHelperHasAQuery() + { + foreach (var flags in AssignMeshesJob.kGeneratedDebugRendererFlags) + { + var found = false; + foreach (var query in MeshQuery.DefaultQueries) + { + if (query.LayerParameterIndex == SurfaceParameterIndex.None && + query.LayerQuery == flags.Item1 && query.LayerQueryMask == flags.Item2) + found = true; + } + Assert.That(found, Is.True, $"no query fills the debug helper for {flags.Item1} (mask {flags.Item2})"); + } + } + + // Two renderable queries claiming the same surface would draw it twice + [Test] + public void DefaultQueries_NoSurfaceIsClaimedByTwoRenderableQueries() + { + for (int i = 0; i < kFlagCombinations; i++) + { + var destinationFlags = (SurfaceDestinationFlags)i; + var queries = RenderQueriesFor(destinationFlags); + Assert.That(queries.Count, Is.LessThanOrEqualTo(1), + $"A surface with {destinationFlags} is claimed by {queries.Count} renderable queries."); + } + } + + // Receiving shadows only means anything while a surface is being drawn. + [Test] + public void Normalize_DropsShadowReceivingWhenNotRenderable() + { + var shadowOnly = SurfaceDestinationFlags.ShadowCasting | + SurfaceDestinationFlags.ShadowReceiving | + SurfaceDestinationFlags.Collidable; + Assert.That(shadowOnly.Normalize(), + Is.EqualTo(SurfaceDestinationFlags.ShadowCasting | SurfaceDestinationFlags.Collidable)); + + // ... and is left alone when the surface IS drawn. + var normal = SurfaceDestinationFlags.Default; + Assert.That(normal.Normalize(), Is.EqualTo(normal)); + } + + // Blocking light in a bake only means anything for a surface that blocks it as it is drawn. + [Test] + public void Normalize_ExcludesFromGlobalIlluminationWhenNotCastingShadows() + { + var sky = SurfaceDestinationFlags.Renderable | SurfaceDestinationFlags.Collidable; + Assert.That(sky.Normalize(), Is.EqualTo(sky | SurfaceDestinationFlags.ExcludedFromGlobalIllumination)); + + // ... and a surface that casts shadows is left as it asks to be, drawn or not. + var shadowOnly = SurfaceDestinationFlags.ShadowCasting | SurfaceDestinationFlags.Collidable; + Assert.That(shadowOnly.Normalize(), Is.EqualTo(shadowOnly)); + Assert.That(SurfaceDestinationFlags.Default.Normalize(), Is.EqualTo(SurfaceDestinationFlags.Default)); + } + + [Test] + public void EveryNormalizedFlagCombination_RoutesSomewhere() + { + for (int i = 0; i < kFlagCombinations; i++) + { + var destinationFlags = ((SurfaceDestinationFlags)i).Normalize(); + + var claimed = false; + foreach (var query in MeshQuery.DefaultQueries) + { + if (!Matches(destinationFlags, query)) + continue; + claimed = true; + if (query.LayerParameterIndex == SurfaceParameterIndex.RenderMaterial) + Assert.That(kValidRenderableIndices, Contains.Item(RenderIndexOf(query))); + } + + Assert.That(claimed, Is.True, + $"a surface with {destinationFlags} (from {(SurfaceDestinationFlags)i}) matches no query at all"); + } + } + + [Test] + public void DefaultQueries_EveryRenderableSurfaceCombinationIsClaimed() + { + for (int i = 0; i < kFlagCombinations; i++) + { + var destinationFlags = ((SurfaceDestinationFlags)i).Normalize(); + if ((destinationFlags & SurfaceDestinationFlags.Renderable) != SurfaceDestinationFlags.Renderable) + continue; + + var queries = RenderQueriesFor(destinationFlags); + Assert.That(queries.Count, Is.EqualTo(1), + $"A renderable surface with {destinationFlags} is claimed by no renderable query."); + Assert.That(kValidRenderableIndices, Contains.Item(RenderIndexOf(queries[0]))); + } + } + } +} diff --git a/Core/Tests/MeshQueryRoutingTests.cs.meta b/Core/Tests/MeshQueryRoutingTests.cs.meta new file mode 100644 index 00000000..b2a3b39c --- /dev/null +++ b/Core/Tests/MeshQueryRoutingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 803f7f679801a0d4c871a137aef5fab6 \ No newline at end of file diff --git a/Core/Tests/OutputMeshBoundsTests.cs b/Core/Tests/OutputMeshBoundsTests.cs new file mode 100644 index 00000000..a02d069c --- /dev/null +++ b/Core/Tests/OutputMeshBoundsTests.cs @@ -0,0 +1,124 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class OutputMeshBoundsTests + { + [Test] + public void ACopyThatExactlyFillsTheBuffer_Fits() + { + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(0, 10, 10), Is.True); + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(7, 3, 10), Is.True); + } + + [Test] + public void ACopyOnePastTheEnd_DoesNotFit() + { + // the exact shape of the original failure: writing index N into an N-length buffer + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(0, 11, 10), Is.False); + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(10, 1, 10), Is.False); + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(8, 3, 10), Is.False); + } + + [Test] + public void AnEmptyCopy_Fits_EvenAtTheveryEnd() + { + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(10, 0, 10), Is.True); + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(0, 0, 0), Is.True); + } + + [Test] + public void NegativeOffsetsOrCounts_DoNotFit() + { + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(-1, 1, 10), Is.False); + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(0, -1, 10), Is.False); + } + + [Test] + public void NothingFitsInAZeroLengthBuffer_ExceptAnEmptyCopy() + { + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(0, 1, 0), Is.False); + Assert.That(ChiselOutputMeshValidation.FitsInBuffer(0, 0, 0), Is.True); + } + } + + [TestFixture] + public class VisibilityMeshGenerationTests + { + // every brush visible, so nothing is filtered out for the wrong reason + struct AllVisible : IBrushVisibilityLookup + { + public bool IsBrushVisible(CompactNodeID brushID) { return true; } + public bool IsBrushVisible(ulong entityID) { return true; } + } + + static ManagedSubMeshTriangleLookup LookupFor(int triangleCount) + { + var lookup = new ManagedSubMeshTriangleLookup + { + perTriangleNodeIDLookup = new CompactNodeID[triangleCount], + perTriangleSelectionIDLookup = new int[triangleCount], + perTriangleSurfaceIndexLookup = new int[triangleCount], + selectionIndexDescriptions = new SelectionDescription[1], + validTriangleCount = triangleCount + }; + return lookup; + } + + [Test] + public void ASourceMeshWithTrianglesButNoVertices_ProducesAnEmptyMeshInsteadOfThrowing() + { + var src = new Mesh { indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 }; + // declare a sub-mesh whose indices cannot possibly be valid - no vertices were ever set + src.SetVertices(new Vector3[0]); + src.subMeshCount = 1; + src.SetIndexBufferParams(3, UnityEngine.Rendering.IndexFormat.UInt32); + src.SetIndexBufferData(new int[] { 0, 1, 2 }, 0, 0, 3, UnityEngine.Rendering.MeshUpdateFlags.DontValidateIndices); + src.SetSubMesh(0, new UnityEngine.Rendering.SubMeshDescriptor(0, 3), + UnityEngine.Rendering.MeshUpdateFlags.DontValidateIndices | UnityEngine.Rendering.MeshUpdateFlags.DontRecalculateBounds); + + var dst = new Mesh(); + var lookup = LookupFor(1); + + Assert.DoesNotThrow(() => lookup.GenerateSubMesh(new AllVisible(), src, dst)); + Assert.That(dst.vertexCount, Is.EqualTo(0), "an unusable source must not leave geometry behind"); + + Object.DestroyImmediate(src); + Object.DestroyImmediate(dst); + } + + [Test] + public void OneOutOfRangeTriangle_DoesNotDiscardTheWholeSubMesh() + { + // SetTriangles rejects a whole sub-mesh if any index is out of range: a bad triangle mustn't cost the good ones + var src = new Mesh { indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 }; + src.SetVertices(new[] + { + new Vector3(0, 0, 0), new Vector3(1, 0, 0), new Vector3(0, 1, 0), + new Vector3(1, 1, 0) + }); + src.subMeshCount = 1; + src.SetIndexBufferParams(9, UnityEngine.Rendering.IndexFormat.UInt32); + // two good triangles, then one referencing a vertex that does not exist + src.SetIndexBufferData(new int[] { 0, 1, 2, 1, 3, 2, 0, 1, 99 }, 0, 0, 9, + UnityEngine.Rendering.MeshUpdateFlags.DontValidateIndices); + src.SetSubMesh(0, new UnityEngine.Rendering.SubMeshDescriptor(0, 9), + UnityEngine.Rendering.MeshUpdateFlags.DontValidateIndices | UnityEngine.Rendering.MeshUpdateFlags.DontRecalculateBounds); + + var dst = new Mesh(); + var lookup = LookupFor(3); + + Assert.DoesNotThrow(() => lookup.GenerateSubMesh(new AllVisible(), src, dst)); + Assert.That(dst.subMeshCount, Is.EqualTo(1)); + Assert.That(dst.GetIndexCount(0), Is.EqualTo(6), + "the two valid triangles must survive; only the out-of-range one is dropped"); + + Object.DestroyImmediate(src); + Object.DestroyImmediate(dst); + } + } +} diff --git a/Core/Tests/OutputMeshBoundsTests.cs.meta b/Core/Tests/OutputMeshBoundsTests.cs.meta new file mode 100644 index 00000000..3d2a3217 --- /dev/null +++ b/Core/Tests/OutputMeshBoundsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 075ac5473f2e8184b979b7a898b03d3f \ No newline at end of file diff --git a/Core/Tests/OutputModelWeldTests.cs b/Core/Tests/OutputModelWeldTests.cs new file mode 100644 index 00000000..223acb37 --- /dev/null +++ b/Core/Tests/OutputModelWeldTests.cs @@ -0,0 +1,700 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class OutputModelWeldTests + { + // A seam along x. The needle's surface lies in z = 0: u-w-p, with the needle u-w-m on its edge u-w. The surface + // across lies in y = 0: w-u-d. + static readonly float3 U = new float3(0, 0, 0), M = new float3(1, 0, 0), W = new float3(2, 0, 0); + static readonly float3 P = new float3(1, 1, 0), D = new float3(1, 0, -1); + static readonly float3[] kNeedleSurface = { U, W, M, W, P, M, P, U, M }; + static readonly float3[] kSurfaceAcross = { W, U, D }; + + // What a vertex carries follows from its position, as a surface's attributes follow from its plane + static RenderVertex Render(float3 p) + { + return new RenderVertex { position = p, normal = new float3(0, 0, 1), tangent = new float4(1, 0, 0, 1), + uv0 = new float2(p.x * 0.5f, p.y + p.z), uv1 = new float2(p.x, p.y - p.z) * 0.25f }; + } + + static BlobAssetReference Brush(params float3[][] surfaces) + { + return Brush(SurfaceDestinationFlags.Renderable | SurfaceDestinationFlags.Collidable, surfaces); + } + + // The same, its surfaces going into the meshes `flags` say + static BlobAssetReference Brush(SurfaceDestinationFlags flags, params float3[][] surfaces) + { + var builder = new BlobBuilder(Allocator.Temp); + try + { + ref var root = ref builder.ConstructRoot(); + var buffers = builder.Allocate(ref root.surfaces, surfaces.Length); + for (int s = 0; s < surfaces.Length; s++) + { + using var indices = new NativeList(Allocator.Temp); + using var collider = new NativeList(Allocator.Temp); + using var select = new NativeList(Allocator.Temp); + using var render = new NativeList(Allocator.Temp); + foreach (var p in surfaces[s]) + { + indices.Add(collider.Length); + collider.Add(p); + select.Add(new SelectVertex { position = p, entityID = new Vector4(1, 0, 0, 0) }); + render.Add(Render(p)); + } + buffers[s].Construct(builder, indices, collider, select, render, s, s, flags, default, default); + } + var queries = builder.Allocate(ref root.querySurfaces, 1); + using var list = new NativeList(Allocator.Temp); + for (int s = 0; s < surfaces.Length; s++) + { + if (buffers[s].indexCount == 0) + continue; + list.Add(new ChiselQuerySurface { surfaceIndex = s, vertexCount = buffers[s].vertexCount, indexCount = buffers[s].indexCount, + geometryHashValue = buffers[s].geometryHashValue, surfaceHashValue = buffers[s].surfaceHashValue }); + } + builder.Construct(ref queries[0].surfaces, list); + root.surfaceOffset = 0; + root.surfaceCount = surfaces.Length; + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + finally + { + builder.Dispose(); + } + } + + // The brushes of a model as the meshes gather them, welded across the model; disposes every buffer it saw + sealed class Model : System.IDisposable + { + public readonly BlobAssetReference[] originals; + public NativeList brushes = new NativeList(Allocator.Temp); + public NativeList> patched = new NativeList>(Allocator.Temp); + public int split, paired, cancelled; + public readonly Dictionary<(float3, float3), int> boundaryBefore; + + public Model(params BlobAssetReference[] buffers) + { + originals = buffers; + foreach (var buffer in buffers) + brushes.Add(new BrushData { brushRenderBuffer = buffer, brushSurfaceCount = buffer.Value.surfaceCount }); + boundaryBefore = Boundary(buffers); + OutputModelWeld.WeldModel(brushes, patched, out split, out paired, out cancelled); + } + + public BlobAssetReference this[int brush] => brushes[brush].brushRenderBuffer; + + public BlobAssetReference[] Buffers() + { + var result = new BlobAssetReference[brushes.Length]; + for (int b = 0; b < brushes.Length; b++) + result[b] = brushes[b].brushRenderBuffer; + return result; + } + + public void Dispose() + { + foreach (var copy in patched) copy.Dispose(); + foreach (var original in originals) original.Dispose(); + brushes.Dispose(); + patched.Dispose(); + } + } + + static List<(float3 a, float3 b, float3 c)> Triangles(BlobAssetReference buffer, int surface) + { + ref var s = ref buffer.Value.surfaces[surface]; + var result = new List<(float3, float3, float3)>(); + for (int i = 0; i + 2 < s.indices.Length; i += 3) + result.Add((s.colliderVertices[s.indices[i]], s.colliderVertices[s.indices[i + 1]], s.colliderVertices[s.indices[i + 2]])); + return result; + } + + // The directed edges no other edge runs the other way, over every surface of every brush: what a model's surface + // closes against + static Dictionary<(float3, float3), int> Boundary(IEnumerable> buffers) + { + var count = new Dictionary<(float3, float3), int>(); + foreach (var buffer in buffers) + { + for (int s = 0; s < buffer.Value.surfaces.Length; s++) + { + foreach (var (a, b, c) in Triangles(buffer, s)) + { + foreach (var (from, to) in new[] { (a, b), (b, c), (c, a) }) + { + count.TryGetValue((from, to), out var n); + count[(from, to)] = n + 1; + } + } + } + } + var net = new Dictionary<(float3, float3), int>(); + foreach (var pair in count) + { + count.TryGetValue((pair.Key.Item2, pair.Key.Item1), out var back); + if (pair.Value > back) + net[pair.Key] = pair.Value - back; + } + return net; + } + + static bool HasTriangle(List<(float3 a, float3 b, float3 c)> triangles, float3 a, float3 b, float3 c) + { + foreach (var t in triangles) + { + if ((t.a.Equals(a) && t.b.Equals(b) && t.c.Equals(c)) || (t.a.Equals(b) && t.b.Equals(c) && t.c.Equals(a)) || + (t.a.Equals(c) && t.b.Equals(a) && t.c.Equals(b))) + return true; + } + return false; + } + + static void AssertStoredAsTheCSGStoresIt(BlobAssetReference buffer, string what) + { + ref var brush = ref buffer.Value; + for (int s = 0; s < brush.surfaces.Length; s++) + { + ref var surface = ref brush.surfaces[s]; + Assert.That(surface.indexCount, Is.EqualTo(surface.indices.Length), what + ": index count"); + Assert.That(surface.vertexCount, Is.EqualTo(surface.colliderVertices.Length), what + ": vertex count"); + Assert.That(surface.renderVertices.Length, Is.EqualTo(surface.vertexCount), what + ": render vertices"); + Assert.That(surface.selectVertices.Length, Is.EqualTo(surface.vertexCount), what + ": select vertices"); + var used = new bool[surface.vertexCount]; + for (int i = 0; i < surface.indices.Length; i++) + { + Assert.That(surface.indices[i], Is.InRange(0, surface.vertexCount - 1), what + ": an index out of range"); + used[surface.indices[i]] = true; + } + Assert.That(used.All(u => u), Is.True, what + ": a vertex no triangle uses"); + foreach (var (a, b, c) in Triangles(buffer, s)) + Assert.That(OutputWeld.HasTwoCornersAtOnePosition(a, b, c), Is.False, what + ": a triangle with two corners at one position"); + var needles = 0; + foreach (var (a, b, c) in Triangles(buffer, s)) + if (ExactFloatLine.AsNeedle(a, b, c, out _, out _, out _)) needles++; + Assert.That(surface.needles.Length, Is.EqualTo(needles), what + ": the needles it records"); + for (int v = 0; v < surface.vertexCount; v++) + { + Assert.That(math.asuint(surface.renderVertices[v].position), Is.EqualTo(math.asuint(surface.colliderVertices[v])), what + ": render position"); + Assert.That(math.asuint(surface.selectVertices[v].position), Is.EqualTo(math.asuint(surface.colliderVertices[v])), what + ": select position"); + } + } + ref var query = ref brush.querySurfaces[0].surfaces; + for (int e = 0; e < query.Length; e++) + { + ref var surface = ref brush.surfaces[query[e].surfaceIndex]; + Assert.That(query[e].indexCount, Is.EqualTo(surface.indexCount), what + ": the mesh query's index count"); + Assert.That(query[e].vertexCount, Is.EqualTo(surface.vertexCount), what + ": the mesh query's vertex count"); + Assert.That(query[e].geometryHashValue, Is.EqualTo(surface.geometryHashValue), what + ": the mesh query's geometry hash"); + Assert.That(surface.indexCount, Is.GreaterThan(0), what + ": the mesh query lists a surface without triangles"); + } + } + + [Test] + public void ANeedleOnASeamBetweenTwoBrushes_Goes_AndTheTriangleAcrossIsSplitAtItsMiddleCorner() + { + using var model = new Model(Brush(kNeedleSurface), Brush(kSurfaceAcross)); + Assert.That((model.split, model.paired, model.patched.Length), Is.EqualTo((1, 0, 2)), "split, paired, brushes copied"); + + var needleSide = Triangles(model[0], 0); + Assert.That(needleSide.Count, Is.EqualTo(2), "the needle's surface"); + Assert.That(HasTriangle(needleSide, U, W, M), Is.False, "the needle is still there"); + var across = Triangles(model[1], 0); + Assert.That(across.Count, Is.EqualTo(2), "the surface across"); + Assert.That(HasTriangle(across, W, M, D) && HasTriangle(across, M, U, D), Is.True, "the triangle across is not split at m: " + string.Join("; ", across)); + Assert.That(Boundary(model.Buffers()), Is.EquivalentTo(model.boundaryBefore), "what the surfaces close against changed"); + AssertStoredAsTheCSGStoresIt(model[0], "the needle's brush"); + AssertStoredAsTheCSGStoresIt(model[1], "the brush across"); + + // the new corner is m, bit for bit, with what the surface across carries there + ref var surface = ref model[1].Value.surfaces[0]; + var found = false; + for (int v = 0; v < surface.vertexCount; v++) + { + if (!surface.colliderVertices[v].Equals(M)) + continue; + found = true; + var expected = Render(M); + var actual = surface.renderVertices[v]; + Assert.That(math.asuint(actual.position), Is.EqualTo(math.asuint(M)), "its position"); + Assert.That(actual.uv0, Is.EqualTo(expected.uv0), "its texture coordinate"); + Assert.That(actual.uv1, Is.EqualTo(expected.uv1), "its lightmap coordinate"); + Assert.That(actual.normal, Is.EqualTo(expected.normal), "its normal"); + Assert.That(actual.tangent, Is.EqualTo(expected.tangent), "its tangent"); + } + Assert.That(found, Is.True, "the surface across has no corner at m"); + + // and the buffers the CSG made, which the cache keeps, are as they were + Assert.That(model.originals[0].Value.surfaces[0].indexCount, Is.EqualTo(9), "the cached needle's surface changed"); + Assert.That(model.originals[0].Value.surfaces[0].needles.Length, Is.EqualTo(1), "the cached needle's surface lost its needle"); + Assert.That(model.originals[1].Value.surfaces[0].indexCount, Is.EqualTo(3), "the cached surface across changed"); + } + + [Test] + public void ANeedleOnTheEdgeBetweenTwoFacesOfOneBrush_GoesTheSameWay() + { + using var model = new Model(Brush(kNeedleSurface, kSurfaceAcross)); + Assert.That((model.split, model.paired, model.patched.Length), Is.EqualTo((1, 0, 1)), "split, paired, brushes copied"); + Assert.That(Triangles(model[0], 0).Count, Is.EqualTo(2), "the needle's face"); + Assert.That(Triangles(model[0], 1).Count, Is.EqualTo(2), "the face across"); + Assert.That(Boundary(model.Buffers()), Is.EquivalentTo(model.boundaryBefore), "what the faces close against changed"); + AssertStoredAsTheCSGStoresIt(model[0], "the brush"); + } + + [Test] + public void TwoFacingNeedles_GoTogether() + { + var Q = new float3(1, 0, -1); + using var model = new Model(Brush(kNeedleSurface), Brush(new[] { W, U, M, M, U, Q, W, M, Q })); + Assert.That((model.split, model.paired, model.patched.Length), Is.EqualTo((0, 1, 2)), "split, paired, brushes copied"); + Assert.That(Triangles(model[0], 0).Count, Is.EqualTo(2), "the first needle's surface"); + Assert.That(Triangles(model[1], 0).Count, Is.EqualTo(2), "the second needle's surface"); + Assert.That(Boundary(model.Buffers()), Is.EquivalentTo(model.boundaryBefore), "what the surfaces close against changed"); + AssertStoredAsTheCSGStoresIt(model[0], "the first brush"); + AssertStoredAsTheCSGStoresIt(model[1], "the second brush"); + } + + [Test] + public void ANeedleAcrossWhichLiesANeedleWithAnotherMiddleCorner_Stays() + { + using var model = new Model(Brush(kNeedleSurface), Brush(new[] { W, U, new float3(0.5f, 0, 0) })); + Assert.That((model.split, model.paired, model.patched.Length), Is.EqualTo((0, 0, 0)), "split, paired, brushes copied"); + Assert.That(model[0], Is.EqualTo(model.originals[0]), "the first brush's buffer was replaced"); + } + + // A triangle with two the same facing it cancels against the one keyed last (the last brush's): which one depends on + // the triangles, not on the order a hash map gives them back in + [Test] + public void ATriangleWithTwoFacingIt_CancelsAgainstTheOneKeyedLast() + { + using var model = new Model(Brush(new[] { U, W, P }), Brush(new[] { U, P, W }), Brush(new[] { U, P, W })); + Assert.That(model.cancelled, Is.EqualTo(1), "one pair cancelled"); + Assert.That(Triangles(model[0], 0).Count, Is.EqualTo(0), "the first brush's triangle"); + Assert.That(Triangles(model[1], 0).Count, Is.EqualTo(1), "the second brush's is left"); + Assert.That(Triangles(model[2], 0).Count, Is.EqualTo(0), "the last brush's cancelled"); + } + + [Test] + public void ANeedleWithNothingAcross_Stays() + { + using var model = new Model(Brush(kNeedleSurface)); + Assert.That((model.split, model.paired, model.patched.Length), Is.EqualTo((0, 0, 0)), "split, paired, brushes copied"); + } + + [Test] + public void AModelWithoutNeedles_IsLeftAlone() + { + using var model = new Model(Brush(new[] { U, W, P }), Brush(kSurfaceAcross)); + Assert.That((model.split, model.paired, model.patched.Length), Is.EqualTo((0, 0, 0)), "split, paired, brushes copied"); + } + + [Test] + public void TwoNeedlesAcrossTwoEdgesOfOneTriangle_BothSplitIt() + { + var N = new float3(1.5f, 0, -0.5f); + using var model = new Model(Brush(kNeedleSurface), Brush(kSurfaceAcross), Brush(new[] { W, D, N })); + Assert.That((model.split, model.paired, model.patched.Length), Is.EqualTo((2, 0, 3)), "split, paired, brushes copied"); + var across = Triangles(model[1], 0); + Assert.That(across.Count, Is.EqualTo(3), "the triangle across both: " + string.Join("; ", across)); + Assert.That(HasTriangle(across, M, U, D) && HasTriangle(across, D, N, M) && HasTriangle(across, N, W, M), Is.True, + "the triangle across both is not split at both middle corners: " + string.Join("; ", across)); + Assert.That(Triangles(model[2], 0).Count, Is.EqualTo(0), "the third brush's needle"); + Assert.That(model[2].Value.querySurfaces[0].surfaces.Length, Is.EqualTo(0), "the mesh query lists a surface left without triangles"); + Assert.That(Boundary(model.Buffers()), Is.EquivalentTo(model.boundaryBefore), "what the surfaces close against changed"); + for (int b = 0; b < 3; b++) + AssertStoredAsTheCSGStoresIt(model[b], "brush " + b); + } + + [Test] + public void ANeedleAcrossTheShortEdgeOfAnother_GoesWhenTheOtherHasGone() + { + var K = new float3(1.5f, 0, 0); + using var model = new Model(Brush(new[] { M, W, K }), Brush(new[] { U, W, M, W, P, K, K, P, M, P, U, M }), Brush(kSurfaceAcross)); + Assert.That((model.split, model.paired, model.patched.Length), Is.EqualTo((2, 0, 3)), "split, paired, brushes copied"); + Assert.That(Triangles(model[0], 0).Count, Is.EqualTo(0), "the needle visited first is still there"); + Assert.That(Triangles(model[1], 0).Count, Is.EqualTo(3), "the second needle's surface"); + var across = Triangles(model[2], 0); + Assert.That(across.Count, Is.EqualTo(3), "the triangle across both: " + string.Join("; ", across)); + Assert.That(HasTriangle(across, W, K, D) && HasTriangle(across, K, M, D) && HasTriangle(across, M, U, D), Is.True, + "the triangle across is not split at both middle corners: " + string.Join("; ", across)); + Assert.That(Boundary(model.Buffers()), Is.EquivalentTo(model.boundaryBefore), "what the surfaces close against changed"); + for (int b = 0; b < 3; b++) + AssertStoredAsTheCSGStoresIt(model[b], "brush " + b); + } + + // Two triangles at the same three positions, bit for bit, wound the other way round, of two brushes: both go, the + // triangle beside one of them stays, and what the surfaces close against does not change + [Test] + public void TwoIdenticalTrianglesFacingOppositeWays_GoTogether() + { + var Q = new float3(2, 1, 0); + using var model = new Model(Brush(new[] { U, W, P, W, Q, P }), Brush(new[] { U, P, W })); + Assert.That((model.split, model.paired, model.cancelled, model.patched.Length), Is.EqualTo((0, 0, 1, 2)), "split, paired, cancelled, brushes copied"); + var first = Triangles(model[0], 0); + Assert.That(first.Count == 1 && HasTriangle(first, W, Q, P), Is.True, "the first brush's surface: " + string.Join("; ", first)); + Assert.That(Triangles(model[1], 0).Count, Is.EqualTo(0), "the second brush's triangle"); + Assert.That(model[1].Value.querySurfaces[0].surfaces.Length, Is.EqualTo(0), "the mesh query lists a surface left without triangles"); + Assert.That(Boundary(model.Buffers()), Is.EquivalentTo(model.boundaryBefore), "what the surfaces close against changed"); + AssertStoredAsTheCSGStoresIt(model[0], "the first brush"); + AssertStoredAsTheCSGStoresIt(model[1], "the second brush"); + Assert.That(model.originals[1].Value.surfaces[0].indexCount, Is.EqualTo(3), "the cached buffer changed"); + } + + // The same two, where one surface goes into other meshes than the other: each mesh would lose one of them, so both stay + [Test] + public void TwoIdenticalTrianglesFacingOppositeWays_OfSurfacesForOtherMeshes_Stay() + { + using var model = new Model(Brush(new[] { U, W, P }), Brush(SurfaceDestinationFlags.Renderable, new[] { U, P, W })); + Assert.That((model.split, model.paired, model.cancelled, model.patched.Length), Is.EqualTo((0, 0, 0, 0)), "split, paired, cancelled, brushes copied"); + } + + // A split makes a triangle the identical twin, facing the other way, of a third brush's: the two go in the same weld + [Test] + public void AHalfOfASplitAndItsTwinFacingTheOtherWay_GoTogether() + { + using var model = new Model(Brush(kNeedleSurface), Brush(kSurfaceAcross), Brush(new[] { D, U, M })); + Assert.That((model.split, model.paired, model.cancelled, model.patched.Length), Is.EqualTo((1, 0, 1, 3)), "split, paired, cancelled, brushes copied"); + var across = Triangles(model[1], 0); + Assert.That(across.Count == 1 && HasTriangle(across, W, M, D), Is.True, "the surface across: " + string.Join("; ", across)); + Assert.That(Triangles(model[2], 0).Count, Is.EqualTo(0), "the third brush's triangle"); + Assert.That(Boundary(model.Buffers()), Is.EquivalentTo(model.boundaryBefore), "what the surfaces close against changed"); + for (int b = 0; b < 3; b++) + AssertStoredAsTheCSGStoresIt(model[b], "brush " + b); + } + + // The exact judge recognises what the weld across the model took away (ExactJudge.CheckMesh), on the needle scenes of EndWeldTests + // The second split of a chain, in the second turn, makes k-m-d, the twin of a fourth brush's d-m-k + [Test] + public void ATwinTheSecondSplitOfAChainMakes_GoesInTheSameWeld() + { + var K = new float3(1.5f, 0, 0); + using var model = new Model(Brush(new[] { M, W, K }), Brush(new[] { U, W, M, W, P, K, K, P, M, P, U, M }), Brush(kSurfaceAcross), + Brush(new[] { D, M, K })); + Assert.That((model.split, model.paired, model.cancelled, model.patched.Length), Is.EqualTo((2, 0, 1, 4)), "split, paired, cancelled, brushes copied"); + var across = Triangles(model[2], 0); + Assert.That(across.Count == 2 && HasTriangle(across, W, K, D) && HasTriangle(across, M, U, D), Is.True, + "the triangle across, split twice, one half cancelled: " + string.Join("; ", across)); + Assert.That(Triangles(model[3], 0).Count, Is.EqualTo(0), "the fourth brush's triangle"); + Assert.That(Boundary(model.Buffers()), Is.EquivalentTo(model.boundaryBefore), "what the surfaces close against changed"); + for (int b = 0; b < 4; b++) + AssertStoredAsTheCSGStoresIt(model[b], "brush " + b); + } + + // Two corners half a millimetre apart: the edge between them is shorter than kCollapseLength + static readonly float3 ShortB = new float3(1, 0, 0), ShortC = new float3(1, 0.0005f, 0); + static readonly float3 ShortD = new float3(0, 0, 1), ShortE = new float3(3, 2, 0), ShortF = new float3(2, 3, 0); + + static void MakeDecal(BlobAssetReference buffer, int surface) + { + buffer.Value.surfaces[surface].decalEntityID = 1; + } + + [Test] + public void ASurfaceWithAShortEdge_IsCollapsed() + { + using var model = new Model(Brush(new[] { ShortB, ShortC, ShortD })); + Assert.That(Triangles(model[0], 0).Count, Is.EqualTo(0), "the triangle across the short edge is left without area"); + } + + [Test] + public void ADecalSurfaceWithAShortEdge_IsLeftAlone() + { + var buffer = Brush(new[] { ShortB, ShortC, ShortD }); + MakeDecal(buffer, 0); + using var model = new Model(buffer); + var triangles = Triangles(model[0], 0); + Assert.That(triangles.Count, Is.EqualTo(1), "a decal's short edges collapse nothing"); + var corners = new[] { triangles[0].a, triangles[0].b, triangles[0].c }; + Assert.That(corners, Is.EquivalentTo(new[] { ShortB, ShortC, ShortD }), "and move nothing"); + } + + [Test] + public void ADecalSurface_FollowsTheCornersTheCollapseMoves() + { + // the decal has one triangle at each end of the short edge; where they meet, the collapse puts both corners on one point + var decal = Brush(new[] { ShortB, ShortE, ShortF }, new[] { ShortC, ShortF, ShortE }); + MakeDecal(decal, 0); + MakeDecal(decal, 1); + var solid = Brush(new[] { ShortB, ShortC, ShortD }); + using var brushes = new NativeList(Allocator.Temp); + using var patched = new NativeList>(Allocator.Temp); + using var moves = new NativeList(Allocator.Temp); + brushes.Add(new BrushData { brushRenderBuffer = solid, brushSurfaceCount = 1 }); + brushes.Add(new BrushData { brushRenderBuffer = decal, brushSurfaceCount = 2 }); + try + { + OutputModelWeld.WeldModel(brushes, patched, moves); + Assert.That(moves.Length, Is.GreaterThan(0), "the solid triangle's short edge was collapsed"); + var first = Triangles(brushes[1].brushRenderBuffer, 0); + var second = Triangles(brushes[1].brushRenderBuffer, 1); + Assert.That(first.Count, Is.EqualTo(1)); + Assert.That(second.Count, Is.EqualTo(1)); + float3 Corner(float3 a, float3 b, float3 c) => a.Equals(ShortE) || a.Equals(ShortF) ? (b.Equals(ShortE) || b.Equals(ShortF) ? c : b) : a; + var atB = Corner(first[0].a, first[0].b, first[0].c); + var atC = Corner(second[0].a, second[0].b, second[0].c); + Assert.That(atB.Equals(atC), Is.True, "the corners of the decal's pieces share a point: " + atB + " / " + atC); + } + finally + { + foreach (var copy in patched) copy.Dispose(); + solid.Dispose(); + decal.Dispose(); + } + } + + [Test] + public void TheExactJudge_CountsWhatTheWeldAcrossTheModelTakesAway() + { + foreach (var (brushes, pivots, what, flipped, split, paired, cancelled) in new[] + { + (EndWeldTests.kSeam, EndWeldTests.kSeamPivots, "the seam", 0, 1, 0, 0), + (EndWeldTests.kFacingNeedles, EndWeldTests.kFacingNeedlesPivots, "the facing needles", 0, 0, 1, 0), + (EndWeldTests.kNeedleOnABrushEdge, EndWeldTests.kNeedleOnABrushEdgePivots, "the needle on a brush edge", 1, 0, 0, 0), + (EndWeldTests.kFarApartNeedle, EndWeldTests.kFarApartNeedlePivots, "the far apart needle", 0, 1, 0, 1), + }) + { + using var harness = ContentsTreeHarness.Build(EndWeldTests.SceneOf(brushes, pivots, what)); + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + var mismatches = ExactJudge.FindMismatches(harness, 8, out var judged, checkMesh: true); + Assert.That(judged, Is.GreaterThan(0), what + ": nothing was judged"); + Assert.That(mismatches.Count, Is.EqualTo(0), what + ": " + string.Join("; ", mismatches.Select(x => x.Format("exact")))); + Assert.That((ExactJudge.LastFlippedByTheWeld, ExactJudge.LastSplitByTheWeld, ExactJudge.LastPairedByTheWeld, ExactJudge.LastCancelledByTheWeld), + Is.EqualTo((flipped, split, paired, cancelled)), what + ": flipped (or split within a brush), split into another brush, paired, cancelled"); + } + } + + // The weld made incrementally over a run of models, each the one before with some brushes' buffers made again; + // after each, every brush's buffer and every move is what the full weld makes of that model + sealed class Incremental : System.IDisposable + { + WeldMemory memory = WeldMemory.Create(Allocator.Persistent); + readonly List> owned = new(); + public int LastScope => memory.lastScope.Value; + public ref WeldMemory Memory => ref memory; + + public BlobAssetReference Own(BlobAssetReference buffer) { owned.Add(buffer); return buffer; } + + public static CompactNodeID Id(int id) => new CompactNodeID(default, new SlotIndex { index = id + 1, generation = 1 }); + + // model: (node id, buffer) in tree order; updated: the ids whose buffers this update made again + public void Weld(string what, (int id, BlobAssetReference buffer)[] model, params int[] updated) => Weld(what, true, model, updated); + + public void Weld(string what, bool check, (int id, BlobAssetReference buffer)[] model, params int[] updated) + { + using var brushes = new NativeList(Allocator.Temp); + using var full = new NativeList(Allocator.Temp); + foreach (var (id, buffer) in model) + { + var data = new BrushData { brushIndexOrder = new IndexOrder { compactNodeID = Id(id), nodeOrder = id }, brushRenderBuffer = buffer, brushSurfaceCount = buffer.Value.surfaceCount }; + brushes.Add(data); + full.Add(data); + } + var orderList = new IndexOrder[updated.Length]; + for (int i = 0; i < updated.Length; i++) + orderList[i] = new IndexOrder { compactNodeID = Id(updated[i]), nodeOrder = updated[i] }; + using var orders = new NativeArray(orderList, Allocator.Temp); + using var moves = new NativeList(Allocator.Temp); + using var fullMoves = new NativeList(Allocator.Temp); + using var patched = new NativeList>(Allocator.Temp); + OutputModelWeld.WeldIncremental(brushes, orders, ref memory, moves); + OutputModelWeld.WeldModel(full, patched, out _, out _, out _, out _, fullMoves); + try + { + if (!check) + return; + for (int b = 0; b < brushes.Length; b++) + Assert.That(OutputModelWeld.SameBuffer(brushes[b].brushRenderBuffer, full[b].brushRenderBuffer), Is.True, $"{what}: brush {model[b].id}"); + var a = new HashSet<(float3, float3)>(); foreach (var m in moves) a.Add((m.c0, m.c1)); + var c = new HashSet<(float3, float3)>(); foreach (var m in fullMoves) c.Add((m.c0, m.c1)); + Assert.That(a.SetEquals(c), Is.True, $"{what}: the moves ({moves.Length} and {fullMoves.Length})"); + } + finally + { + foreach (var copy in patched) copy.Dispose(); + } + } + + public void Dispose() + { + memory.Dispose(); + foreach (var buffer in owned) buffer.Dispose(); + } + } + + static float3[] Shifted(float3[] corners, float3 by) => corners.Select(p => p + by).ToArray(); + + static readonly float3 kFar = new float3(50, 0, 0); + static float3[] FarTriangle(float y) => new[] { kFar, kFar + new float3(1, y, 0), kFar + new float3(0, 1, 0) }; + + [Test] + public void AfterACancelledPairMovesApart_TheIncrementalWeld_IsTheFullWeld() + { + using var weld = new Incremental(); + var a = weld.Own(Brush(new[] { U, W, P })); + var b = weld.Own(Brush(new[] { U, P, W })); + var far = weld.Own(Brush(FarTriangle(1))); + weld.Weld("the first update", new[] { (0, a), (1, b), (2, far) }); + Assert.That(weld.LastScope, Is.EqualTo(-1), "the first update welds everything"); + + var moved = weld.Own(Brush(new[] { U, P, W + new float3(0, 0, 1) })); + weld.Weld("the facing triangle moved", new[] { (0, a), (1, moved), (2, far) }, 1); + Assert.That(weld.LastScope, Is.EqualTo(2), "the moved triangle and the one it faced"); + + var far2 = weld.Own(Brush(FarTriangle(2))); + weld.Weld("a triangle apart changed", new[] { (0, a), (1, moved), (2, far2) }, 2); + Assert.That(weld.LastScope, Is.EqualTo(1), "only the triangle apart"); + + weld.Weld("the facing triangle back", new[] { (0, a), (1, b), (2, far2) }, 1); + } + + [Test] + public void AroundANeedle_TheIncrementalWeld_IsTheFullWeld() + { + using var weld = new Incremental(); + var needle = weld.Own(Brush(kNeedleSurface)); + var across = weld.Own(Brush(kSurfaceAcross)); + var far = weld.Own(Brush(FarTriangle(1))); + weld.Weld("the first update", new[] { (0, needle), (1, across), (2, far) }); + + var far2 = weld.Own(Brush(FarTriangle(2))); + weld.Weld("a triangle apart changed", new[] { (0, needle), (1, across), (2, far2) }, 2); + Assert.That(weld.LastScope, Is.EqualTo(1), "only the triangle apart"); + + var acrossMoved = weld.Own(Brush(new[] { W, U, D + new float3(0, 0, -1) })); + weld.Weld("the surface across changed", new[] { (0, needle), (1, acrossMoved), (2, far2) }, 1); + Assert.That(weld.LastScope, Is.EqualTo(4), "the needle's surface and the triangle across"); + + var away = weld.Own(Brush(new[] { W + new float3(0, 0, 3), U + new float3(0, 0, 3), D })); + weld.Weld("the surface across moved off the seam", new[] { (0, needle), (1, away), (2, far2) }, 1); + weld.Weld("and back", new[] { (0, needle), (1, across), (2, far2) }, 1); + } + + [Test] + public void AroundAShortEdge_TheIncrementalWeld_IsTheFullWeld() + { + using var weld = new Incremental(); + var solid = weld.Own(Brush(new[] { ShortB, ShortC, ShortD })); + var next = weld.Own(Brush(new[] { ShortC, ShortB, new float3(1, 0, -1) })); + var far = weld.Own(Brush(FarTriangle(1))); + weld.Weld("the first update", new[] { (0, solid), (1, next), (2, far) }); + + var nextMoved = weld.Own(Brush(new[] { ShortC, ShortB, new float3(1, 1, -1) })); + weld.Weld("the brush with the other side of the short edge changed", new[] { (0, solid), (1, nextMoved), (2, far) }, 1); + Assert.That(weld.LastScope, Is.EqualTo(1), "the changed triangle: the short edge's point group is clustered again apart"); + + var nextAway = weld.Own(Brush(new[] { ShortC + new float3(0, 0, -2), ShortB + new float3(0, 0, -2), new float3(1, 1, -3) })); + weld.Weld("moved away from the short edge", new[] { (0, solid), (1, nextAway), (2, far) }, 1); + weld.Weld("and back", new[] { (0, solid), (1, next), (2, far) }, 1); + } + + // A short edge of another brush joins a point group (a chain of short edges over three brushes): the cluster's corner + // changes, so the brushes with corners in it are stored again though no triangle of theirs is welded again + [Test] + public void AShortEdgeJoiningAPointGroup_MovesTheCornersOfBrushesOutOfTheScope() + { + float3 a = new float3(1, -0.0003f, 0), b = new float3(1, 0, 0), c = new float3(1, 0.0003f, 0), e = new float3(1, 0.0006f, 0); + using var weld = new Incremental(); + var solid = weld.Own(Brush(new[] { b, c, ShortD }, new[] { b, ShortD, new float3(0, -1, 0) })); + var chain = weld.Own(Brush(new[] { c, e, new float3(2, 0, 1) }, new[] { e, new float3(0, 2, 0), new float3(2, 0, 1) })); + var far = weld.Own(Brush(FarTriangle(1))); + var away = weld.Own(Brush(new[] { a + new float3(0, 0, -2), b + new float3(0, 0, -2), new float3(2, -1, -1) })); + var near = weld.Own(Brush(new[] { a, b, new float3(2, -1, 1) })); + weld.Weld("the first update", new[] { (0, solid), (1, chain), (2, away), (3, far) }); + weld.Weld("the short edge moved onto the group", new[] { (0, solid), (1, chain), (2, near), (3, far) }, 2); + Assert.That(weld.LastScope, Is.EqualTo(1), "only the moved triangle"); + weld.Weld("a brush apart changed", new[] { (0, solid), (1, chain), (2, near), (3, weld.Own(Brush(FarTriangle(2)))) }, 3); + weld.Weld("and away again", new[] { (0, solid), (1, chain), (2, away), (3, far) }, 2, 3); + } + + [Test] + public void AddingRemovingAndReorderingBrushes_TheIncrementalWeld_IsTheFullWeld() + { + using var weld = new Incremental(); + var a = weld.Own(Brush(new[] { U, W, P })); + var b = weld.Own(Brush(new[] { U, P, W })); + var needle = weld.Own(Brush(Shifted(kNeedleSurface, new float3(0, 0, 10)))); + var across = weld.Own(Brush(Shifted(kSurfaceAcross, new float3(0, 0, 10)))); + weld.Weld("the first update", new[] { (0, a), (2, needle), (3, across) }); + weld.Weld("a brush added", new[] { (0, a), (1, b), (2, needle), (3, across) }, 1); + Assert.That(weld.LastScope, Is.EqualTo(2), "the added triangle and the one it faces"); + weld.Weld("a brush removed", new[] { (1, b), (2, needle), (3, across) }); + Assert.That(weld.LastScope, Is.EqualTo(1), "the triangle the removed one faced"); + weld.Weld("the needle's brush removed", new[] { (1, b), (3, across) }); + weld.Weld("and added again", new[] { (1, b), (2, needle), (3, across) }, 2); + weld.Weld("two brushes swapped", new[] { (1, b), (3, across), (2, needle) }); + Assert.That(weld.LastScope, Is.EqualTo(-1), "another order welds everything"); + } + + // One surface with two triangles apart, each split by a needle of another brush: two pieces that both add to the + // surface, so welded together + [Test] + public void TwoPiecesSplittingOneSurface_AreWeldedTogether() + { + var by = new float3(10, 0, 0); + using var weld = new Incremental(); + var first = weld.Own(Brush(kNeedleSurface)); + var second = weld.Own(Brush(Shifted(kNeedleSurface, by))); + var across = weld.Own(Brush(kSurfaceAcross.Concat(Shifted(kSurfaceAcross, by)).ToArray())); + weld.Weld("the first update", new[] { (0, first), (1, second), (2, across) }); + + var secondAway = weld.Own(Brush(Shifted(kNeedleSurface, by + new float3(0, 0, 0.5f)))); + weld.Weld("the second needle moved off its seam", new[] { (0, first), (1, secondAway), (2, across) }, 1); + Assert.That(weld.LastScope, Is.EqualTo(3 + 3 + 2), "both needles' surfaces and the triangles across"); + + weld.Weld("and back", new[] { (0, first), (1, second), (2, across) }, 1); + Assert.That(weld.LastScope, Is.EqualTo(3 + 3 + 2), "the first needle's piece taken in, the surface split by both"); + } + + // The hotspot cut writes each surface's group into the brush's buffer in place (OutputHotspotSections.StartCutKeys): a buffer the weld keeps carries it too + [Test] + public void AGroupWrittenIntoABrushsOwnBuffer_ReachesTheBufferTheWeldKept() + { + using var weld = new Incremental(); + var a = weld.Own(Brush(new[] { U, W, P })); + var b = weld.Own(Brush(new[] { U, P, W })); + var far = weld.Own(Brush(FarTriangle(1))); + weld.Weld("the first update", new[] { (0, a), (1, b), (2, far) }); + a.Value.surfaces[0].hotspot.group = 7; + a.Value.surfaces[0].hotspot.groupVariations = 3; + weld.Weld("the group written in place", new[] { (0, a), (1, b), (2, far) }); + } + + // Planted: a brush's kept buffer forgotten; the verify has to see it + [Test] + public void AWrongKeptBuffer_IsReportedByTheVerify() + { + var verify = OutputModelWeld.VerifyIncremental; + try + { + using var weld = new Incremental(); + var a = weld.Own(Brush(new[] { U, W, P })); + var b = weld.Own(Brush(new[] { U, P, W })); + var far = weld.Own(Brush(FarTriangle(1))); + weld.Weld("the first update", new[] { (0, a), (1, b), (2, far) }); + ref var memory = ref weld.Memory; + ref var kept = ref memory.brushes.ElementAt(memory.slotOf[Incremental.Id(1)]); + Assert.That(kept.output.IsCreated, Is.True, "the cancelled triangle's brush was stored again"); + kept.output.Dispose(); + kept.output = default; + OutputModelWeld.VerifyIncremental = true; + LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("The incremental weld differs from the full weld: 1 of 3")); + var far2 = weld.Own(Brush(FarTriangle(2))); + weld.Weld("a triangle apart changed", false, new[] { (0, a), (1, b), (2, far2) }, 2); + } + finally + { + OutputModelWeld.VerifyIncremental = verify; + } + } + } +} diff --git a/Core/Tests/OutputModelWeldTests.cs.meta b/Core/Tests/OutputModelWeldTests.cs.meta new file mode 100644 index 00000000..640e0ff5 --- /dev/null +++ b/Core/Tests/OutputModelWeldTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f57daa4e7bc4143488a0f24f6d90419e \ No newline at end of file diff --git a/Core/Tests/OutputWeldTests.cs b/Core/Tests/OutputWeldTests.cs new file mode 100644 index 00000000..67da79bb --- /dev/null +++ b/Core/Tests/OutputWeldTests.cs @@ -0,0 +1,580 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class OutputWeldTests + { + struct Corner + { + public float3 collider; + public SelectVertex select; + public RenderVertex render; + } + + static Corner V(float3 position, float2 uv = default, float entity = 1) + { + return new Corner + { + collider = position, + select = new SelectVertex { position = position, entityID = new Vector4(entity, 0, 0, 0) }, + render = new RenderVertex { position = position, normal = new float3(0, 0, 1), tangent = new float4(1, 0, 0, 1), uv0 = uv, uv1 = uv * 0.5f } + }; + } + + static bool SameBits(Corner a, Corner b) + { + return math.all(math.asuint(a.collider) == math.asuint(b.collider)) && + math.all(math.asuint(a.select.position) == math.asuint(b.select.position)) && + math.all(math.asuint((float4)a.select.entityID) == math.asuint((float4)b.select.entityID)) && + math.all(math.asuint(a.render.position) == math.asuint(b.render.position)) && + math.all(math.asuint(a.render.normal) == math.asuint(b.render.normal)) && + math.all(math.asuint(a.render.tangent) == math.asuint(b.render.tangent)) && + math.all(math.asuint(a.render.uv0) == math.asuint(b.render.uv0)) && + math.all(math.asuint(a.render.uv1) == math.asuint(b.render.uv1)); + } + + static bool SamePosition(float3 a, float3 b) => a.x == b.x && a.y == b.y && a.z == b.z; + + // The surface after the weld: its indices, its vertices as corners, and how many triangles the weld says it dropped + static (int[] indices, Corner[] vertices, int dropped, int flipped) Weld(int[] indices, Corner[] vertices) + { + var indexList = new NativeList(indices.Length, Allocator.Temp); + var colliderList = new NativeList(vertices.Length, Allocator.Temp); + var selectList = new NativeList(vertices.Length, Allocator.Temp); + var renderList = new NativeList(vertices.Length, Allocator.Temp); + try + { + foreach (var index in indices) + indexList.Add(index); + foreach (var vertex in vertices) + { + colliderList.Add(vertex.collider); + selectList.Add(vertex.select); + renderList.Add(vertex.render); + } + var dropped = OutputWeld.WeldSurface(indexList, colliderList, selectList, renderList, out var flipped); + Assert.That(selectList.Length, Is.EqualTo(colliderList.Length), "the select stream has another length than the collider stream"); + Assert.That(renderList.Length, Is.EqualTo(colliderList.Length), "the render stream has another length than the collider stream"); + var outVertices = new Corner[colliderList.Length]; + for (int v = 0; v < outVertices.Length; v++) + outVertices[v] = new Corner { collider = colliderList[v], select = selectList[v], render = renderList[v] }; + return (indexList.AsArray().ToArray(), outVertices, dropped, flipped); + } + finally + { + indexList.Dispose(); + colliderList.Dispose(); + selectList.Dispose(); + renderList.Dispose(); + } + } + + static void AssertSameSurface((int[] indices, Corner[] vertices, int dropped, int flipped) result, int[] indices, Corner[] vertices, string what) + { + Assert.That(result.flipped, Is.EqualTo(0), what + ": a needle was flipped"); + Assert.That(result.indices, Is.EqualTo(indices), what + ": the indices changed"); + Assert.That(result.vertices.Length, Is.EqualTo(vertices.Length), what + ": the vertex count changed"); + for (int v = 0; v < vertices.Length; v++) + Assert.That(SameBits(result.vertices[v], vertices[v]), Is.True, $"{what}: vertex {v} changed"); + } + + // Whether a triangle has area is decided exactly: these corners give a float cross product of 0, but the triangle has + // area (in float, Burst's fused multiply-add rounds differently) + [Test] + public void ATriangleWhoseFloatCrossProductIsZero_StillHasArea() + { + var a = new float3(34.77891f, -0.914978f, -6.45306158f); + var b = new float3(38.496666f, -0.914978f, -9.305811f); + var c = new float3(38.4633064f, -0.914978f, -9.280213f); + Assume.That(math.lengthsq(math.cross(b - a, c - a)), Is.Zero, "the float cross product is 0"); + Assert.That(OutputWeld.WithoutArea(a, b, c), Is.False); + } + + [Test] + public void ATriangleOnOneLine_OrWithTwoCornersAtOnePosition_HasNoArea() + { + Assert.That(OutputWeld.WithoutArea(new float3(0, 0, 0), new float3(1, 2, 3), new float3(2, 4, 6)), Is.True, "on one line"); + Assert.That(OutputWeld.WithoutArea(new float3(1, 2, 3), new float3(1, 2, 3), new float3(5, 0, 0)), Is.True, "two corners at one position"); + Assert.That(OutputWeld.WithoutArea(new float3(0, 0, 0), new float3(1, 0, 0), new float3(0, 1e-30f, 0)), Is.False, "however thin"); + } + + [Test] + public void ACleanSurface_IsLeftExactlyAsItWas() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(1, 0, 0), new float2(1, 0)), V(new float3(1, 1, 0), new float2(1, 1)), V(new float3(0, 1, 0), new float2(0, 1)) }; + var indices = new[] { 0, 1, 2, 0, 2, 3 }; + var result = Weld(indices, vertices); + Assert.That(result.dropped, Is.EqualTo(0)); + AssertSameSurface(result, indices, vertices, "a quad"); + } + + [Test] + public void TwoCornersThatRoundToOneFloat_AreOneVertex_AndTheirTriangleIsDropped() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(2, 0, 0)), V(new float3(2, 1, 0)), V(new float3(2, 1, 0)), V(new float3(0, 1, 0)) }; + var result = Weld(new[] { 0, 1, 2, 0, 2, 3, 0, 3, 4 }, vertices); + Assert.That(result.dropped, Is.EqualTo(1), "triangles dropped"); + Assert.That(result.indices, Is.EqualTo(new[] { 0, 1, 2, 0, 2, 3 }), "the two triangles left, corner 3 shared with 2 and 4 now 3"); + Assert.That(result.vertices.Length, Is.EqualTo(4), "one vertex per record"); + Assert.That(SameBits(result.vertices[3], vertices[4]), Is.True, "the last vertex is the pentagon's corner 4"); + } + + [Test] + public void TwoCornersAtOnePositionWithOtherAttributes_TheirTriangleIsDropped_TheVerticesStayApart() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(2, 0, 0)), V(new float3(2, 1, 0), new float2(1, 1)), V(new float3(2, 1, 0), new float2(1, 2)), V(new float3(0, 1, 0)) }; + var result = Weld(new[] { 0, 1, 2, 0, 2, 3, 0, 3, 4 }, vertices); + Assert.That(result.dropped, Is.EqualTo(1), "triangles dropped"); + Assert.That(result.indices, Is.EqualTo(new[] { 0, 1, 2, 0, 3, 4 }), "the two triangles left, as they were"); + Assert.That(result.vertices.Length, Is.EqualTo(5), "the vertices with other attributes stay"); + for (int v = 0; v < 5; v++) + Assert.That(SameBits(result.vertices[v], vertices[v]), Is.True, $"vertex {v} changed"); + } + + [Test] + public void ANeedleWithANeighbourAcrossItsLongEdge_IsFlippedAway() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(1, 0, 0)), V(new float3(2, 0, 0)), V(new float3(1, 1, 0)) }; + var result = Weld(new[] { 0, 1, 2, 0, 2, 3 }, vertices); + Assert.That(result.dropped, Is.EqualTo(0), "triangles dropped"); + Assert.That(result.flipped, Is.EqualTo(1), "needles flipped"); + Assert.That(result.indices, Is.EqualTo(new[] { 0, 1, 3, 1, 2, 3 }), "u-m-d and m-w-d, in the needle's and the neighbour's places"); + Assert.That(result.vertices.Length, Is.EqualTo(4), "the vertices stay"); + for (int v = 0; v < 4; v++) + Assert.That(SameBits(result.vertices[v], vertices[v]), Is.True, $"vertex {v} changed"); + } + + // The same needle read from another corner: wherever its winding starts, it is the same needle + [Test] + public void ANeedleReadFromAnyCorner_IsFlippedAlike() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(1, 0, 0)), V(new float3(2, 0, 0)), V(new float3(1, 1, 0)) }; + var result = Weld(new[] { 2, 0, 1, 2, 3, 0 }, vertices); + Assert.That(result.flipped, Is.EqualTo(1), "needles flipped"); + Assert.That(result.indices, Is.EqualTo(new[] { 0, 1, 3, 1, 2, 3 }), "u-m-d and m-w-d"); + } + + // On the surface's border nothing runs the long edge the other way: the needle stays, filling its hairline + [Test] + public void ANeedleOnTheBorder_StaysAsItIs() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(1, 0, 0)), V(new float3(2, 0, 0)), V(new float3(1, -1, 0)) }; + var indices = new[] { 0, 1, 2, 1, 0, 3 }; // a neighbour on a short edge only + AssertSameSurface(Weld(indices, vertices), indices, vertices, "a needle on the border"); + } + + // A neighbour whose third corner lies on the same line would turn into two needles: both stay + [Test] + public void ANeedleWhoseNeighbourIsOnTheSameLine_StaysAsItIs() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(1, 0, 0)), V(new float3(2, 0, 0)), V(new float3(3, 0, 0)) }; + var indices = new[] { 0, 1, 2, 0, 2, 3 }; + AssertSameSurface(Weld(indices, vertices), indices, vertices, "a needle beside a needle"); + } + + // Off the line by the smallest float there is: no needle, decided exactly (coordinates 127 powers of two apart, so the + // 512 bit path), and nothing is flipped + [Test] + public void AnAlmostStraightTriangle_IsNoNeedle() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(1, math.asfloat(1u), 0)), V(new float3(2, 0, 0)), V(new float3(1, 1, 0)) }; + var indices = new[] { 0, 1, 2, 0, 2, 3 }; + Assert.That(OutputWeld.OnOneLine(vertices[0].collider, vertices[1].collider, vertices[2].collider), Is.False, "one denormal off the line is on it"); + AssertSameSurface(Weld(indices, vertices), indices, vertices, "an almost straight triangle"); + } + + // A needle from x = -39.04 through 0 to 1.37e-8, 55 powers of two apart: on one line exactly, decided in 512 bits, and flipped + [Test] + public void ANeedleFiftyFivePowersOfTwoWide_IsDecidedExactly_AndFlipped() + { + var u = new float3(-39.04000473022461f, 5.032470703125f, 2.44000244140625f); + var m = new float3(0.0f, 5.032470703125f, 2.44000244140625f); + var w = new float3(1.3747119886886594e-08f, 5.032470703125f, 2.44000244140625f); + var d = new float3(1.3747119886886594e-08f, 5.3375244140625f, 2.44000244140625f); + Assert.That(OutputWeld.OnOneLine(u, m, w), Is.True, "the needle is not on one line"); + var result = Weld(new[] { 0, 1, 2, 0, 2, 3 }, new[] { V(u), V(m), V(w), V(d) }); + Assert.That(result.flipped, Is.EqualTo(1), "needles flipped"); + Assert.That(result.indices, Is.EqualTo(new[] { 0, 1, 3, 1, 2, 3 }), "u-m-d and m-w-d"); + } + + // NaN and infinity are never on a line: nothing is decided about them, nothing is flipped + [Test] + public void NaNAndInfinity_AreOnNoLine() + { + Assert.That(OutputWeld.OnOneLine(new float3(0, 0, 0), new float3(float.NaN, 0, 0), new float3(2, 0, 0)), Is.False); + Assert.That(OutputWeld.OnOneLine(new float3(0, 0, 0), new float3(1, 0, 0), new float3(float.PositiveInfinity, 0, 0)), Is.False); + Assert.That(OutputWeld.OnOneLine(new float3(0, 0, 0), new float3(0, 0, 0), new float3(0, 0, 0)), Is.True, "three times the origin"); + } + + [Test] + public void OnOneLine_AgreesWithAnExactOracle([NUnit.Framework.Range(1, 40)] int seed) + { + var random = new Unity.Mathematics.Random((uint)seed * 747796405u + 7); + for (int i = 0; i < 500; i++) + { + float3 a, b, c; + var kind = random.NextInt(6); + var scale = math.asfloat((uint)random.NextInt(1, 254) << 23); // a power of two, 2^-126 .. 2^126 + a = RandomPoint(ref random, scale); + var direction = new float3(random.NextInt(-4, 5), random.NextInt(-4, 5), random.NextInt(-4, 5)) * scale; + switch (kind) + { + case 0: b = RandomPoint(ref random, scale); c = RandomPoint(ref random, scale); break; // anything + case 1: b = a + direction; c = a + direction * 2; break; // on a line (when exact) + case 2: b = a + direction; c = a + direction * 3; c.y = NextAfter(c.y); break; // a float step off + case 3: b = new float3(a.x * 2, a.y, a.z); c = new float3(-a.x, a.y, a.z); break; // along x + case 4: b = new float3(math.asfloat(1u), a.y, a.z); c = new float3(-0.0f, a.y, a.z); break; // denormal and -0 + default: b = a * 0.5f; c = a * math.asfloat((uint)random.NextInt(1, 254) << 23); break; // through the origin, far apart + } + var expected = OracleOnOneLine(a, b, c, out var span); + if (span > 228) + { + Assert.That(OutputWeld.OnOneLine(a, b, c), Is.False, $"seed {seed}, triple {i}: wider than the 512 bit path decides, yet decided"); + continue; + } + Assert.That(OutputWeld.OnOneLine(a, b, c), Is.EqualTo(expected), $"seed {seed}, triple {i} (kind {kind}): {a} {b} {c}"); + } + } + + static float3 RandomPoint(ref Unity.Mathematics.Random random, float scale) + { + return new float3(random.NextInt(-1000, 1001), random.NextInt(-1000, 1001), random.NextInt(-1000, 1001)) * (scale / 512.0f); + } + + static float NextAfter(float value) + { + var bits = math.asuint(value); + if (value == 0) return math.asfloat(1u); + return math.asfloat(value > 0 ? bits + 1 : bits - 1); + } + + static System.Numerics.BigInteger Exact(float value) + { + var bits = math.asuint(value); + var field = (int)((bits >> 23) & 0xFF); + long whole = field == 0 ? (bits & 0x7FFFFF) : ((bits & 0x7FFFFF) | 0x800000); + var result = new System.Numerics.BigInteger(whole) << (field == 0 ? 0 : field - 1); + return (bits & 0x80000000u) != 0 ? -result : result; + } + + // The exact answer, and how many powers of two the nonzero coordinates span (what OnOneLine's integer grid needs) + static bool OracleOnOneLine(float3 a, float3 b, float3 c, out int span) + { + span = 0; + if (!math.all(math.isfinite(a)) || !math.all(math.isfinite(b)) || !math.all(math.isfinite(c))) + return false; + int lowest = int.MaxValue, highest = int.MinValue; + foreach (var v in new[] { a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z }) + { + if (v == 0) continue; + var field = (int)((math.asuint(v) >> 23) & 0xFF); + var e = field == 0 ? -149 : field - 150; + lowest = math.min(lowest, e); highest = math.max(highest, e); + } + span = lowest == int.MaxValue ? 0 : highest - lowest; + var ux = Exact(b.x) - Exact(a.x); var uy = Exact(b.y) - Exact(a.y); var uz = Exact(b.z) - Exact(a.z); + var wx = Exact(c.x) - Exact(a.x); var wy = Exact(c.y) - Exact(a.y); var wz = Exact(c.z) - Exact(a.z); + return (uy * wz - uz * wy).IsZero && (uz * wx - ux * wz).IsZero && (ux * wy - uy * wx).IsZero; + } + + // 0 and -0 are one position, so a triangle with corners at both draws nothing and goes; the two are other bits, so they + // are not one vertex, and the one no triangle uses any more goes + [Test] + public void ZeroAndMinusZero_AreOnePosition_NotOneVertex() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(-0.0f, 0, 0)), V(new float3(0, 1, 0)), V(new float3(1, 0, 0)) }; + var result = Weld(new[] { 0, 1, 2, 0, 3, 2 }, vertices); + Assert.That(result.dropped, Is.EqualTo(1), "triangles dropped"); + Assert.That(result.indices, Is.EqualTo(new[] { 0, 2, 1 }), "the triangle left, renumbered"); + Assert.That(result.vertices.Length, Is.EqualTo(3), "the -0 vertex nothing uses is gone"); + Assert.That(math.asuint(result.vertices[0].collider.x), Is.EqualTo(0u), "the vertex at 0 kept its bits"); + } + + // A NaN position is never equal to anything, so a broken vertex is never hidden by dropping its triangle + [Test] + public void ANaNCorner_IsNeverAtThePositionOfAnother_SoItsTriangleStays() + { + var nan = new float3(float.NaN, 0, 0); + var vertices = new[] { V(nan), V(nan), V(new float3(0, 1, 0)) }; + var result = Weld(new[] { 0, 1, 2 }, vertices); + Assert.That(result.dropped, Is.EqualTo(0), "a triangle with NaN corners was dropped"); + Assert.That(result.indices.Length, Is.EqualTo(3), "the triangle is gone"); + } + + [Test] + public void ASurfaceOfOnlyCollapsedTriangles_IsLeftEmpty() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(0, 0, 0)), V(new float3(1, 0, 0)), V(new float3(1, 0, 0)) }; + var result = Weld(new[] { 0, 1, 2, 2, 3, 0 }, vertices); + Assert.That(result.dropped, Is.EqualTo(2)); + Assert.That(result.indices, Is.Empty); + Assert.That(result.vertices, Is.Empty); + } + + // Nothing here may make a broken surface worse: an index out of range, or a count that is no whole triangle, leaves it as it is + [Test] + public void ABrokenSurface_IsLeftAsItIs() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(0, 0, 0)), V(new float3(1, 0, 0)) }; + var outOfRange = new[] { 0, 1, 2, 0, 1, 7 }; + AssertSameSurface(Weld(outOfRange, vertices), outOfRange, vertices, "an index out of range"); + var negative = new[] { 0, 1, 2, -1, 1, 2 }; + AssertSameSurface(Weld(negative, vertices), negative, vertices, "a negative index"); + var partial = new[] { 0, 1, 2, 0 }; + AssertSameSurface(Weld(partial, vertices), partial, vertices, "an index count that is no whole triangle"); + } + + [Test] + public void RandomSurfaces_KeepEveryTriangleThatHasArea_CornerForCorner([NUnit.Framework.Range(1, 300)] int seed) + { + var random = new Unity.Mathematics.Random((uint)seed * 2654435761u + 1); + var vertices = new List(); + var count = random.NextInt(3, 24); + for (int v = 0; v < count; v++) + { + if (vertices.Count > 0 && random.NextInt(4) == 0) + { + vertices.Add(vertices[random.NextInt(vertices.Count)]); // a copy, bit for bit + continue; + } + var position = new float3(random.NextInt(3), random.NextInt(3), random.NextInt(2)); + if (position.x == 0 && random.NextInt(4) == 0) + position.x = -0.0f; // sometimes -0 where it is 0 + vertices.Add(V(position, new float2(random.NextInt(2), 0), random.NextInt(1, 3))); + } + var indices = new List(); + var triangles = random.NextInt(1, 20); + for (int t = 0; t < triangles * 3; t++) + indices.Add(random.NextInt(vertices.Count)); + + var input = vertices.ToArray(); + var result = Weld(indices.ToArray(), input); + AssertWeldInvariants(indices.ToArray(), input, result, $"seed {seed}"); + if (result.flipped > 0) + return; // the flip replaced needles and neighbours: the invariants above are what holds, corner for corner no longer + + var expected = new List(); + var expectedDropped = 0; + for (int t = 0; t < indices.Count; t += 3) + { + var a = input[indices[t]]; var b = input[indices[t + 1]]; var c = input[indices[t + 2]]; + if (SamePosition(a.collider, b.collider) || SamePosition(b.collider, c.collider) || SamePosition(c.collider, a.collider)) + { + expectedDropped++; + continue; + } + expected.Add(a); expected.Add(b); expected.Add(c); + } + Assert.That(result.dropped, Is.EqualTo(expectedDropped), $"seed {seed}: triangles dropped"); + Assert.That(result.indices.Length, Is.EqualTo(expected.Count), $"seed {seed}: corners kept"); + for (int i = 0; i < expected.Count; i++) + Assert.That(SameBits(result.vertices[result.indices[i]], expected[i]), Is.True, $"seed {seed}: corner {i} is not the corner it was"); + var used = new bool[result.vertices.Length]; + foreach (var index in result.indices) + used[index] = true; + for (int v = 0; v < result.vertices.Length; v++) + { + Assert.That(used[v], Is.True, $"seed {seed}: vertex {v} is used by no triangle"); + for (int w = v + 1; w < result.vertices.Length; w++) + Assert.That(SameBits(result.vertices[v], result.vertices[w]), Is.False, $"seed {seed}: vertices {v} and {w} are the same"); + } + } + + static void AssertWeldInvariants(int[] inputIndices, Corner[] input, (int[] indices, Corner[] vertices, int dropped, int flipped) result, string what) + { + Assert.That(result.indices.Length, Is.EqualTo(inputIndices.Length - 3 * result.dropped), what + ": triangles other than the dropped ones went"); + int needlesBefore = 0, needlesAfter = 0; + for (int t = 0; t < inputIndices.Length; t += 3) + { + var a = input[inputIndices[t]].collider; var b = input[inputIndices[t + 1]].collider; var c = input[inputIndices[t + 2]].collider; + if (!SamePosition(a, b) && !SamePosition(b, c) && !SamePosition(c, a) && OutputWeld.OnOneLine(a, b, c)) + needlesBefore++; + } + for (int t = 0; t < result.indices.Length; t += 3) + { + var a = result.vertices[result.indices[t]].collider; var b = result.vertices[result.indices[t + 1]].collider; var c = result.vertices[result.indices[t + 2]].collider; + Assert.That(SamePosition(a, b) || SamePosition(b, c) || SamePosition(c, a), Is.False, what + ": a triangle with two corners at one position is left"); + if (OutputWeld.OnOneLine(a, b, c)) + needlesAfter++; + } + Assert.That(needlesAfter, Is.EqualTo(needlesBefore - result.flipped), what + ": needles left"); + foreach (var corner in result.vertices) + { + var known = false; + foreach (var original in input) + known |= SameBits(corner, original); + Assert.That(known, Is.True, what + ": a vertex the input does not have"); + } + Assert.That(NetEdges(result.indices, result.vertices), Is.EquivalentTo(NetEdges(inputIndices, input)), what + ": the net edges changed"); + } + + // Per pair of positions, how often it is run one way more than the other (0 left out). -0 is 0 here, as everywhere. + static Dictionary<(float3, float3), int> NetEdges(int[] indices, Corner[] vertices) + { + var net = new Dictionary<(float3, float3), int>(); + for (int t = 0; t + 2 < indices.Length; t += 3) + { + for (int e = 0; e < 3; e++) + { + var from = vertices[indices[t + e]].collider + 0.0f; + var to = vertices[indices[t + (e + 1) % 3]].collider + 0.0f; + if (SamePosition(from, to)) + continue; + var forward = math.asuint(from).x < math.asuint(to).x || (math.asuint(from).x == math.asuint(to).x && + (math.asuint(from).y < math.asuint(to).y || (math.asuint(from).y == math.asuint(to).y && math.asuint(from).z < math.asuint(to).z))); + var key = forward ? (from, to) : (to, from); + net.TryGetValue(key, out var n); + net[key] = n + (forward ? 1 : -1); + } + } + foreach (var key in new List<(float3, float3)>(net.Keys)) + { + if (net[key] == 0) + net.Remove(key); + } + return net; + } + + [Test] + public void AClosedSolidWithCopiedCornersAndSpikes_StaysClosed() + { + // an octahedron + var p = new[] { new float3(1, 0, 0), new float3(-1, 0, 0), new float3(0, 1, 0), new float3(0, -1, 0), new float3(0, 0, 1), new float3(0, 0, -1) }; + var faces = new[] { 0, 2, 4, 2, 1, 4, 1, 3, 4, 3, 0, 4, 2, 0, 5, 1, 2, 5, 3, 1, 5, 0, 3, 5 }; + var vertices = new List(); + foreach (var position in p) + vertices.Add(V(position)); + // each face uses its own copy of its first corner + var indices = new List(); + for (int f = 0; f < faces.Length; f += 3) + { + vertices.Add(V(p[faces[f]])); + indices.Add(vertices.Count - 1); indices.Add(faces[f + 1]); indices.Add(faces[f + 2]); + } + // and spikes: a triangle from a corner to its copy and a neighbour, whose two long edges cancel + indices.Add(0); indices.Add(6); indices.Add(2); + indices.Add(4); indices.Add(4); indices.Add(1); + + Assert.That(IsClosed(indices.ToArray(), vertices.ToArray()), Is.True, "the octahedron is not closed to begin with"); + var result = Weld(indices.ToArray(), vertices.ToArray()); + Assert.That(result.dropped, Is.EqualTo(2), "the two spikes"); + Assert.That(result.indices.Length, Is.EqualTo(24), "the octahedron's 8 faces"); + Assert.That(result.vertices.Length, Is.EqualTo(6), "one vertex per corner: the copies are the same records"); + Assert.That(IsClosed(result.indices, result.vertices), Is.True, "the welded octahedron is open"); + } + + static bool IsClosed(int[] indices, Corner[] vertices) + { + var edges = new Dictionary<(float3, float3), int>(); + for (int t = 0; t < indices.Length; t += 3) + { + for (int e = 0; e < 3; e++) + { + var from = vertices[indices[t + e]].collider; + var to = vertices[indices[t + (e + 1) % 3]].collider; + if (SamePosition(from, to)) + continue; + edges.TryGetValue((from, to), out var n); + edges[(from, to)] = n + 1; + } + } + foreach (var edge in edges) + { + edges.TryGetValue((edge.Key.Item2, edge.Key.Item1), out var back); + if (back != edge.Value) + return false; + } + return true; + } + + // The exact judge counts the rounded exact triangles it does not look for in the mesh, because the weld drops them: + // exactly the drain cap's two (EndWeldTests), and none of a box's + [Test] + public void TheExactJudge_CountsTheTrianglesTheWeldDrops() + { + var planes = new float4[EndWeldTests.kDrainCap.Length / 4]; + for (int p = 0; p < planes.Length; p++) + planes[p] = math.asfloat(new uint4(EndWeldTests.kDrainCap[p * 4], EndWeldTests.kDrainCap[p * 4 + 1], EndWeldTests.kDrainCap[p * 4 + 2], EndWeldTests.kDrainCap[p * 4 + 3])); + var pivot = math.asfloat(new uint3(EndWeldTests.kDrainCapPivot[0], EndWeldTests.kDrainCapPivot[1], EndWeldTests.kDrainCapPivot[2])); + var cap = ContentsSceneNode.Brush(planes, localToTree: float4x4.Translate(pivot), name: "drain cap"); + using (var harness = ContentsTreeHarness.Build(new ContentsScene().Add(cap))) + { + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + var mismatches = ExactJudge.FindMismatches(harness, 8, out _, checkMesh: true); + Assert.That(mismatches, Is.Empty, "the drain cap's mesh is judged wrong"); + Assert.That(ExactJudge.LastDroppedByTheWeld, Is.EqualTo(2), "the exact triangles the weld drops from the drain cap"); + } + var box = ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0, 0, 0), new float3(1, 1, 1)), name: "box"); + using (var harness = ContentsTreeHarness.Build(new ContentsScene().Add(box))) + { + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(ExactJudge.FindMismatches(harness, 8, out _, checkMesh: true), Is.Empty, "the box's mesh is judged wrong"); + Assert.That(ExactJudge.LastDroppedByTheWeld, Is.EqualTo(0), "the weld drops nothing of a box"); + } + } + + // The exact judge recognises the needles the weld flipped away (ExactJudge.CheckMesh): the wedges of EndWeldTests have one + [Test] + public void TheExactJudge_CountsTheNeedlesTheWeldFlips() + { + using var harness = ContentsTreeHarness.Build(EndWeldTests.SceneOf(EndWeldTests.kWedges, EndWeldTests.kWedgesPivots, "wedges")); + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + var mismatches = ExactJudge.FindMismatches(harness, 8, out _, checkMesh: true); + Assert.That(mismatches, Is.Empty, "the wedges' mesh is judged wrong"); + Assert.That(ExactJudge.LastFlippedByTheWeld, Is.EqualTo(1), "needles the weld flipped away"); + } + + // Every surface buffer is welded as it is made: what a buffer counts, hashes and holds is the welded surface + [Test] + public void ASurfaceBuffer_IsMadeOfTheWeldedSurface() + { + var vertices = new[] { V(new float3(0, 0, 0)), V(new float3(2, 0, 0)), V(new float3(2, 1, 0)), V(new float3(2, 1, 0)), V(new float3(0, 1, 0)) }; + var indices = new[] { 0, 1, 2, 0, 2, 3, 0, 3, 4 }; + var indexList = new NativeList(Allocator.Temp); + var colliderList = new NativeList(Allocator.Temp); + var selectList = new NativeList(Allocator.Temp); + var renderList = new NativeList(Allocator.Temp); + try + { + foreach (var index in indices) + indexList.Add(index); + foreach (var vertex in vertices) + { + colliderList.Add(vertex.collider); + selectList.Add(vertex.select); + renderList.Add(vertex.render); + } + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + root.Construct(builder, indexList, colliderList, selectList, renderList, 0, 0, + SurfaceDestinationFlags.Renderable | SurfaceDestinationFlags.Collidable, default, default); + using var blob = builder.CreateBlobAssetReference(Allocator.Temp); + ref var buffer = ref blob.Value; + Assert.That(buffer.indexCount, Is.EqualTo(6), "the buffer counts the collapsed triangle"); + Assert.That(buffer.vertexCount, Is.EqualTo(4), "the buffer counts the copied corner"); + Assert.That(buffer.indices.Length, Is.EqualTo(6), "the buffer holds the collapsed triangle"); + Assert.That(buffer.renderVertices.Length, Is.EqualTo(4), "the buffer holds the copied corner"); + for (int i = 0; i < buffer.indices.Length; i += 3) + { + var a = buffer.colliderVertices[buffer.indices[i]]; + var b = buffer.colliderVertices[buffer.indices[i + 1]]; + var c = buffer.colliderVertices[buffer.indices[i + 2]]; + Assert.That(SamePosition(a, b) || SamePosition(b, c) || SamePosition(c, a), Is.False, "the buffer holds a triangle with two corners at one position"); + } + } + finally + { + indexList.Dispose(); + colliderList.Dispose(); + selectList.Dispose(); + renderList.Dispose(); + } + } + } +} diff --git a/Core/Tests/OutputWeldTests.cs.meta b/Core/Tests/OutputWeldTests.cs.meta new file mode 100644 index 00000000..4aa16ba6 --- /dev/null +++ b/Core/Tests/OutputWeldTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c529d0f77af32844c901fde4e28aeb9e \ No newline at end of file diff --git a/Core/Tests/PlaneDefinedBrushTests.cs b/Core/Tests/PlaneDefinedBrushTests.cs new file mode 100644 index 00000000..ed982494 --- /dev/null +++ b/Core/Tests/PlaneDefinedBrushTests.cs @@ -0,0 +1,296 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class PlaneDefinedBrushTests + { + static readonly uint[] kCone = + { + 0x00000000, 0xBF800000, 0x80000000, 0x41AFAE14, 0x3E0C0DE9, 0x3E8234EE, 0x3F751857, 0x4202885F, + 0x3EBBA91A, 0x80000000, 0x3F6E2F3F, 0x424041DC, 0x3F18F346, 0x3E825AF9, 0x3F42A9FD, 0x422BAA45, + 0x3F494C29, 0x80000000, 0x3F1E298E, 0x4250FE00, 0x3F665850, 0x3E824729, 0x3EB57BE2, 0x421B81E5, + 0x3F7D6D54, 0x80000000, 0x3E10D0C3, 0x422668BA, 0x3F751857, 0x3E8234EE, 0xBE0C0DE9, 0x41AFA1DC, + 0x3F6E2F3F, 0x00000000, 0xBEBBA91A, 0x419B9193, 0x3F42A9EC, 0x3E825B95, 0xBF18F33A, 0xC06EF5D1, + 0x3F1E298E, 0x00000000, 0xBF494C29, 0xC1125CF1, 0x3EB57BE2, 0x3E824729, 0xBF665850, 0xC1FB06E7, + 0x3E10D0C3, 0x00000000, 0xBF7D6D54, 0xC20EC59D, 0xBE0C0DE9, 0x3E8234EE, 0xBF751857, 0xC253332A, + 0xBEBBA91A, 0x80000000, 0xBF6E2F3F, 0xC254CD03, 0xBF18F346, 0x3E825AF9, 0xBF42A9FD, 0xC27C6CA1, + 0xBF494C29, 0x80000000, 0xBF1E298E, 0xC2658C7D, 0xBF665850, 0x3E824729, 0xBEB57BE2, 0xC26C37FB, + 0xBF7D6D54, 0x80000000, 0xBE10D0C3, 0xC23AF0CE, 0xBF751857, 0x3E8234EE, 0x3E0C0DE9, 0xC2287BB9, + 0xBF6E2F3F, 0x00000000, 0x3EBBA91A, 0xC1C4A7E1, 0xBF42A9EC, 0x3E825B95, 0x3F18F33A, 0xC183A6A7, + 0xBF1E298E, 0x00000000, 0x3F494C29, 0x408045F3, 0xBEB57BE2, 0x3E824729, 0x3F665850, 0x41333575, + 0xBE10D0C3, 0x00000000, 0x3F7D6D54, 0x41F47B13, 0xBF6E2F3F, 0x80000000, 0xBEBBA91A, 0xC254CD03, + 0xBF1E298E, 0x80000000, 0xBF494C29, 0xC2658C7D, 0xBE10D0C3, 0x80000000, 0xBF7D6D54, 0xC23AF0CE, + 0x3EBBA91A, 0x00000000, 0xBF6E2F3F, 0xC1C4A7E1, 0x3F494C29, 0x00000000, 0xBF1E298E, 0x408045F3, + 0x3F7D6D54, 0x00000000, 0xBE10D0C3, 0x41F47B13, 0x3F6E2F3F, 0x80000000, 0x3EBBA91A, 0x424041DC, + 0x3F1E298E, 0x80000000, 0x3F494C29, 0x4250FE00, 0x3E10D0C3, 0x80000000, 0x3F7D6D54, 0x422668BA, + 0xBEBBA91A, 0x00000000, 0x3F6E2F3F, 0x419B9193, 0xBF494C29, 0x00000000, 0x3F1E298E, 0xC1125CF1, + 0xBF7D6D54, 0x00000000, 0x3E10D0C3, 0xC20EC59D, 0xBF751857, 0x3E8234EE, 0xBE0C0DE9, 0xC253332A, + 0xBF665850, 0x3E824729, 0x3EB57BE2, 0xC1FB06E7, 0xBF18F346, 0x3E825AF9, 0x3F42A9FD, 0xC06EF375, + 0xBE0C0DE9, 0x3E8234EE, 0x3F751857, 0x41AFA1DC, 0x3EB57BEF, 0x3E8246A3, 0x3F665861, 0x421B8211, + 0x3F42A9F7, 0x3E825B2B, 0x3F18F342, 0x422BAA34, 0x3F75182F, 0x3E823626, 0x3E0C0DD2, 0x420287FC, + 0x3F665850, 0x3E824729, 0xBEB57BE2, 0x41333575, 0x3F18F346, 0x3E825AF9, 0xBF42A9FD, 0xC183A664, + 0x3E0C0DE9, 0x3E8234EE, 0xBF751857, 0xC2287BB9, 0xBEB57BE2, 0x3E824729, 0xBF665850, 0xC26C37FB, + 0xBF42A9FD, 0x3E825AF9, 0xBF18F346, 0xC27C6CA1, + }; + + + static readonly uint[] kLightBlockerA = + { + 0x80000000,0x3F800000,0x00000000,0xBF6A3D6E, 0x00000000,0xBF800000,0x00000000,0xBF6A3D73, 0xBF800000,0x00000000,0x80000000,0xBB9C2CCD, + 0x3F800000,0x00000000,0x00000000,0xBB9C251F, 0x00000000,0x00000000,0x3F800000,0xBF17D280, 0x00000000,0x00000000,0xBF800000,0xBEF2EA5C, + }; + static readonly uint[] kLightBlockerAPivot = { 0xC25B4B85, 0x41B83852, 0x414C02D8 }; + // A slat 9.5 mm across + static readonly uint[] kLightBlockerB = + { + 0x00000000,0xBF800000,0x00000000,0xBB26C429, 0x3F800000,0x00000000,0x00000000,0xBB4F347B, 0x00000000,0x80000000,0xBF800000,0xBE91BFD1, + 0x00000000,0x00000000,0x3F800000,0xBE7F0FBB, 0xBF3504F3,0x3F3504F3,0x00000000,0xBB316CC0, + }; + static readonly uint[] kLightBlockerBPivot = { 0x41815EF7, 0xBFA1EF4D, 0xC0420FB4 }; + // A cone, as planes + static readonly uint[] kConeAsImported = + { + 0x00000000,0xBF800000,0x00000000,0xC0D8B5D2, 0x3E0C0DE9,0x3E8234EE,0x3F751857,0xC02D7501, 0x3EBBA91A,0x00000000,0x3F6E2F3F,0xC01FFDEE, + 0x3F18F346,0x3E825AF9,0x3F42A9FD,0xC02E097B, 0x3F494C29,0x00000000,0x3F1E298E,0xC0210292, 0x3F665850,0x3E824729,0x3EB57BE2,0xC02F5D30, + 0x3F7D6D54,0x00000000,0x3E10D0C3,0xC022A4DA, 0x3F751857,0x3E8234EE,0xBE0C0DE9,0xC0315008, 0x3F6E2F3F,0x00000000,0xBEBBA91A,0xC024F9DE, + 0x3F42A9EC,0x3E825B95,0xBF18F33A,0xC033B107, 0x3F1E298E,0x00000000,0xBF494C29,0xC02731F8, 0x3EB57BE2,0x3E824729,0xBF665850,0xC03549EE, + 0x3E10D0C3,0x00000000,0xBF7D6D54,0xC028597A, 0xBE0C0DE9,0x3E8234EE,0xBF751857,0xC035EF9C, 0xBEBBA91A,0x00000000,0xBF6E2F3F,0xC028B47C, + 0xBF18F346,0x3E825AF9,0xBF42A9FD,0xC035C2F7, 0xBF494C29,0x00000000,0xBF1E298E,0xC027E54B, 0xBF665850,0x3E824729,0xBEB57BE2,0xC034392E, + 0xBF7D6D54,0x00000000,0xBE10D0C3,0xC025DC63, 0xBF751857,0x3E8234EE,0x3E0C0DE9,0xC0321495, 0xBF6E2F3F,0x00000000,0x3EBBA91A,0xC023B88C, + 0xBF42A9EC,0x3E825B95,0x3F18F33A,0xC0301C49, 0xBF1E298E,0x00000000,0x3F494C29,0xC021B5E6, 0xBEB57BE2,0x3E824729,0x3F665850,0xC02E4C70, + 0xBE10D0C3,0x00000000,0x3F7D6D54,0xC02027C3, 0xBF6E2F3F,0x00000000,0xBEBBA91A,0xC026DC5F, 0xBF1E298E,0x00000000,0xBF494C29,0xC028725E, + 0xBE10D0C3,0x00000000,0xBF7D6D54,0xC028A2D1, 0x3EBBA91A,0x00000000,0xBF6E2F3F,0xC027F668, 0x3F494C29,0x00000000,0xBF1E298E,0xC0264D83, + 0x3F7D6D54,0x00000000,0xBE10D0C3,0xC023DB01, 0x3F6E2F3F,0x00000000,0x3EBBA91A,0xC021D60B, 0x3F1E298E,0x00000000,0x3F494C29,0xC0207580, + 0x3E10D0C3,0x00000000,0x3F7D6D54,0xC01FDE6C, 0xBEBBA91A,0x00000000,0x3F6E2F3F,0xC020BC02, 0xBF494C29,0x00000000,0x3F1E298E,0xC0229A5A, + 0xBF7D6D54,0x00000000,0x3E10D0C3,0xC024A63C, 0xBF751857,0x3E8234EE,0xBE0C0DE9,0xC0334089, 0xBF665850,0x3E824729,0x3EB57BE2,0xC0312FD0, + 0xBF18F346,0x3E825AF9,0x3F42A9FD,0xC02F401E, 0xBE0C0DE9,0x3E8234EE,0x3F751857,0xC02DBBEF, 0x3EB57BEF,0x3E8246A5,0x3F665861,0xC02D9496, + 0x3F42A9F7,0x3E825B2B,0x3F18F342,0xC02E91EB, 0x3F75182F,0x3E823626,0x3E0C0DD1,0xC0302427, 0x3F665850,0x3E824729,0xBEB57BE2,0xC032668E, + 0x3F18F346,0x3E825AF9,0xBF42A9FD,0xC0348D20, 0x3E0C0DE9,0x3E8234EE,0xBF751857,0xC035A8AE, 0xBEB57BE2,0x3E824729,0xBF665850,0xC03601C1, + 0xBF42A9FD,0x3E825AF9,0xBF18F346,0xC0353A8A, + }; + static readonly uint[] kConeAsImportedPivot = { 0xC21C18C1, 0x41E5DB89, 0xC21BE46D }; + + static float4[] Planes(uint[] bits) + { + var planes = new float4[bits.Length / 4]; + for (int p = 0; p < planes.Length; p++) + planes[p] = math.asfloat(new uint4(bits[p * 4], bits[p * 4 + 1], bits[p * 4 + 2], bits[p * 4 + 3])); + return planes; + } + + static float4[] BoxPlanes(float3 min, float3 max) + { + return new[] + { + new float4(-1, 0, 0, min.x), new float4(1, 0, 0, -max.x), + new float4( 0,-1, 0, min.y), new float4(0, 1, 0, -max.y), + new float4( 0, 0,-1, min.z), new float4(0, 0, 1, -max.z) + }; + } + + static ChiselSurfaceArray SurfacesFor(int count) + { + var surfaceArray = new ChiselSurfaceArray(); + surfaceArray.EnsureSize(count); + for (int i = 0; i < surfaceArray.surfaces.Length; i++) + surfaceArray.surfaces[i] = new ChiselSurface(); + return surfaceArray; + } + + static bool SameBits(float4 a, float4 b) => math.all(math.asuint(a) == math.asuint(b)); + + // Given these planes, the definition is a valid brush of exactly expectedFaces of them, and a scene registers it + // with exactly their planes + static void AssertIsABrushOf(float4[] planes, int expectedFaces, string what) + { + var definition = new ChiselBrushDefinition(); + definition.SetPlanes(planes); + Assert.That(definition.Validate(), Is.True, what + ": not a valid brush"); + var outline = definition.BrushOutline; + Assert.That(outline, Is.Not.Null, what + ": no outline"); + Assert.That(outline.ValidateData(out var dataProblem), Is.True, what + ": its outline is broken: " + dataProblem); + Assert.That(outline.polygons.Length, Is.EqualTo(expectedFaces), what + ": another number of faces"); + var seen = new HashSet(); + for (int f = 0; f < outline.polygons.Length; f++) + { + var polygon = outline.polygons[f]; + int p = polygon.descriptionIndex; + Assert.That(p >= 0 && p < planes.Length, Is.True, $"{what}: face {f} has no plane of the brush ({p})"); + Assert.That(seen.Add(p), Is.True, $"{what}: plane {p} has two faces"); + Assert.That(polygon.edgeCount, Is.GreaterThanOrEqualTo(3), $"{what}: face {f} (plane {p}) has {polygon.edgeCount} edges"); + Assert.That(SameBits(outline.planes[f], planes[p]), Is.True, $"{what}: face {f} has plane {outline.planes[f]}, it was given {planes[p]}"); + } + Assert.That(definition.RequiredSurfaceCount, Is.EqualTo(planes.Length), what + ": surfaces are one per given plane"); + + var brush = CSGTreeBrush.Create(); + try + { + BrushMeshManager.ConvertBrushMeshesToBrushMeshInstances(new List { brush }, new List { outline }, + new List { SurfacesFor(planes.Length) }); + var blobRef = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh); + Assert.That(blobRef.IsCreated, Is.True, what + ": a scene registers it as nothing"); + ref var blob = ref blobRef.Value; + Assert.That(blob.localPlaneCount, Is.EqualTo(outline.polygons.Length), what + ": registered with another number of planes"); + for (int f = 0; f < outline.polygons.Length; f++) + Assert.That(SameBits(blob.localPlanes[f], outline.planes[f]), Is.True, $"{what}: registered plane {f} is {blob.localPlanes[f]}, not {outline.planes[f]}"); + } + finally + { + var registered = brush.BrushMesh; + brush.Destroy(); + if (registered.Valid) + registered.Destroy(); + } + } + + [Test] + public void TheCone_IsABrushOfItsFortyNinePlanes() + { + AssertIsABrushOf(Planes(kCone), 49, "the cone"); + } + + [Test] + public void TheContentsHarness_BuildsABrushOfPlanesAsAScene() + { + var planes = Planes(kCone); + var definition = new ChiselBrushDefinition(); + definition.SetPlanes(planes); + Assert.That(definition.Validate(), Is.True, "the cone is no brush"); + var outline = definition.BrushOutline; + + var cone = ContentsSceneNode.Brush(planes, name: "cone"); + using (var harness = ContentsTreeHarness.Build(new ContentsScene().Add(cone))) + { + var blobRef = BrushMeshManager.GetBrushMeshBlob(harness.TreeBrushOf(cone).BrushMesh); + Assert.That(blobRef.IsCreated, Is.True, "the harness made nothing of the cone's planes"); + ref var blob = ref blobRef.Value; + Assert.That(blob.localPlaneCount, Is.EqualTo(outline.planes.Length), "the harness made another brush of the cone's planes: faces"); + for (int f = 0; f < outline.planes.Length; f++) + Assert.That(SameBits(blob.localPlanes[f], outline.planes[f]), Is.True, $"the harness made another brush of the cone's planes: plane {f} is {blob.localPlanes[f]}, not {outline.planes[f]}"); + Assert.That(blob.localVertices.Length, Is.EqualTo(outline.vertices.Length), "the harness made another brush of the cone's planes: vertices"); + for (int v = 0; v < outline.vertices.Length; v++) + Assert.That(math.all(math.asuint(blob.localVertices[v]) == math.asuint(outline.vertices[v])), Is.True, + $"the harness made another brush of the cone's planes: vertex {v} is {blob.localVertices[v]}, not {outline.vertices[v]}"); + + Assert.That(harness.Update(), Is.True, "the CSG update did not run"); + Assert.That(harness.TriangleCountOf(cone), Is.GreaterThan(0), "the cone draws nothing: " + harness.LastUpdateReport); + } + } + + static float3 Pivot(uint[] bits) => math.asfloat(new uint3(bits[0], bits[1], bits[2])); + + // The brush a scene makes of these planes reaches the CSG where it is placed, and draws + static void AssertIsABrushThatDraws(uint[] planeBits, uint[] pivotBits, int expectedFaces, string what) + { + var planes = Planes(planeBits); + AssertIsABrushOf(planes, expectedFaces, what); + var node = ContentsSceneNode.Brush(planes, localToTree: float4x4.Translate(Pivot(pivotBits)), name: what); + using (var harness = ContentsTreeHarness.Build(new ContentsScene().Add(node))) + { + Assert.That(harness.Update(), Is.True, what + ": the CSG update did not run"); + Assert.That(harness.TriangleCountOf(node), Is.GreaterThan(0), what + " draws nothing: " + harness.LastUpdateReport); + } + } + + [Test] + public void LightBlockerA_IsABrushThatDraws() + { + AssertIsABrushThatDraws(kLightBlockerA, kLightBlockerAPivot, 6, "light blocker A"); + } + + [Test] + public void TheSlat_IsABrushThatDraws() + { + AssertIsABrushThatDraws(kLightBlockerB, kLightBlockerBPivot, 5, "the slat"); + } + + [Test] + public void TheConeAsPlanes_IsABrushThatDraws() + { + AssertIsABrushThatDraws(kConeAsImported, kConeAsImportedPivot, 49, "the cone as planes"); + } + + [Test] + public void ABox_IsABrushOfItsSixPlanes() + { + AssertIsABrushOf(BoxPlanes(new float3(-1, -2, -3), new float3(4, 5, 6)), 6, "a box"); + } + + // A thin slab, 1/64 of a unit + [Test] + public void AThinSlab_IsABrushOfItsSixPlanes() + { + AssertIsABrushOf(BoxPlanes(new float3(-40, 0, -30), new float3(40, 1f / 64, 30)), 6, "a slab 1/64 thick"); + } + + // A plane that only touches the brush (at an edge) makes no face, and its surface is not used + [Test] + public void APlaneThatOnlyTouches_MakesNoFace() + { + var planes = BoxPlanes(new float3(0, 0, 0), new float3(1, 1, 1)).ToList(); + var diagonal = math.normalize(new float3(1, 1, 0)); + planes.Add(new float4(diagonal, -math.dot(diagonal, new float3(1, 1, 0)))); // through the edge x = 1, y = 1 + var definition = new ChiselBrushDefinition(); + definition.SetPlanes(planes.ToArray()); + Assert.That(definition.Validate(), Is.True); + Assert.That(definition.BrushOutline.polygons.Any(p => p.descriptionIndex == 6), Is.False, "the touching plane has a face"); + Assert.That(definition.BrushOutline.polygons.Length, Is.EqualTo(6)); + } + + [Test] + public void PlanesThatDoNotCloseIt_AreNoBrush() + { + var definition = new ChiselBrushDefinition(); + definition.SetPlanes(BoxPlanes(new float3(0, 0, 0), new float3(1, 1, 1)).Take(5).ToArray()); + UnityEngine.TestTools.LogAssert.ignoreFailingMessages = true; + try { Assert.That(definition.Validate(), Is.False, "five planes of a box made a brush"); } + finally { UnityEngine.TestTools.LogAssert.ignoreFailingMessages = false; } + } + + [Test] + public void PlanesThatEncloseNothing_AreNoBrush() + { + var definition = new ChiselBrushDefinition(); + var planes = BoxPlanes(new float3(0, 0, 0), new float3(1, 1, 1)); + planes[1] = new float4(1, 0, 0, 1); // x <= -1, while x >= 0 + definition.SetPlanes(planes); + UnityEngine.TestTools.LogAssert.ignoreFailingMessages = true; + try { Assert.That(definition.Validate(), Is.False, "planes that enclose nothing made a brush"); } + finally { UnityEngine.TestTools.LogAssert.ignoreFailingMessages = false; } + } + + // Editing the outline (the editor assigns it) makes the brush its outline from then on + [Test] + public void AnOutlineGivenFromOutside_EndsThePlanes() + { + var definition = new ChiselBrushDefinition(); + definition.SetPlanes(BoxPlanes(new float3(0, 0, 0), new float3(1, 1, 1))); + Assert.That(definition.Validate(), Is.True); + definition.BrushOutline = new BrushMesh(definition.BrushOutline); + Assert.That(definition.IsGivenAsPlanes, Is.False); + Assert.That(definition.Validate(), Is.True, "the edited outline is a valid brush"); + } + + // The same planes derive their outline once; a changed plane derives it again + [Test] + public void TheOutline_IsDerivedAgainOnlyWhenThePlanesChange() + { + var definition = new ChiselBrushDefinition(); + var planes = BoxPlanes(new float3(0, 0, 0), new float3(1, 1, 1)); + definition.SetPlanes(planes); + Assert.That(definition.Validate(), Is.True); + var first = definition.BrushOutline; + Assert.That(definition.Validate(), Is.True); + Assert.That(definition.BrushOutline, Is.SameAs(first), "unchanged planes derived their outline again"); + definition.inputPlanes[1] = new float4(1, 0, 0, -2); + Assert.That(definition.Validate(), Is.True); + Assert.That(definition.BrushOutline, Is.Not.SameAs(first), "a changed plane kept the old outline"); + } + } +} diff --git a/Core/Tests/PlaneDefinedBrushTests.cs.meta b/Core/Tests/PlaneDefinedBrushTests.cs.meta new file mode 100644 index 00000000..a39ef924 --- /dev/null +++ b/Core/Tests/PlaneDefinedBrushTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69b20762ff700c6409274a7f3f0ec652 \ No newline at end of file diff --git a/Core/Tests/PlaneExtensionsTests.cs b/Core/Tests/PlaneExtensionsTests.cs new file mode 100644 index 00000000..e198b1fd --- /dev/null +++ b/Core/Tests/PlaneExtensionsTests.cs @@ -0,0 +1,91 @@ +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using static Chisel.Core.Tests.ChiselTestUtility; + +namespace Chisel.Core.Tests +{ + // Three-plane and plane-plane intersection are how CSG recovers vertices from brush planes. + [TestFixture] + public class PlaneExtensionsTests + { + [Test] + public void Intersection_ThreeAxisPlanes_MeetAtExpectedPoint() + { + var px = new Plane(new Vector3(1, 0, 0), -1f); // x = 1 + var py = new Plane(new Vector3(0, 1, 0), -2f); // y = 2 + var pz = new Plane(new Vector3(0, 0, 1), -3f); // z = 3 + + var point = PlaneExtensions.Intersection(px, py, pz); + + AssertApproximately(new Vector3(1, 2, 3), point, 1e-3f); + Assert.That(IsOnPlane(px, point), Is.True); + Assert.That(IsOnPlane(py, point), Is.True); + Assert.That(IsOnPlane(pz, point), Is.True); + } + + [Test] + public void Intersection_ParallelPlanes_ReturnsNaN() + { + var p1 = new Plane(new Vector3(1, 0, 0), -1f); // x = 1 + var p2 = new Plane(new Vector3(1, 0, 0), -2f); // x = 2 (parallel to p1) + var p3 = new Plane(new Vector3(0, 1, 0), 0f); + + var point = PlaneExtensions.Intersection(p1, p2, p3); + Assert.That(float.IsNaN(point.x), Is.True); + } + + [Test] + public void Intersection_DoubleAndFloatOverloads_Agree() + { + var px = new Plane(new Vector3(1, 0, 0), -1f); + var py = new Plane(new Vector3(0, 1, 0), -2f); + var pz = new Plane(new Vector3(0, 0, 1), -3f); + + var floatPoint = PlaneExtensions.Intersection(px, py, pz); + var doublePoint = PlaneExtensions.Intersection( + new double4(px.normal.x, px.normal.y, px.normal.z, px.distance), + new double4(py.normal.x, py.normal.y, py.normal.z, py.distance), + new double4(pz.normal.x, pz.normal.y, pz.normal.z, pz.distance)); + + AssertApproximately((double3)(float3)floatPoint, doublePoint, 1e-3); + AssertApproximately(new double3(1, 2, 3), doublePoint, 1e-3); + } + + [Test] + public void Intersection_DoubleParallelPlanes_ReturnsNaN() + { + var point = PlaneExtensions.Intersection( + new double4(1, 0, 0, -1), + new double4(1, 0, 0, -2), + new double4(0, 1, 0, 0)); + Assert.That(double.IsNaN(point.x), Is.True); + } + + [Test] + public void PlanePlaneIntersection_TwoOriginPlanes_FindLineOfIntersection() + { + // x = 0 plane and y = 0 plane intersect along the z axis. + PlaneExtensions.PlanePlaneIntersection(out double3 linePoint, out double3 lineVec, + new double4(1, 0, 0, 0), new double4(0, 1, 0, 0)); + + // Direction is parallel to the z axis. + var dir = math.normalize(lineVec); + Assert.That(math.abs(dir.z), Is.EqualTo(1.0).Within(1e-6)); + Assert.That(math.abs(dir.x), Is.EqualTo(0.0).Within(1e-6)); + Assert.That(math.abs(dir.y), Is.EqualTo(0.0).Within(1e-6)); + + // Any point on the line satisfies x = 0 and y = 0. + Assert.That(linePoint.x, Is.EqualTo(0.0).Within(1e-6)); + Assert.That(linePoint.y, Is.EqualTo(0.0).Within(1e-6)); + } + + [Test] + public void NanAccessors_AreNaN() + { + Assert.That(float.IsNaN(PlaneExtensions.NanVector.x), Is.True); + Assert.That(float.IsNaN(PlaneExtensions.NanFloat3.x), Is.True); + Assert.That(double.IsNaN(PlaneExtensions.NanDouble3.x), Is.True); + } + } +} diff --git a/Core/Tests/PlaneExtensionsTests.cs.meta b/Core/Tests/PlaneExtensionsTests.cs.meta new file mode 100644 index 00000000..24335ab0 --- /dev/null +++ b/Core/Tests/PlaneExtensionsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd559437eb989a049a0f56e4307edf7d \ No newline at end of file diff --git a/Core/Tests/RangeAndEdgeTests.cs b/Core/Tests/RangeAndEdgeTests.cs new file mode 100644 index 00000000..f84fa975 --- /dev/null +++ b/Core/Tests/RangeAndEdgeTests.cs @@ -0,0 +1,54 @@ +using NUnit.Framework; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class RangeTests + { + [Test] + public void Length_IsEndMinusStart() + { + Assert.That(new Range { start = 2, end = 10 }.Length, Is.EqualTo(8)); + Assert.That(new Range { start = 0, end = 0 }.Length, Is.EqualTo(0)); + Assert.That(new Range { start = 7, end = 5 }.Length, Is.EqualTo(-2)); + } + + [Test] + public void Center_IsMidpointRoundedTowardStart() + { + Assert.That(new Range { start = 2, end = 10 }.Center, Is.EqualTo(6)); + Assert.That(new Range { start = 5, end = 12 }.Center, Is.EqualTo(8)); + Assert.That(new Range { start = 0, end = 1 }.Center, Is.EqualTo(0)); + } + } + + [TestFixture] + public class EdgeTests + { + [Test] + public void Equals_IsOrderSensitive() + { + var a = new Edge { index1 = 3, index2 = 7 }; + var b = new Edge { index1 = 3, index2 = 7 }; + var reversed = new Edge { index1 = 7, index2 = 3 }; + + Assert.That(a.Equals(b), Is.True); + Assert.That(a.Equals(reversed), Is.False); + } + + [Test] + public void GetHashCode_IsStableForEqualEdges() + { + var a = new Edge { index1 = 11, index2 = 22 }; + var b = new Edge { index1 = 11, index2 = 22 }; + Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode())); + } + + [Test] + public void ToString_ListsBothIndices() + { + var edge = new Edge { index1 = 4, index2 = 9 }; + Assert.That(edge.ToString(), Is.EqualTo("(4, 9)")); + } + } +} diff --git a/Core/Tests/RangeAndEdgeTests.cs.meta b/Core/Tests/RangeAndEdgeTests.cs.meta new file mode 100644 index 00000000..94617ac9 --- /dev/null +++ b/Core/Tests/RangeAndEdgeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 20f550f40703d74498f8e48ddba8c7c1 \ No newline at end of file diff --git a/Core/Tests/RoutingTableCapacityTests.cs b/Core/Tests/RoutingTableCapacityTests.cs new file mode 100644 index 00000000..a16bcc0d --- /dev/null +++ b/Core/Tests/RoutingTableCapacityTests.cs @@ -0,0 +1,540 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class RoutingTableCapacityTests + { + // What CreateRoutingTableJob allocates for the event stack (CreateRoutingTableJob.cs:77). + const int kQueuedEventCapacity = 4096; + + static CompactNodeID NodeID(int index) + { + return new CompactNodeID(new CompactHierarchyID(new SlotIndex { index = 1, generation = 1 }), + new SlotIndex { index = index, generation = 1 }); + } + + /// + /// One additive branch with additive brush children, indices 1..childCount. + /// + static BlobAssetReference Tree(int childCount) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + + var nodes = builder.Allocate(ref root.compactHierarchy, childCount + 1); + nodes[0] = new CompactHierarchyNode + { + Type = CSGNodeType.Branch, + Operation = CSGOperationType.Additive, + CompactNodeID = NodeID(0), + childCount = childCount, + childOffset = 1 + }; + for (int i = 1; i <= childCount; i++) + { + nodes[i] = new CompactHierarchyNode + { + Type = CSGNodeType.Brush, + Operation = CSGOperationType.Additive, + CompactNodeID = NodeID(i), + childCount = 0, + childOffset = 0 + }; + } + + // The routing job only reads these through GetBrushContents / brushIDValueToContents; every brush is an + // ordinary solid, which is the default. + root.minBrushIDValue = 1; + builder.Allocate(ref root.brushIDValueToContents, childCount); + builder.Allocate(ref root.brushIDValueToAncestorLegend, childCount); + builder.Allocate(ref root.brushAncestorLegend, 0); + builder.Allocate(ref root.brushAncestors, 0); + root.minNodeIDValue = 0; + root.maxNodeIDValue = childCount; + + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + /// A touching table in which every one of the brushes intersects the brush being routed + static BlobAssetReference Touching(int childCount) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + + var intersections = builder.Allocate(ref root.brushIntersections, childCount); + for (int i = 0; i < childCount; i++) + { + intersections[i] = new BrushIntersection + { + nodeIndexOrder = new IndexOrder { compactNodeID = NodeID(i + 1), nodeOrder = i }, + type = IntersectionType.Intersection, + bottomUpStart = 0, + bottomUpEnd = 0 + }; + } + + root.BitOffset = 0; + root.BitCount = childCount + 1; + var wordCount = ((root.BitCount * 2) + 31) / 32; + var bits = builder.Allocate(ref root.intersectionBits, wordCount); + for (int i = 0; i < wordCount; i++) + bits[i] = 0; + for (int id = 0; id <= childCount; id++) + { + var bitIndex = id << 1; + bits[bitIndex >> 5] |= (uint)IntersectionType.Intersection << (bitIndex & 31); + } + + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + /// Routes one brush out of a branch of mutually touching brushes. + static void RouteOneBrushOf(int childCount) + { + var tree = Tree(childCount); + var touching = Touching(childCount); + var orders = new NativeList(1, Allocator.Persistent); + var touched = new NativeList>(childCount, Allocator.Persistent); + var output = new NativeList>(childCount, Allocator.Persistent); + var treeRef = new NativeReference>(tree, Allocator.Persistent); + try + { + // Route the first brush: every other child of the branch is a touching sibling of it. + orders.Add(new IndexOrder { compactNodeID = NodeID(1), nodeOrder = 0 }); + for (int i = 0; i < childCount; i++) + touched.Add(touching); + output.Resize(childCount, NativeArrayOptions.ClearMemory); + + var job = new CreateRoutingTableJob + { + allUpdateBrushIndexOrders = orders, + brushesTouchedByBrushes = touched, + compactTreeRef = treeRef, + routingTableLookup = output + }; + job.Execute(0); + } + finally + { + for (int i = 0; i < output.Length; i++) + if (output[i].IsCreated) output[i].Dispose(); + output.Dispose(); + touched.Dispose(); + orders.Dispose(); + treeRef.Dispose(); + touching.Dispose(); + tree.Dispose(); + } + } + + [Test] + public void ABranchWithFewerTouchingChildrenThanTheEventCapacity_Routes() + { + Assert.DoesNotThrow(() => RouteOneBrushOf(kQueuedEventCapacity - 96)); + } + + [Test] + public void ABranchWithMoreTouchingChildrenThanTheEventCapacity_StillRoutes() + { + Assert.DoesNotThrow(() => RouteOneBrushOf(kQueuedEventCapacity + 4)); + } + + + + /// + /// A right-deep chain: each level is a branch holding one additive brush and, except at the bottom, another + /// subtractive branch. levels means brushes, at the odd node + /// indices 1, 3, 5, ... + /// + static BlobAssetReference NestedTree(int depth) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + + // level i occupies: branch at 2i, brush at 2i+1, next branch at 2i+2 + var nodeCount = depth * 2; + var nodes = builder.Allocate(ref root.compactHierarchy, nodeCount); + for (int i = 0; i < depth; i++) + { + var branchIndex = i * 2; + var isLast = (i == depth - 1); + nodes[branchIndex] = new CompactHierarchyNode + { + Type = CSGNodeType.Branch, + Operation = (i == 0) ? CSGOperationType.Additive : CSGOperationType.Subtractive, + CompactNodeID = NodeID(branchIndex), + childCount = isLast ? 1 : 2, + childOffset = branchIndex + 1 + }; + nodes[branchIndex + 1] = new CompactHierarchyNode + { + Type = CSGNodeType.Brush, + Operation = CSGOperationType.Additive, + CompactNodeID = NodeID(branchIndex + 1), + childCount = 0, + childOffset = 0 + }; + } + + root.minBrushIDValue = 0; + builder.Allocate(ref root.brushIDValueToContents, nodeCount); + builder.Allocate(ref root.brushIDValueToAncestorLegend, nodeCount); + builder.Allocate(ref root.brushAncestorLegend, 0); + builder.Allocate(ref root.brushAncestors, 0); + root.minNodeIDValue = 0; + root.maxNodeIDValue = nodeCount; + + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + /// Every node of a touching, with only the brushes counted as intersections. + static BlobAssetReference TouchingNested(int depth) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + + var intersections = builder.Allocate(ref root.brushIntersections, depth); + for (int i = 0; i < depth; i++) + { + intersections[i] = new BrushIntersection + { + nodeIndexOrder = new IndexOrder { compactNodeID = NodeID((i * 2) + 1), nodeOrder = i }, + type = IntersectionType.Intersection, + bottomUpStart = 0, + bottomUpEnd = 0 + }; + } + + var nodeCount = depth * 2; + root.BitOffset = 0; + root.BitCount = nodeCount; + var wordCount = ((root.BitCount * 2) + 31) / 32; + var bits = builder.Allocate(ref root.intersectionBits, wordCount); + for (int i = 0; i < wordCount; i++) + bits[i] = 0; + for (int id = 0; id < nodeCount; id++) + { + var bitIndex = id << 1; + bits[bitIndex >> 5] |= (uint)IntersectionType.Intersection << (bitIndex & 31); + } + + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + /// A routing table copied out of its blob: per node in routing order, its node index and its rows. + internal sealed class RoutedTable + { + public readonly System.Collections.Generic.List nodes = new(); + public readonly System.Collections.Generic.List rows = new(); + } + + /// + /// Runs CreateRoutingTableJob for the brush at of a and + /// copies out the table it wrote, or null when it wrote none. + /// + internal static RoutedTable RouteNestedBrush(int depth, int brushIndex, int rowsPerNodeCopyLimit = 0) + { + var tree = NestedTree(depth); + var touching = TouchingNested(depth); + var orders = new NativeList(1, Allocator.Persistent); + var touched = new NativeList>(depth, Allocator.Persistent); + var output = new NativeList>(depth, Allocator.Persistent); + var treeRef = new NativeReference>(tree, Allocator.Persistent); + try + { + var nodeOrder = (brushIndex - 1) / 2; + orders.Add(new IndexOrder { compactNodeID = NodeID(brushIndex), nodeOrder = nodeOrder }); + for (int i = 0; i < depth; i++) + touched.Add(touching); + output.Resize(depth, NativeArrayOptions.ClearMemory); + + new CreateRoutingTableJob + { + allUpdateBrushIndexOrders = orders, + brushesTouchedByBrushes = touched, + compactTreeRef = treeRef, + routingTableLookup = output, + rowsPerNodeCopyLimit = rowsPerNodeCopyLimit + }.Execute(0); + + if (!output[nodeOrder].IsCreated) + return null; + ref var table = ref output[nodeOrder].Value; + var nodeOfLookup = new int[table.routingLookups.Length]; + for (int i = 0; i < nodeOfLookup.Length; i++) + nodeOfLookup[i] = -1; + for (int id = 0; id < table.nodeIDToTableIndex.Length; id++) + { + var lookup = table.nodeIDToTableIndex[id]; + if (lookup >= 0) + nodeOfLookup[lookup] = id + table.nodeIDOffset; + } + var copy = new RoutedTable(); + for (int k = 0; k < table.routingLookups.Length; k++) + { + var lookup = table.routingLookups[k]; + var rows = new ushort[lookup.endIndex - lookup.startIndex][]; + for (int r = 0; r < rows.Length; r++) + { + var row = table.routingRows[lookup.startIndex + r]; + rows[r] = new ushort[CategoryRoutingRow.Length]; + for (int c = 0; c < CategoryRoutingRow.Length; c++) + rows[r][c] = row[c]; + } + copy.nodes.Add(nodeOfLookup[k]); + copy.rows.Add(rows); + } + return copy; + } + finally + { + for (int i = 0; i < output.Length; i++) + if (output[i].IsCreated) output[i].Dispose(); + output.Dispose(); + touched.Dispose(); + orders.Dispose(); + treeRef.Dispose(); + touching.Dispose(); + tree.Dispose(); + } + } + + /// + /// What the tree makes of a surface of the brush at from its category against every + /// other brush: the operation tables applied as the tree nests them, without the routing table. + /// + static int EvaluateNested(int depth, int brushIndex, int[] categoryOfNode, int branchIndex = 0) + { + var brush = branchIndex + 1; + var left = (brush == brushIndex) ? (int)CategoryIndex.SelfAligned : categoryOfNode[brush]; + if (branchIndex / 2 == depth - 1) + return left; + var right = EvaluateNested(depth, brushIndex, categoryOfNode, branchIndex + 2); + return CategoryRoutingRow.kOperationTables[((int)CSGOperationType.Subtractive * CategoryRoutingRow.OperationStride) + + (left * CategoryRoutingRow.RowStride) + right]; + } + + /// What the routing table makes of the same surface: row 0 of the first node, one step per node. + internal static int Route(RoutedTable table, int[] categoryOfNode) + { + var input = 0; + for (int k = 0; k < table.rows.Count; k++) + { + if (input >= table.rows[k].Length) + return -1 - k; // no such row: never a category, so it can only mismatch + var node = table.nodes[k]; + var category = (node >= 0 && node < categoryOfNode.Length) ? categoryOfNode[node] : (int)CategoryIndex.Outside; + input = table.rows[k][input][category]; + } + return input; + } + + /// + /// Null when the table is minimal; otherwise what is wrong with it: a destination that leads nowhere, a row nothing + /// leads to, or two alike rows of one node. + /// + internal static string WhyNotMinimal(RoutedTable table) + { + var count = table.rows.Count; + var reached = new bool[count][]; + for (int k = 0; k < count; k++) + reached[k] = new bool[table.rows[k].Length]; + if (count == 0 || table.rows[0].Length == 0) + return "the table is empty"; + reached[0][0] = true; + for (int k = 0; k < count; k++) + { + for (int r = 0; r < table.rows[k].Length; r++) + { + if (!reached[k][r]) + return $"node {k} (brush {table.nodes[k]}): row {r} of {table.rows[k].Length} is never reached"; + for (int s = 0; s < r; s++) + { + if (System.Linq.Enumerable.SequenceEqual(table.rows[k][s], table.rows[k][r])) + return $"node {k} (brush {table.nodes[k]}): rows {s} and {r} of {table.rows[k].Length} are alike"; + } + if (k + 1 == count) + continue; + foreach (var destination in table.rows[k][r]) + { + if (destination >= table.rows[k + 1].Length) + return $"node {k} (brush {table.nodes[k]}): row {r} leads to row {destination} of a node with {table.rows[k + 1].Length}"; + reached[k + 1][destination] = true; + } + } + } + foreach (var destination in table.rows[count - 1][0]) + { + if (destination >= CategoryRoutingRow.Length) + return $"the last node ends in {destination}, which is not a category"; + } + return null; + } + + /// + /// Null when the table routes every combination of categories as the tree evaluates it, otherwise the first it gets + /// wrong. Exhaustive up to 6^5 combinations, a fixed sample of 4096 beyond that. + /// + internal static string WhereRoutingDiffersFromTheTree(int depth, int brushIndex, RoutedTable table) + { + var others = new System.Collections.Generic.List(); + for (int i = 0; i < depth; i++) + if ((i * 2) + 1 != brushIndex) + others.Add((i * 2) + 1); + + var categoryOfNode = new int[depth * 2]; + var combinations = System.Math.Pow(CategoryRoutingRow.Length, others.Count); + var exhaustive = combinations <= 7776; + var random = new System.Random(depth * 1000 + brushIndex); + var total = exhaustive ? (int)combinations : 4096; + for (int n = 0; n < total; n++) + { + var rest = n; + foreach (var other in others) + { + if (exhaustive) + { + categoryOfNode[other] = rest % CategoryRoutingRow.Length; + rest /= CategoryRoutingRow.Length; + } else + categoryOfNode[other] = random.Next(CategoryRoutingRow.Length); + } + var expected = EvaluateNested(depth, brushIndex, categoryOfNode); + var routed = Route(table, categoryOfNode); + if (routed != expected) + { + var text = new System.Text.StringBuilder(); + foreach (var other in others) + text.Append(" brush ").Append(other).Append('=').Append((CategoryIndex)categoryOfNode[other]); + return $"routed to {routed}, the tree says {(CategoryIndex)expected}, for{text}"; + } + } + return null; + } + + [TestCase(2)] + [TestCase(4)] + [TestCase(8)] + [TestCase(16)] + [TestCase(32)] + public void ANestedChain_GivesEveryBrushAMinimalTable(int depth) + { + for (int brush = 1; brush < depth * 2; brush += 2) + { + var table = RouteNestedBrush(depth, brush); + Assert.That(table, Is.Not.Null, $"{depth} levels, brush {brush}: no routing table was written"); + var problem = WhyNotMinimal(table); + Assert.That(problem, Is.Null, $"{depth} levels, brush {brush}: {problem}"); + } + } + + [TestCase(2)] + [TestCase(4)] + [TestCase(6)] + [TestCase(8)] + [TestCase(12)] + [TestCase(16)] + [TestCase(32)] + public void ANestedChain_RoutesEveryBrushLikeTheTree(int depth) + { + for (int brush = 1; brush < depth * 2; brush += 2) + { + var table = RouteNestedBrush(depth, brush); + Assert.That(table, Is.Not.Null, $"{depth} levels, brush {brush}: no routing table was written"); + var difference = WhereRoutingDiffersFromTheTree(depth, brush, table); + Assert.That(difference, Is.Null, $"{depth} levels, brush {brush}: {difference}"); + } + } + + [Test] + public void TheJudges_CatchPlantedErrors() + { + const int depth = 4, brush = 3; + var table = RouteNestedBrush(depth, brush); + Assert.That(table, Is.Not.Null); + Assert.That(WhyNotMinimal(table), Is.Null, "the unplanted table has to pass for the plants to mean anything"); + Assert.That(WhereRoutingDiffersFromTheTree(depth, brush, table), Is.Null, "the unplanted table has to pass for the plants to mean anything"); + + // A wrong final category in a row that is reached + var last = table.rows.Count - 1; + var saved = table.rows[last][0][(int)CategoryIndex.Inside]; + table.rows[last][0][(int)CategoryIndex.Inside] = (ushort)((saved == (int)CategoryIndex.Outside) ? (int)CategoryIndex.Inside : (int)CategoryIndex.Outside); + Assert.That(WhereRoutingDiffersFromTheTree(depth, brush, table), Is.Not.Null, "a wrong final category went unnoticed"); + table.rows[last][0][(int)CategoryIndex.Inside] = saved; + + // A row duplicated in the node that has the most + var node = 0; + for (int k = 1; k < table.rows.Count; k++) + if (table.rows[k].Length > table.rows[node].Length) node = k; + var rows = new System.Collections.Generic.List(table.rows[node]) { (ushort[])table.rows[node][0].Clone() }; + var original = table.rows[node]; + table.rows[node] = rows.ToArray(); + Assert.That(WhyNotMinimal(table), Is.Not.Null, "two alike rows went unnoticed"); + table.rows[node] = original; + + // A row nothing leads to + if (table.rows.Count > 1) + { + var extra = new System.Collections.Generic.List(table.rows[last]) { new ushort[CategoryRoutingRow.Length] }; + for (int c = 0; c < CategoryRoutingRow.Length; c++) + extra[extra.Count - 1][c] = (ushort)CategoryIndex.Outside; + original = table.rows[last]; + table.rows[last] = extra.ToArray(); + var problem = WhyNotMinimal(table); + table.rows[last] = original; + Assert.That(problem, Is.Not.Null, "an unreachable row went unnoticed"); + } + } + + [Test] + public void ANodeNeedingMoreRowsThanTheLimit_IsRefusedLoudly() + { + Assert.That(RouteNestedBrush(8, 1), Is.Not.Null, "the chain has to route at the job's own maximum for the refusal to mean anything"); + UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error, new System.Text.RegularExpressions.Regex("brush 1 is nested too deeply to route")); + var table = RouteNestedBrush(8, 1, rowsPerNodeCopyLimit: 2); + Assert.That(table, Is.Null, "a table was written although the routing needed more rows than allowed"); + } + + const int kStopAtRows = 8192; + const double kStopAfterSeconds = 5.0; + + [Test, Explicit("Measurement: writes Library/ChiselMeasurements/RoutingRowsPerNode.txt")] + public void MeasureRowsPerNodeAgainstNestingDepth() + { + var lines = new System.Text.StringBuilder(); + lines.AppendLine("depth most rows for one node (any brush) most rows in one table seconds"); + foreach (var depth in new[] { 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 48, 64, 96, 128 }) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + int most = 0, mostInATable = 0; + for (int brush = 1; brush < depth * 2; brush += 2) + { + var table = RouteNestedBrush(depth, brush); + if (table == null) + continue; + var inTable = 0; + foreach (var rows in table.rows) + { + most = System.Math.Max(most, rows.Length); + inTable += rows.Length; + } + mostInATable = System.Math.Max(mostInATable, inTable); + } + watch.Stop(); + lines.AppendLine($"{depth,5} {most,34} {mostInATable,22} {watch.Elapsed.TotalSeconds,7:0.000}"); + if (most > kStopAtRows || watch.Elapsed.TotalSeconds > kStopAfterSeconds) + { + lines.AppendLine($"stopped after {depth} levels: growth is steep enough that deeper cases are not safe to run blind"); + break; + } + } + var directory = System.IO.Path.Combine("Library", "ChiselMeasurements"); + System.IO.Directory.CreateDirectory(directory); + System.IO.File.WriteAllText(System.IO.Path.Combine(directory, "RoutingRowsPerNode.txt"), lines.ToString()); + TestContext.WriteLine(lines.ToString()); + } + } +} diff --git a/Core/Tests/RoutingTableCapacityTests.cs.meta b/Core/Tests/RoutingTableCapacityTests.cs.meta new file mode 100644 index 00000000..e1ade031 --- /dev/null +++ b/Core/Tests/RoutingTableCapacityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d8d87040f8855f4fb172dd1235e7dcf \ No newline at end of file diff --git a/Core/Tests/SkippedTreeUpdateTests.cs b/Core/Tests/SkippedTreeUpdateTests.cs new file mode 100644 index 00000000..e9df6049 --- /dev/null +++ b/Core/Tests/SkippedTreeUpdateTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class SkippedTreeUpdateTests + { + static ContentsScene TwoBoxes() + { + return new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0), new float3(2)), name: "a")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1), new float3(3)), operation: CSGOperationType.Subtractive, name: "b")); + } + + // Flushes every tree that needs an update except the one given, and returns the trees that were built + static List FlushSkipping(CSGTree skipped) + { + var built = new List(); + CompactHierarchyManager.Flush((tree, meshUpdates, dependencies) => + { + dependencies.Complete(); + built.Add(tree); + if (meshUpdates.meshDataArray.Length > 0) + meshUpdates.meshDataArray.Dispose(); + return 0; + }, tree => tree == skipped); + return built; + } + + [Test] + public void ASkippedTree_IsBuiltAfterUpdateSkippedTrees() + { + using var harness = ContentsTreeHarness.Build(TwoBoxes()); + Assert.That(FlushSkipping(harness.Tree), Has.No.Member(harness.Tree)); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(harness.Tree), Is.True); + + CompactHierarchyManager.UpdateSkippedTrees(); + harness.Update(); + Assert.That(harness.Delivered, Is.True, harness.LastUpdateReport); + Assert.That(harness.Triangles, Is.Not.Empty); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(harness.Tree), Is.False); + } + + [Test] + public void ADestroyedTree_IsNoLongerSkipped() + { + CSGTree destroyed; + using (var harness = ContentsTreeHarness.Build(TwoBoxes())) + { + destroyed = harness.Tree; + FlushSkipping(destroyed); + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(destroyed), Is.True); + } + Assert.That(CompactHierarchyManager.IsTreeUpdateSkipped(destroyed), Is.False); + + using var next = ContentsTreeHarness.Build(TwoBoxes()); + Assert.DoesNotThrow(CompactHierarchyManager.UpdateSkippedTrees); + } + } +} diff --git a/Core/Tests/SkippedTreeUpdateTests.cs.meta b/Core/Tests/SkippedTreeUpdateTests.cs.meta new file mode 100644 index 00000000..1a3dc653 --- /dev/null +++ b/Core/Tests/SkippedTreeUpdateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f92d1c950c1e49de955eed6dbf98f5b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Core/Tests/TreeInputHashTests.cs b/Core/Tests/TreeInputHashTests.cs new file mode 100644 index 00000000..5477fdc3 --- /dev/null +++ b/Core/Tests/TreeInputHashTests.cs @@ -0,0 +1,169 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class TreeInputHashTests + { + static readonly SurfaceParameterIdentity kByParameter = parameter => new Hash128((uint)parameter, (uint)(parameter >> 32), 1, 2); + static readonly SurfaceParameterIdentity kAllTheSame = parameter => new Hash128(3, 4, 5, 6); + + static ContentsScene TwoBoxes() + { + return new ContentsScene() + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(0), new float3(2)), name: "a")) + .Add(ContentsSceneNode.Brush(ContentsScene.BoxPlanes(new float3(1), new float3(3)), operation: CSGOperationType.Subtractive, name: "b")); + } + + static Hash128 HashOf(ContentsTreeHarness harness) { return CompactHierarchyManager.GetTreeInputHash(harness.Tree, kByParameter); } + + [Test] + public void TheSameInput_HasTheSameHash() + { + using var first = ContentsTreeHarness.Build(TwoBoxes()); + using var second = ContentsTreeHarness.Build(TwoBoxes()); + Assert.That(HashOf(first).isValid, Is.True); + Assert.That(HashOf(second), Is.EqualTo(HashOf(first)), "the brushes' EntityIds differ, the input doesn't"); + } + + [Test] + public void ChangingAnOperation_ChangesTheHash() + { + using var harness = ContentsTreeHarness.Build(TwoBoxes()); + var before = HashOf(harness); + harness.SetOperation(harness.Scene.roots[1], CSGOperationType.Additive); + Assert.That(HashOf(harness), Is.Not.EqualTo(before)); + } + + [Test] + public void ChangingContents_ChangesTheHash() + { + using var harness = ContentsTreeHarness.Build(TwoBoxes()); + var before = HashOf(harness); + harness.SetContents(harness.Scene.roots[0], 1); + Assert.That(HashOf(harness), Is.Not.EqualTo(before)); + } + + [Test] + public void MovingABrush_ChangesTheHash() + { + using var harness = ContentsTreeHarness.Build(TwoBoxes()); + var before = HashOf(harness); + var brush = (CSGTreeBrush)harness.Tree[1]; + brush.LocalTransformation = float4x4.Translate(new float3(0.5f, 0, 0)); + Assert.That(HashOf(harness), Is.Not.EqualTo(before)); + } + + // The later brush of two overlapping ones wins, so their order is part of the input + [Test] + public void ReorderingBrushes_ChangesTheHash() + { + using var harness = ContentsTreeHarness.Build(TwoBoxes()); + var before = HashOf(harness); + harness.Move(harness.Scene.roots[1], null, 0); + Assert.That(HashOf(harness), Is.Not.EqualTo(before)); + } + + // Brushes made with other materials. In another session the same materials have other EntityIds, so the hash + // only sees the materials through the identity it is given. + [Test] + public void Materials_AreHashedByTheirIdentity() + { + var materials = new List(); + var boxes = new List(); + var instances = new List(); + var trees = new List(); + try + { + ContentsTreeHarness.PrepareForUpdate(); + for (int t = 0; t < 2; t++) + { + var material = new Material(Shader.Find("Hidden/InternalErrorShader")); + materials.Add(material); + var box = Box(material, instances); + boxes.Add(box); + trees.Add(CSGTree.Create(default(UnityEngine.EntityId), box)); + } + + Assert.That(CompactHierarchyManager.GetTreeInputHash(trees[1], kAllTheSame), + Is.EqualTo(CompactHierarchyManager.GetTreeInputHash(trees[0], kAllTheSame)), + "materials with the same identity"); + Assert.That(CompactHierarchyManager.GetTreeInputHash(trees[1], kByParameter), + Is.Not.EqualTo(CompactHierarchyManager.GetTreeInputHash(trees[0], kByParameter)), + "materials with another identity"); + } + finally + { + foreach (var box in boxes) + { + if (box.Valid) + box.Destroy(); + } + foreach (var tree in trees) + { + if (tree.Valid) + tree.Destroy(); + } + foreach (var instance in instances) + instance.Destroy(); + foreach (var material in materials) + Object.DestroyImmediate(material); + } + } + + static CSGTreeBrush Box(Material material, List instances) + { + var planes = ContentsScene.BoxPlanes(new float3(0), new float3(2)); + var surfaceArray = new ChiselSurfaceArray(); + surfaceArray.EnsureSize(planes.Length); + for (int i = 0; i < surfaceArray.surfaces.Length; i++) + surfaceArray.surfaces[i] = ChiselSurface.Create(material); + BrushMeshFactory.CreateFromPlanes(planes, new Bounds(Vector3.zero, new Vector3(16, 16, 16)), ref surfaceArray, out var brushMesh); + var instance = BrushMeshInstance.Create(brushMesh, in surfaceArray); + instances.Add(instance); + return CSGTreeBrush.Create(default(UnityEngine.EntityId), float4x4.identity, instance); + } + + static ChiselDecalInstance Decal(ulong entityID, float3 position) + { + return new ChiselDecalInstance + { + entityID = entityID, + decalToTree = float4x4.Translate(position), + settings = ChiselDecalSettings.Default, + destinationFlags = SurfaceDestinationFlags.Renderable + }; + } + + static void SetDecals(CSGTree tree, params ChiselDecalInstance[] decals) + { + using var array = new NativeArray(decals, Allocator.Temp); + tree.SetDecals(array); + } + + // The tree keeps its decals in EntityId order, which is another order in every session + [Test] + public void Decals_AreHashedWithoutTheirEntityIds() + { + using var first = ContentsTreeHarness.Build(TwoBoxes()); + using var second = ContentsTreeHarness.Build(TwoBoxes()); + SetDecals(first.Tree, Decal(1, new float3(1, 1, 0)), Decal(2, new float3(2, 1, 0))); + SetDecals(second.Tree, Decal(9, new float3(1, 1, 0)), Decal(4, new float3(2, 1, 0))); + Assert.That(HashOf(second), Is.EqualTo(HashOf(first))); + } + + [Test] + public void MovingADecal_ChangesTheHash() + { + using var harness = ContentsTreeHarness.Build(TwoBoxes()); + SetDecals(harness.Tree, Decal(1, new float3(1, 1, 0))); + var before = HashOf(harness); + SetDecals(harness.Tree, Decal(1, new float3(1, 1.5f, 0))); + Assert.That(HashOf(harness), Is.Not.EqualTo(before)); + } + } +} diff --git a/Core/Tests/TreeInputHashTests.cs.meta b/Core/Tests/TreeInputHashTests.cs.meta new file mode 100644 index 00000000..46a68e50 --- /dev/null +++ b/Core/Tests/TreeInputHashTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 05e0edb646084f04bd262769e59c6414 \ No newline at end of file diff --git a/Core/Tests/UVMatrixTests.cs b/Core/Tests/UVMatrixTests.cs new file mode 100644 index 00000000..2c42754a --- /dev/null +++ b/Core/Tests/UVMatrixTests.cs @@ -0,0 +1,96 @@ +using System.Text.RegularExpressions; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Chisel.Core.Tests +{ + // UVMatrix is the 2x4 surface-UV transform attached to every CSG polygon. + [TestFixture] + public class UVMatrixTests + { + static void AssertVector4(Vector4 expected, Vector4 actual, float epsilon = 1e-5f) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(epsilon)); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(epsilon)); + Assert.That(actual.z, Is.EqualTo(expected.z).Within(epsilon)); + Assert.That(actual.w, Is.EqualTo(expected.w).Within(epsilon)); + } + + [Test] + public void Identity_HasExpectedRows() + { + AssertVector4(new Vector4(1, 0, 0, 0), UVMatrix.Identity.U); + AssertVector4(new Vector4(0, 1, 0, 0), UVMatrix.Identity.V); + } + + [Test] + public void Centered_HasHalfOffset() + { + AssertVector4(new Vector4(1, 0, 0, 0.5f), UVMatrix.Centered.U); + AssertVector4(new Vector4(0, 1, 0, 0.5f), UVMatrix.Centered.V); + } + + [Test] + public void PlaneNormal_OfIdentity_IsForward() + { + var n = UVMatrix.Identity.PlaneNormal; + Assert.That(n.x, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(n.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(n.z, Is.EqualTo(1f).Within(1e-5f)); + } + + [Test] + public void Equality_Operators() + { + Assert.That(UVMatrix.Identity == UVMatrix.Identity, Is.True); + Assert.That(UVMatrix.Identity != UVMatrix.Centered, Is.True); + Assert.That(UVMatrix.Identity.Equals((object)UVMatrix.Identity), Is.True); + Assert.That(UVMatrix.Identity.Equals((object)UVMatrix.Centered), Is.False); + } + + [Test] + public void GetHashCode_IsStableForEqualMatrices() + { + var a = new UVMatrix(new float4(1, 0, 0, 0.5f), new float4(0, 1, 0, 0.5f)); + Assert.That(a.GetHashCode(), Is.EqualTo(UVMatrix.Centered.GetHashCode())); + } + + [Test] + public void Matrix4x4_RoundTrip_PreservesRows() + { + foreach (var original in new[] { UVMatrix.Identity, UVMatrix.Centered }) + { + Matrix4x4 m = original; // implicit ToMatrix4x4 + UVMatrix roundTrip = m; // implicit from Matrix4x4 + AssertVector4(original.U, roundTrip.U); + AssertVector4(original.V, roundTrip.V); + } + } + + [Test] + public void TRS_Translation_RoundTripsThroughDecompose() + { + var translation = new Vector2(3, 4); + var scale = new float2(1, 1); + var uv = UVMatrix.TRS(translation, new Vector3(0, 1, 0), 0f, scale); + + uv.Decompose(out var decomposedTranslation, out _, out var decomposedScale); + + Assert.That(decomposedTranslation.x, Is.EqualTo(translation.x).Within(1e-3f)); + Assert.That(decomposedTranslation.y, Is.EqualTo(translation.y).Within(1e-3f)); + Assert.That(Mathf.Abs(decomposedScale.x), Is.EqualTo(scale.x).Within(1e-3f)); + Assert.That(Mathf.Abs(decomposedScale.y), Is.EqualTo(scale.y).Within(1e-3f)); + } + + [Test] + public void Constructor_ResetsNonFiniteValuesToIdentity() + { + LogAssert.Expect(LogType.Error, new Regex("Resetting UV values")); + var uv = new UVMatrix(new float4(float.NaN, 0, 0, 0), new float4(0, 1, 0, 0)); + AssertVector4(new Vector4(1, 0, 0, 0), uv.U); + AssertVector4(new Vector4(0, 1, 0, 0), uv.V); + } + } +} diff --git a/Core/Tests/UVMatrixTests.cs.meta b/Core/Tests/UVMatrixTests.cs.meta new file mode 100644 index 00000000..2a9e5c05 --- /dev/null +++ b/Core/Tests/UVMatrixTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: baca60b21874ec943a50ada1a5e46c2f \ No newline at end of file diff --git a/Core/Tests/WeldIncidenceTests.cs b/Core/Tests/WeldIncidenceTests.cs new file mode 100644 index 00000000..26e77d81 --- /dev/null +++ b/Core/Tests/WeldIncidenceTests.cs @@ -0,0 +1,208 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; + +namespace Chisel.Core.Tests +{ + [TestFixture] + public class WeldIncidenceTests + { + static BlobAssetReference Planes(params float4[] planes) + { + using var builder = new BlobBuilder(Allocator.Temp); + ref var root = ref builder.ConstructRoot(); + var array = builder.Allocate(ref root.treeSpacePlanes, planes.Length); + for (int i = 0; i < planes.Length; i++) + array[i] = planes[i]; + return builder.CreateBlobAssetReference(Allocator.Persistent); + } + + // Planes given as n·x = d; Chisel stores n·x + w = 0 with outward normals, so w = -d + static float4 Side(double nx, double ny, double nz, double d) + { + return new float4((float)nx, (float)ny, (float)nz, (float)-d); + } + + // The trim: a 6-sided prism along z, its face at x = 59.70375 19.06 mm tall + static float4[] TrimSides() + { + return new[] + { + Side( 0, 0, 1, 72.20875000000001), + Side( 0, 0, -1, -28.89875), + Side(-1, 0, 0, -59.70375000000001), + Side(-0.11684124756739721, 0.9931506043228763, 0, -11.63311624959858), + Side(-0.37863284572050415, -0.9255469562056768, 0, -18.247920766075882), + Side( 0.923076923076923, 0.3846153846153846, 0, 53.621346153846154), + }; + } + + // The wall whose sloped underside (side 2 here) cuts the trim's face + static float4[] WallSides() + { + return new[] + { + Side( 0, 0, -1, -28.28875), + Side( 0, 0, 1, 72.81875), + Side( 0.1501760908452158, -0.9886592647310041, 0, 13.613243628319662), + Side( 0, 1, 0, -2.44), + Side(-1, 0, 0, -58.521875), + Side( 0.9917428264343938, 0.12824260686651645, 0, 58.93566400823595), + }; + } + + const float kTrimFaceX = 59.70375f; + const float kTrimBottomY = -4.7084375f; // the face's bottom edge + const float kMidZ = 50f; // mid-way along the 43 m trim, away from its end caps + + // where the wall's underside crosses the trim's face + static float3 CutPoint() + { + var underside = WallSides()[2]; + float y = -(underside.x * kTrimFaceX + underside.w) / underside.y; + return new float3(kTrimFaceX, y, kMidZ); + } + + static float3 CornerPoint() { return new float3(kTrimFaceX, kTrimBottomY, kMidZ); } + + [Test] + public void TheFixtureMatchesTheMap() + { + var cut = CutPoint(); + var corner = CornerPoint(); + Assert.That(cut.y, Is.EqualTo(-4.70047f).Within(1e-4f), "the cut sits where the wall's underside crosses the face"); + Assert.That(math.distance(cut, corner), Is.EqualTo(0.00796f).Within(1e-4f), "7.96 mm of face below the cut"); + Assert.That(math.distance(cut, corner), Is.LessThan(CSGConstants.kVertexEqualEpsilon), + "the whole point of the case: the cut is inside the weld radius of the corner"); + } + + [Test] + public void TheCutAndTheCorner_AreNotMerged() + { + var trim = Planes(TrimSides()); + var wall = Planes(WallSides()); + var filter = WeldIncidenceFilter.Create(ref trim.Value.treeSpacePlanes, 6, ref wall.Value.treeSpacePlanes, 6); + + Assert.That(filter.Allows(CutPoint(), CornerPoint()), Is.False, + "the corner is 7.9 mm off the wall underside that the cut lies on"); + Assert.That(filter.Allows(CornerPoint(), CutPoint()), Is.False, "and the test is symmetric"); + + wall.Dispose(); + trim.Dispose(); + } + + [Test] + public void ThePlainWeldMergesThem_WhichIsTheDefect() + { + using var plain = new HashedVertices(64, Allocator.Temp); + var corner = plain.AddNoResize(CornerPoint()); + Assert.That(plain.AddNoResize(CutPoint()), Is.EqualTo(corner), + "distance alone merges the cut into the corner, which is how the cut is lost"); + } + + [Test] + public void TheGatedWeld_KeepsThemApart() + { + var trim = Planes(TrimSides()); + var wall = Planes(WallSides()); + var filter = WeldIncidenceFilter.Create(ref trim.Value.treeSpacePlanes, 6, ref wall.Value.treeSpacePlanes, 6); + + using var hashed = new HashedVertices(64, Allocator.Temp); + var corner = hashed.AddNoResize(CornerPoint(), in filter); + var cut = hashed.AddNoResize(CutPoint(), in filter); + Assert.That(cut, Is.Not.EqualTo(corner), "the cut must become its own vertex"); + Assert.That(hashed.Length, Is.EqualTo(2)); + + wall.Dispose(); + trim.Dispose(); + } + + // The gate must not break what the weld is for: one point computed twice, a fraction of a millimetre apart. + [Test] + public void AVertexComputedTwice_IsStillMerged() + { + var trim = Planes(TrimSides()); + var wall = Planes(WallSides()); + var filter = WeldIncidenceFilter.Create(ref trim.Value.treeSpacePlanes, 6, ref wall.Value.treeSpacePlanes, 6); + + var cut = CutPoint(); + var again = cut + new float3(0.0002f, -0.0001f, 0.0003f); // 0.37 mm of float and conditioning noise + Assert.That(filter.Allows(cut, again), Is.True); + + using var hashed = new HashedVertices(64, Allocator.Temp); + Assert.That(hashed.AddNoResize(again, in filter), Is.EqualTo(hashed.AddNoResize(cut, in filter))); + + wall.Dispose(); + trim.Dispose(); + } + + static float4[] UnitBoxSides() + { + return new[] + { + new float4(-1, 0, 0, 0), new float4(1, 0, 0, -1), + new float4( 0, -1, 0, 0), new float4(0, 1, 0, -1), + new float4( 0, 0, -1, 0), new float4(0, 0, 1, -1), + }; + } + + [Test] + public void AMergeOffAFaceItLiesOn_IsRefused() + { + var box = Planes(UnitBoxSides()); + var filter = WeldIncidenceFilter.Create(ref box.Value.treeSpacePlanes, 6); + + var onTop = new float3(0.5f, 1f, 0.5f); + Assert.That(filter.Allows(onTop, onTop - new float3(0, 0.005f, 0)), Is.False, "5 mm below the face it lies on"); + Assert.That(filter.Allows(onTop, onTop - new float3(0, 0.0005f, 0)), Is.True, "0.5 mm is within the plane tolerance"); + + box.Dispose(); + } + + // A plane is infinite, so a point far outside a brush can sit on the plane of one of its faces by accident. + // Such a point does not belong to the brush and must not be gated by it. + [Test] + public void PointsOutsideTheBrush_AreNotGatedByIt() + { + var box = Planes(UnitBoxSides()); + var filter = WeldIncidenceFilter.Create(ref box.Value.treeSpacePlanes, 6); + + var outside = new float3(0.5f, 5f, 0.5f); + Assert.That(filter.Allows(outside, outside + new float3(0, 0.005f, 0)), Is.True); + + box.Dispose(); + } + + [Test] + public void EdgePlanesBeyondTheFaceCount_AreIgnored() + { + var withEdgePlane = new[] + { + UnitBoxSides()[0], UnitBoxSides()[1], UnitBoxSides()[2], + UnitBoxSides()[3], UnitBoxSides()[4], UnitBoxSides()[5], + new float4(math.normalize(new float3(1, 1, 0)), -0.25f), // slices through the middle + }; + var box = Planes(withEdgePlane); + var onTop = new float3(0.5f, 1f, 0.5f); + var below = onTop - new float3(0, 0.005f, 0); + + var faces = WeldIncidenceFilter.Create(ref box.Value.treeSpacePlanes, 6); + Assert.That(faces.Allows(onTop, below), Is.False, "with only the face planes the top face still gates it"); + + var everything = WeldIncidenceFilter.Create(ref box.Value.treeSpacePlanes, 7); + Assert.That(everything.Allows(onTop, below), Is.True, + "the fixture must actually exercise the case: counting the edge plane puts the point outside"); + + box.Dispose(); + } + + [Test] + public void TheDisabledFilter_AllowsEverything() + { + var filter = WeldIncidenceFilter.Disabled; + Assert.That(filter.IsEnabled, Is.False); + Assert.That(filter.Allows(CutPoint(), CornerPoint()), Is.True); + } + } +} diff --git a/Core/Tests/WeldIncidenceTests.cs.meta b/Core/Tests/WeldIncidenceTests.cs.meta new file mode 100644 index 00000000..31095748 --- /dev/null +++ b/Core/Tests/WeldIncidenceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b097c2c69a6bd6419c94cf0191366f8 \ No newline at end of file diff --git a/Core/Tests/com.chisel.core.tests.asmdef b/Core/Tests/com.chisel.core.tests.asmdef new file mode 100644 index 00000000..3214489d --- /dev/null +++ b/Core/Tests/com.chisel.core.tests.asmdef @@ -0,0 +1,31 @@ +{ + "name": "com.chisel.core.tests", + "rootNamespace": "Chisel.Core.Tests", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "com.chisel.core", + "AssetMetadataRuntime", + "com.chisel.dots.extensions", + "Unity.Burst", + "Unity.Collections", + "Unity.Entities", + "Unity.Mathematics", + "Unity.Mathematics.Extensions" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": true, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Core/Tests/com.chisel.core.tests.asmdef.meta b/Core/Tests/com.chisel.core.tests.asmdef.meta new file mode 100644 index 00000000..a51a1b28 --- /dev/null +++ b/Core/Tests/com.chisel.core.tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9c994bd3779fcd140ba728287c419b06 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests.meta b/Editor/Tests.meta new file mode 100644 index 00000000..0f715f7c --- /dev/null +++ b/Editor/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ef2725322139ec74da5b7000471f3338 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests/ChiselHotspotGroupEditTests.cs b/Editor/Tests/ChiselHotspotGroupEditTests.cs new file mode 100644 index 00000000..04e4cbd9 --- /dev/null +++ b/Editor/Tests/ChiselHotspotGroupEditTests.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using Chisel.Components; +using Chisel.Core; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEditor; +using UnityEngine; + +namespace Chisel.Editors.Tests +{ + // A hotspot edit to one surface of a group goes to every surface of the group (ChiselHotspotGroupEdit.Spread) + [TestFixture] + public class ChiselHotspotGroupEditTests + { + const string kFolder = "Assets/__ChiselHotspotGroupEditTests"; + + readonly List roots = new(); + ChiselHotspotLayout layout; + Material material; + + [SetUp] + public void SetUp() + { + if (Object.FindObjectsByType(FindObjectsInactive.Include).Length > 0) + Assert.Ignore("A Chisel model is loaded; run these in a scene without one"); + layout = ScriptableObject.CreateInstance(); + layout.size = new Vector2Int(2048, 2048); + layout.pixelsPerUnit = 256; + layout.tiles = new List + { + new() { id = 1, name = "A1", points = new[] { new Vector2Int(0, 0), new Vector2Int(512, 0), new Vector2Int(512, 512), new Vector2Int(0, 512) } }, + new() { id = 2, name = "B1", points = new[] { new Vector2Int(1024, 0), new Vector2Int(2048, 0), new Vector2Int(2048, 512), new Vector2Int(1024, 512) } }, + }; + if (!AssetDatabase.IsValidFolder(kFolder)) + AssetDatabase.CreateFolder("Assets", kFolder.Substring("Assets/".Length)); + material = new Material(ChiselProjectSettings.DefaultWallMaterial); + AssetDatabase.CreateAsset(material, kFolder + "/hotspot.mat"); + material.AddMetadataOfType().hotspotLayout = layout; + } + + [TearDown] + public void TearDown() + { + foreach (var root in roots) + if (root) Object.DestroyImmediate(root); + roots.Clear(); + Update(); + AssetDatabase.DeleteAsset(kFolder); + if (layout) + Object.DestroyImmediate(layout); + } + + static void Update() + { + for (int i = 0; i < 3; i++) + { + ChiselNodeHierarchyManager.Update(); + ChiselModelManager.Instance.UpdateModels(); + } + } + + ChiselModelComponent Model() + { + var root = new GameObject("hotspot group edit test"); + roots.Add(root); + return root.AddComponent(); + } + + ChiselBoxComponent Box(ChiselModelComponent model, float3 min, float3 max) + { + var go = new GameObject("box"); + go.transform.SetParent(model.transform, false); + var box = go.AddComponent(); + if (math.any(min > box.Max)) { box.Max = max; box.Min = min; } + else { box.Min = min; box.Max = max; } + Update(); + foreach (var surface in box.SurfaceDefinition.surfaces) + surface.SetMaterial(material); + box.OnValidate(); + Update(); + return box; + } + + // The face of a generator's brush toward -z + static SurfaceReference FrontOf(ChiselGeneratorComponent generator) + { + var brush = (CSGTreeBrush)generator.TopTreeNode; + var blob = BrushMeshManager.GetBrushMeshBlob(brush.BrushMesh); + ref var mesh = ref blob.Value; + var best = -1; var bestZ = float.PositiveInfinity; + for (int p = 0; p < mesh.polygons.Length; p++) + { + var z = 0f; + for (int e = 0; e < mesh.polygons[p].edgeCount; e++) + z += mesh.localVertices[mesh.halfEdges[mesh.polygons[p].firstEdge + e].vertexIndex].z; + z /= mesh.polygons[p].edgeCount; + if (z < bestZ) { bestZ = z; best = p; } + } + return new SurfaceReference(generator, mesh.polygons[best].descriptionIndex, brush, best); + } + + static void SetSeed(SurfaceReference surface, uint seed) + { + var details = surface.SurfaceDetails; + details.hotspot.seed = seed; + surface.SurfaceDetails = details; + } + + // Two boxes side by side, their fronts one group; a third one apart + (SurfaceReference left, SurfaceReference right, SurfaceReference apart) Fronts() + { + var model = Model(); + var left = Box(model, new float3(-2, 0, -0.125f), new float3(0, 2, 0.125f)); + var right = Box(model, new float3(0, 0, -0.125f), new float3(2, 2, 0.125f)); + var apart = Box(model, new float3(10, 0, -0.125f), new float3(12, 2, 0.125f)); + Update(); + return (FrontOf(left), FrontOf(right), FrontOf(apart)); + } + + [Test] + public void AnEditToOneSurfaceOfAGroup_GoesToTheOthers_NotToASurfaceApart() + { + var (left, right, apart) = Fronts(); + var apartSeed = apart.SurfaceDetails.hotspot.seed; + SetSeed(left, 1234); + Assert.That(ChiselHotspotGroupEdit.Spread(new[] { left }), Is.True, "something changed"); + Assert.That(right.SurfaceDetails.hotspot.seed, Is.EqualTo(1234u), "the other surface of the group"); + Assert.That(apart.SurfaceDetails.hotspot.seed, Is.EqualTo(apartSeed), "the surface apart"); + } + + [Test] + public void SpreadingWhatTheGroupHasAlready_ChangesNothing() + { + var (left, right, _) = Fronts(); + SetSeed(left, 1234); + ChiselHotspotGroupEdit.Spread(new[] { left }); + Assert.That(ChiselHotspotGroupEdit.Spread(new[] { left }), Is.False); + Assert.That(ChiselHotspotGroupEdit.Spread(new[] { right }), Is.False, "from the other one either"); + } + + [Test] + public void ASpreadEdit_IsUndone() + { + var (left, right, _) = Fronts(); + var rightSeed = right.SurfaceDetails.hotspot.seed; + SetSeed(left, 1234); + Undo.IncrementCurrentGroup(); + Assert.That(ChiselHotspotGroupEdit.Spread(new[] { left }, "Spread Hotspot"), Is.True); + Assert.That(right.SurfaceDetails.hotspot.seed, Is.EqualTo(1234u)); + Undo.PerformUndo(); + Assert.That(right.SurfaceDetails.hotspot.seed, Is.EqualTo(rightSeed), "the other surface has its own seed back"); + } + } +} diff --git a/Editor/Tests/ChiselHotspotGroupEditTests.cs.meta b/Editor/Tests/ChiselHotspotGroupEditTests.cs.meta new file mode 100644 index 00000000..b9727e1b --- /dev/null +++ b/Editor/Tests/ChiselHotspotGroupEditTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 27cfc77e064d5fa408b006ce969d00ce \ No newline at end of file diff --git a/Editor/Tests/com.chisel.unity.editor.tests.asmdef b/Editor/Tests/com.chisel.unity.editor.tests.asmdef new file mode 100644 index 00000000..f07c9d91 --- /dev/null +++ b/Editor/Tests/com.chisel.unity.editor.tests.asmdef @@ -0,0 +1,31 @@ +{ + "name": "com.chisel.unity.editor.tests", + "rootNamespace": "Chisel.Editors.Tests", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "com.chisel.unity.editor", + "com.chisel.unity.components", + "com.chisel.core", + "Unity.Collections", + "Unity.Entities", + "Unity.Mathematics", + "AssetMetadataRuntime", + "AssetMetadataEditor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Editor/Tests/com.chisel.unity.editor.tests.asmdef.meta b/Editor/Tests/com.chisel.unity.editor.tests.asmdef.meta new file mode 100644 index 00000000..80cdaa50 --- /dev/null +++ b/Editor/Tests/com.chisel.unity.editor.tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cbb71544c67240d4881174d760b3e476 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: