diff --git a/core/include/cubos/core/ecs/query/filter.hpp b/core/include/cubos/core/ecs/query/filter.hpp index 37175b67e..1c373e6f4 100644 --- a/core/include/cubos/core/ecs/query/filter.hpp +++ b/core/include/cubos/core/ecs/query/filter.hpp @@ -71,6 +71,9 @@ namespace cubos::core::ecs /// @brief Relation data type. DataTypeId dataType; + /// @brief Whether the link is symmetric. + bool isSymmetric; + /// @brief From target index. int fromTarget; @@ -80,8 +83,23 @@ namespace cubos::core::ecs /// @brief Tables which match the link, found through calls to @ref update(). std::vector tables; + /// @brief Tables which match the reverse link, found through calls to @ref update(). + /// + /// Only filled if @ref isSymmetric is true. + std::vector reverseTables; + + /// @brief Whether each of the tables in @ref reverseTables is already in @ref tables. + /// + /// Only filled if @ref isSymmetric is true. + std::vector reverseTablesSeen; + /// @brief How many tables have already been seen through @ref SparseRelationTableRegistry::collect. std::size_t seenCount{0}; + + /// @brief Gets the nth table in the link, as if the reverseTables vector was appended to the end of the + /// tables vector. + /// @param index Table index. + SparseRelationTableId table(std::size_t index) const; }; World& mWorld; diff --git a/core/include/cubos/core/ecs/table/sparse_relation/registry.hpp b/core/include/cubos/core/ecs/table/sparse_relation/registry.hpp index cbf1c94e8..65e7646fd 100644 --- a/core/include/cubos/core/ecs/table/sparse_relation/registry.hpp +++ b/core/include/cubos/core/ecs/table/sparse_relation/registry.hpp @@ -93,19 +93,15 @@ namespace cubos::core::ecs /// @param index Entity index. void erase(ArchetypeId archetype, uint32_t index); - /// @brief Collects new tables which match the given filter. - /// @param[out] tables Vector to insert the table identifiers into. + /// @brief Calls the given function for each new table. /// @param counter Counter previously returned by this function. Zero should be used for the first call. - /// @param filter Function which receives a table identifier and returns a boolean. + /// @param func Function which receives a table identifier. /// @return Counter to be passed to this function in a future call. - std::size_t collect(std::vector& tables, std::size_t counter, auto filter) + std::size_t forEach(std::size_t counter, auto func) { for (; counter < mIds.size(); ++counter) { - if (filter(mIds[counter])) - { - tables.emplace_back(mIds[counter]); - } + func(mIds[counter]); } return counter; diff --git a/core/src/cubos/core/ecs/query/filter.cpp b/core/src/cubos/core/ecs/query/filter.cpp index e6516f113..cd58b265a 100644 --- a/core/src/cubos/core/ecs/query/filter.cpp +++ b/core/src/cubos/core/ecs/query/filter.cpp @@ -56,6 +56,7 @@ QueryFilter::QueryFilter(World& world, const std::vector& terms) CUBOS_ASSERT(mLinkCount < MaxLinkCount, "Currently only {} links are supported", MaxLinkCount); mTermCursors.emplace_back(mTargetCount + mLinkCount); mLinks[mLinkCount].dataType = term.type; + mLinks[mLinkCount].isSymmetric = world.types().isSymmetricRelation(term.type); mLinks[mLinkCount].fromTarget = term.relation.fromTarget; mLinks[mLinkCount].toTarget = term.relation.toTarget; ++mLinkCount; @@ -145,40 +146,83 @@ void QueryFilter::update() auto& link = mLinks[linkIndex]; // Collect all tables which match any of the target archetypes. - link.seenCount = - mWorld.tables().sparseRelation().collect(link.tables, link.seenCount, [&](SparseRelationTableId id) { - if (id.dataType != link.dataType) + link.seenCount = mWorld.tables().sparseRelation().forEach(link.seenCount, [&](SparseRelationTableId id) { + if (id.dataType != link.dataType) + { + return; + } + + // The from archetype must be one of the from target archetypes. + // If the link is symmetric, we also check if the to archetype is one of the to from archetypes. + bool normalCandidate = false; + bool reverseCandidate = false; + for (const auto& archetype : mTargets[link.fromTarget].archetypes) + { + if (id.from == archetype) { - return false; - } + normalCandidate = true; - // The from archetype must be one of the from target archetypes. - bool found = false; - for (const auto& archetype : mTargets[link.fromTarget].archetypes) + if (!link.isSymmetric || reverseCandidate) + { + break; + } + } + else if (link.isSymmetric && id.to == archetype) { - if (id.from == archetype) + reverseCandidate = true; + + if (normalCandidate) { - found = true; break; } } + } + + // Early exit if no candidate was found. + if (!normalCandidate && !reverseCandidate) + { + return; + } - if (!found) + // The to archetype must be one of the to target archetypes. + // If the link is symmetric, we also check if the from archetype is one of the to target archetypes. + bool normalFound = !normalCandidate; + bool reverseFound = !reverseCandidate; + for (const auto& archetype : mTargets[link.toTarget].archetypes) + { + if (id.to == archetype && !normalFound) { - return false; - } + normalFound = true; + link.tables.emplace_back(id); + + if (reverseFound) + { + if (!link.reverseTablesSeen.empty()) + { + link.reverseTablesSeen.back() = true; + } - // The to archetype must be one of the to target archetypes. - for (const auto& archetype : mTargets[link.toTarget].archetypes) + return; + } + } + else if (id.from == archetype && !reverseFound) { - if (id.to == archetype) + reverseFound = true; + link.reverseTables.emplace_back(id); + link.reverseTablesSeen.emplace_back(normalCandidate && normalFound); + + if (normalFound) { - return true; + return; } } + } + }); - return false; - }); + if (!link.isSymmetric) + { + CUBOS_ASSERT(link.reverseTables.empty()); + } } } @@ -192,6 +236,16 @@ int QueryFilter::targetCount() const return mTargetCount; } +auto QueryFilter::Link::table(std::size_t index) const -> SparseRelationTableId +{ + if (index < tables.size()) + { + return tables[index]; + } + + return reverseTables[index - tables.size()]; +} + QueryFilter::View::View(QueryFilter& filter) : mFilter{filter} { @@ -264,14 +318,7 @@ bool QueryFilter::View::Iterator::operator==(const Iterator& other) const auto QueryFilter::View::Iterator::operator*() const -> const Match& { - if (mView.mFilter.mLinkCount > 0) - { - CUBOS_ASSERT(mIndex < mView.mFilter.mLinks[0].tables.size(), "Iterator out of bounds"); - } - else - { - CUBOS_ASSERT(mIndex < mView.mFilter.mTargets[0].archetypes.size(), "Iterator out of bounds"); - } + CUBOS_ASSERT(this->valid(), "Iterator out of bounds"); auto& world = mView.mFilter.mWorld; @@ -385,11 +432,17 @@ void QueryFilter::View::Iterator::advance() // We have one link and two targets. auto& link = filter.mLinks[0]; + if (link.tables.empty() && link.reverseTables.empty()) + { + // If no table matches the query, we can't advance. + return; + } + // Check if any of the targets is pinned. if (mView.mPins[link.fromTarget].isNull() && mView.mPins[link.toTarget].isNull()) { // No pins, just advance to the next cached table. - if (mIndex == link.tables.size()) + if (mIndex == link.tables.size() + link.reverseTables.size()) { mIndex = 0; // Wrap around if we reached the end. } @@ -400,8 +453,9 @@ void QueryFilter::View::Iterator::advance() // Find the next cached table where our cursor is in bounds, i.e., where there are still rows to iterate // over. If the cursor row is still in bounds of the current table, we stay in it. - while (mIndex < link.tables.size() && - mCursorRows[filter.mTargetCount] >= world.tables().sparseRelation().at(link.tables[mIndex]).size()) + while (mIndex < link.tables.size() + link.reverseTables.size() && + (mCursorRows[filter.mTargetCount] >= world.tables().sparseRelation().at(link.table(mIndex)).size() || + (mIndex >= link.tables.size() && link.reverseTablesSeen[mIndex - link.tables.size()]))) { ++mIndex; mCursorRows[filter.mTargetCount] = 0; @@ -412,30 +466,51 @@ void QueryFilter::View::Iterator::advance() auto toEntity = mView.mPins[link.toTarget]; // The to target is pinned. - if (mIndex == link.tables.size()) + if (mIndex == link.tables.size() + link.reverseTables.size()) { mIndex = 0; // Wrap around if we reached the end. - auto& table = world.tables().sparseRelation().at(link.tables[mIndex]); - mCursorRows[filter.mTargetCount] = table.firstTo(toEntity.index); + auto& table = world.tables().sparseRelation().at(link.table(mIndex)); + if (mIndex < link.tables.size()) + { + mCursorRows[filter.mTargetCount] = table.firstTo(toEntity.index); + } + else + { + mCursorRows[filter.mTargetCount] = table.firstFrom(toEntity.index); + } } else { // Find the next row in the relation table which matches the pinned entity. - auto& table = world.tables().sparseRelation().at(link.tables[mIndex]); - mCursorRows[filter.mTargetCount] = table.nextTo(mCursorRows[filter.mTargetCount]); + auto& table = world.tables().sparseRelation().at(link.table(mIndex)); + if (mIndex < link.tables.size()) + { + mCursorRows[filter.mTargetCount] = table.nextTo(mCursorRows[filter.mTargetCount]); + } + else + { + mCursorRows[filter.mTargetCount] = table.nextFrom(mCursorRows[filter.mTargetCount]); + } } // Advance to the next cached table as long as the cursor is out of bounds. - while (mIndex < link.tables.size() && - mCursorRows[filter.mTargetCount] >= world.tables().sparseRelation().at(link.tables[mIndex]).size()) + while (mIndex < link.tables.size() + link.reverseTables.size() && + mCursorRows[filter.mTargetCount] >= world.tables().sparseRelation().at(link.table(mIndex)).size()) { ++mIndex; - if (mIndex < link.tables.size()) + if (mIndex < link.tables.size() + link.reverseTables.size()) { // Jump to the first row which matches the pinned entity. - auto& table = world.tables().sparseRelation().at(link.tables[mIndex]); - mCursorRows[filter.mTargetCount] = table.firstTo(toEntity.index); + auto& table = world.tables().sparseRelation().at(link.table(mIndex)); + if (mIndex < link.tables.size()) + { + mCursorRows[filter.mTargetCount] = table.firstTo(toEntity.index); + } + else + { + mCursorRows[filter.mTargetCount] = table.firstFrom(toEntity.index); + } } } } @@ -444,39 +519,60 @@ void QueryFilter::View::Iterator::advance() auto fromEntity = mView.mPins[link.fromTarget]; // The from target is pinned. - if (mIndex == link.tables.size()) + if (mIndex == link.tables.size() + link.reverseTables.size()) { mIndex = 0; // Wrap around if we reached the end. - auto& table = world.tables().sparseRelation().at(link.tables[mIndex]); - mCursorRows[filter.mTargetCount] = table.firstFrom(fromEntity.index); + auto& table = world.tables().sparseRelation().at(link.table(mIndex)); + if (mIndex < link.tables.size()) + { + mCursorRows[filter.mTargetCount] = table.firstFrom(fromEntity.index); + } + else + { + mCursorRows[filter.mTargetCount] = table.firstTo(fromEntity.index); + } } else { // Find the next row in the relation table which matches the pinned entity. - auto& table = world.tables().sparseRelation().at(link.tables[mIndex]); - mCursorRows[filter.mTargetCount] = table.nextFrom(mCursorRows[filter.mTargetCount]); + auto& table = world.tables().sparseRelation().at(link.table(mIndex)); + if (mIndex < link.tables.size()) + { + mCursorRows[filter.mTargetCount] = table.nextFrom(mCursorRows[filter.mTargetCount]); + } + else + { + mCursorRows[filter.mTargetCount] = table.nextTo(mCursorRows[filter.mTargetCount]); + } } // Advance to the next cached table as long as the cursor is out of bounds. - while (mIndex < link.tables.size() && - mCursorRows[filter.mTargetCount] >= world.tables().sparseRelation().at(link.tables[mIndex]).size()) + while (mIndex < link.tables.size() + link.reverseTables.size() && + mCursorRows[filter.mTargetCount] >= world.tables().sparseRelation().at(link.table(mIndex)).size()) { ++mIndex; - if (mIndex < link.tables.size()) + if (mIndex < link.tables.size() + link.reverseTables.size()) { // Jump to the first row which matches the pinned entity. - auto& table = world.tables().sparseRelation().at(link.tables[mIndex]); - mCursorRows[filter.mTargetCount] = table.firstFrom(fromEntity.index); + auto& table = world.tables().sparseRelation().at(link.table(mIndex)); + if (mIndex < link.tables.size()) + { + mCursorRows[filter.mTargetCount] = table.firstFrom(fromEntity.index); + } + else + { + mCursorRows[filter.mTargetCount] = table.firstTo(fromEntity.index); + } } } } else { // Both targets are pinned, so we either move the iterator to the pinned entities or to the end. - if (mIndex != link.tables.size()) + if (mIndex != link.tables.size() + link.reverseTables.size()) { - mIndex = link.tables.size(); + mIndex = link.tables.size() + link.reverseTables.size(); } else { @@ -489,19 +585,39 @@ void QueryFilter::View::Iterator::advance() // Find the index of the table which matches the pinned entities. mIndex = 0; while (mIndex < link.tables.size() && - (link.tables[mIndex].from != fromArchetype || link.tables[mIndex].to != toArchetype)) + (link.table(mIndex).from != fromArchetype || link.table(mIndex).to != toArchetype)) { ++mIndex; } - if (mIndex < link.tables.size()) + if (mIndex == link.tables.size()) + { + // Try the reverse tables. + while (mIndex < link.tables.size() + link.reverseTables.size() && + (link.table(mIndex).from != toArchetype || link.table(mIndex).to != fromArchetype)) + { + ++mIndex; + } + } + + if (mIndex < link.tables.size() + link.reverseTables.size()) { // Find the row of the pinned entities in the table. - auto& table = world.tables().sparseRelation().at(link.tables[mIndex]); - mCursorRows[filter.mTargetCount] = table.row(fromEntity.index, toEntity.index); + auto& table = world.tables().sparseRelation().at(link.table(mIndex)); + + if (mIndex < link.tables.size()) + { + mCursorRows[filter.mTargetCount] = table.row(fromEntity.index, toEntity.index); + } + else + { + mCursorRows[filter.mTargetCount] = table.row(toEntity.index, fromEntity.index); + } + if (mCursorRows[filter.mTargetCount] == table.size()) { - mIndex = link.tables.size(); // Turns out the entities are not related after all. + // Turns out the entities are not related after all. + mIndex = link.tables.size() + link.reverseTables.size(); } } } @@ -510,20 +626,38 @@ void QueryFilter::View::Iterator::advance() // Update the target archetypes. if (mIndex < link.tables.size()) { - mTargetArchetypes[link.fromTarget] = link.tables[mIndex].from; - mTargetArchetypes[link.toTarget] = link.tables[mIndex].to; + mTargetArchetypes[link.fromTarget] = link.table(mIndex).from; + mTargetArchetypes[link.toTarget] = link.table(mIndex).to; + + // Get the entity indices of the current row. + uint32_t fromIndex = UINT32_MAX; + uint32_t toIndex = UINT32_MAX; + world.tables() + .sparseRelation() + .at(link.table(mIndex)) + .indices(mCursorRows[filter.mTargetCount], fromIndex, toIndex); + + // Get the rows of the entities in their respective dense tables. + mCursorRows[link.fromTarget] = world.tables().dense().at(link.table(mIndex).from).row(fromIndex); + mCursorRows[link.toTarget] = world.tables().dense().at(link.table(mIndex).to).row(toIndex); + } + else if (mIndex < link.tables.size() + link.reverseTables.size()) + { + // We're iterating over the reverse tables. + mTargetArchetypes[link.fromTarget] = link.table(mIndex).to; + mTargetArchetypes[link.toTarget] = link.table(mIndex).from; // Get the entity indices of the current row. uint32_t fromIndex = UINT32_MAX; uint32_t toIndex = UINT32_MAX; world.tables() .sparseRelation() - .at(link.tables[mIndex]) + .at(link.table(mIndex)) .indices(mCursorRows[filter.mTargetCount], fromIndex, toIndex); // Get the rows of the entities in their respective dense tables. - mCursorRows[link.fromTarget] = world.tables().dense().at(link.tables[mIndex].from).row(fromIndex); - mCursorRows[link.toTarget] = world.tables().dense().at(link.tables[mIndex].to).row(toIndex); + mCursorRows[link.fromTarget] = world.tables().dense().at(link.table(mIndex).to).row(toIndex); + mCursorRows[link.toTarget] = world.tables().dense().at(link.table(mIndex).from).row(fromIndex); } else { @@ -541,5 +675,6 @@ std::size_t QueryFilter::View::Iterator::endIndex() const return mView.mFilter.mTargets[0].archetypes.size(); } - return mView.mFilter.mLinks[0].tables.size(); + auto& link = mView.mFilter.mLinks[0]; + return link.tables.size() + link.reverseTables.size(); } diff --git a/core/tests/ecs/query/filter.cpp b/core/tests/ecs/query/filter.cpp index 7677d8476..f15598ddc 100644 --- a/core/tests/ecs/query/filter.cpp +++ b/core/tests/ecs/query/filter.cpp @@ -17,6 +17,7 @@ TEST_CASE("ecs::QueryFilter") auto integerComponent = world.types().id(reflect()); auto parentComponent = world.types().id(reflect()); auto emptyRelation = world.types().id(reflect()); + auto symmetricRelation = world.types().id(reflect()); // Create some entities. auto e1 = world.create(); @@ -33,6 +34,9 @@ TEST_CASE("ecs::QueryFilter") world.relate(e2I, e1, EmptyRelation{}); world.relate(e4P, e3I, EmptyRelation{}); + world.relate(e1, e1, SymmetricRelation{}); + world.relate(e4P, e2I, SymmetricRelation{}); + auto a = ArchetypeId::Empty; auto aI = world.archetypeGraph().with(a, ColumnId::make(integerComponent)); auto aP = world.archetypeGraph().with(a, ColumnId::make(parentComponent)); @@ -207,7 +211,7 @@ TEST_CASE("ecs::QueryFilter") } } - SUBCASE("with two targets and a single relation") + SUBCASE("with two targets and a single non-symmetric relation") { SUBCASE("unfiltered relation") { @@ -821,5 +825,343 @@ TEST_CASE("ecs::QueryFilter") } } } + + SUBCASE("with two targets and a single symmetric relation") + { + SUBCASE("unfiltered relation") + { + QueryFilter filter{world, {QueryTerm::makeRelation(symmetricRelation, 0, 1)}}; + + SUBCASE("no pins") + { + auto view = filter.view(); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it->entities[0] == e2I); + REQUIRE(it->entities[1] == e4P); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("first target pinned") + { + SUBCASE("on e1") + { + auto view = filter.view().pin(0, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e2I") + { + auto view = filter.view().pin(0, e2I); + auto it = view.begin(); + REQUIRE(it->entities[0] == e2I); + REQUIRE(it->entities[1] == e4P); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e4P") + { + auto view = filter.view().pin(0, e4P); + auto it = view.begin(); + REQUIRE(it->entities[0] == e4P); + REQUIRE(it->entities[1] == e2I); + ++it; + REQUIRE(it == view.end()); + } + } + + SUBCASE("second target pinned") + { + SUBCASE("on e1") + { + auto view = filter.view().pin(1, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e2I") + { + auto view = filter.view().pin(1, e2I); + auto it = view.begin(); + REQUIRE(it->entities[0] == e4P); + REQUIRE(it->entities[1] == e2I); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e4P") + { + auto view = filter.view().pin(1, e4P); + auto it = view.begin(); + REQUIRE(it->entities[0] == e2I); + REQUIRE(it->entities[1] == e4P); + ++it; + REQUIRE(it == view.end()); + } + } + + SUBCASE("both targets") + { + SUBCASE("on e1 and e1") + { + auto view = filter.view().pin(0, e1).pin(1, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e2I and e4P") + { + auto view = filter.view().pin(0, e2I).pin(1, e4P); + auto it = view.begin(); + REQUIRE(it->entities[0] == e2I); + REQUIRE(it->entities[1] == e4P); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e4P and e2I") + { + auto view = filter.view().pin(0, e4P).pin(1, e2I); + auto it = view.begin(); + REQUIRE(it->entities[0] == e4P); + REQUIRE(it->entities[1] == e2I); + ++it; + REQUIRE(it == view.end()); + } + } + } + + SUBCASE("relation without integers on the first term") + { + QueryFilter filter{world, + { + QueryTerm::makeWithoutComponent(integerComponent, 0), + QueryTerm::makeRelation(symmetricRelation, 0, 1), + }}; + + SUBCASE("no pins") + { + auto view = filter.view(); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it->entities[0] == e4P); + REQUIRE(it->entities[1] == e2I); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("first target pinned") + { + SUBCASE("on e1") + { + auto view = filter.view().pin(0, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e2I") + { + auto view = filter.view().pin(0, e2I); + REQUIRE(view.begin() == view.end()); + } + + SUBCASE("on e4P") + { + auto view = filter.view().pin(0, e4P); + auto it = view.begin(); + REQUIRE(it->entities[0] == e4P); + REQUIRE(it->entities[1] == e2I); + ++it; + REQUIRE(it == view.end()); + } + } + + SUBCASE("second target pinned") + { + SUBCASE("on e1") + { + auto view = filter.view().pin(1, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e2I") + { + auto view = filter.view().pin(1, e2I); + auto it = view.begin(); + REQUIRE(it->entities[0] == e4P); + REQUIRE(it->entities[1] == e2I); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e4P") + { + auto view = filter.view().pin(1, e4P); + REQUIRE(view.begin() == view.end()); + } + } + + SUBCASE("both targets pinned") + { + SUBCASE("e1 and e1") + { + auto view = filter.view().pin(0, e1).pin(1, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("e2I and e4P") + { + auto view = filter.view().pin(0, e2I).pin(1, e4P); + REQUIRE(view.begin() == view.end()); + } + + SUBCASE("e4P and e2I") + { + auto view = filter.view().pin(0, e4P).pin(1, e2I); + auto it = view.begin(); + REQUIRE(it->entities[0] == e4P); + REQUIRE(it->entities[1] == e2I); + ++it; + REQUIRE(it == view.end()); + } + } + } + + SUBCASE("relation without integers on the second term") + { + QueryFilter filter{world, + { + QueryTerm::makeRelation(symmetricRelation, 0, 1), + QueryTerm::makeWithoutComponent(integerComponent, 1), + }}; + + SUBCASE("no pins") + { + auto view = filter.view(); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it->entities[0] == e2I); + REQUIRE(it->entities[1] == e4P); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("first target pinned") + { + SUBCASE("on e1") + { + auto view = filter.view().pin(0, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e2I") + { + auto view = filter.view().pin(0, e2I); + auto it = view.begin(); + REQUIRE(it->entities[0] == e2I); + REQUIRE(it->entities[1] == e4P); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e4P") + { + auto view = filter.view().pin(0, e4P); + REQUIRE(view.begin() == view.end()); + } + } + + SUBCASE("second target pinned") + { + SUBCASE("on e1") + { + auto view = filter.view().pin(1, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("on e2I") + { + auto view = filter.view().pin(1, e2I); + REQUIRE(view.begin() == view.end()); + } + + SUBCASE("on e4P") + { + auto view = filter.view().pin(1, e4P); + auto it = view.begin(); + REQUIRE(it->entities[0] == e2I); + REQUIRE(it->entities[1] == e4P); + ++it; + REQUIRE(it == view.end()); + } + } + + SUBCASE("both targets pinned") + { + SUBCASE("e1 and e1") + { + auto view = filter.view().pin(0, e1).pin(1, e1); + auto it = view.begin(); + REQUIRE(it->entities[0] == e1); + REQUIRE(it->entities[1] == e1); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("e2I and e4P") + { + auto view = filter.view().pin(0, e2I).pin(1, e4P); + auto it = view.begin(); + REQUIRE(it->entities[0] == e2I); + REQUIRE(it->entities[1] == e4P); + ++it; + REQUIRE(it == view.end()); + } + + SUBCASE("e4P and e2I") + { + auto view = filter.view().pin(0, e4P).pin(1, e2I); + REQUIRE(view.begin() == view.end()); + } + } + } + } } // NOLINTEND(readability-function-size)