From 90c7553ad945667c2b6b64fff0e41b6ba100f092 Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Wed, 3 Jun 2026 04:19:49 +0900 Subject: [PATCH 1/3] [Domain] flag walls and obstacles blocking connection passages --- CMakeLists.txt | 1 + src/application/LayoutReviewWidget.cpp | 1 + src/application/ProjectPersistence.cpp | 1 + src/domain/ImportIssue.cpp | 2 + src/domain/ImportIssue.h | 1 + src/domain/ImportValidationService.cpp | 71 ++++++++++++++++ tests/ImportValidationServiceTests.cpp | 108 +++++++++++++++++++++++++ 7 files changed, 185 insertions(+) create mode 100644 tests/ImportValidationServiceTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c9e80d6..b6775a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -159,6 +159,7 @@ if (BUILD_TESTING) tests/EcsCoreTests.cpp tests/ImportContractsTests.cpp tests/DxfImportServiceTests.cpp + tests/ImportValidationServiceTests.cpp tests/DemoFixtureServiceTests.cpp tests/FacilityLayoutBuilderTests.cpp tests/WorldQueryTests.cpp diff --git a/src/application/LayoutReviewWidget.cpp b/src/application/LayoutReviewWidget.cpp index 7e5ce8d..da91f22 100644 --- a/src/application/LayoutReviewWidget.cpp +++ b/src/application/LayoutReviewWidget.cpp @@ -126,6 +126,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) { case ImportIssueCode::DisconnectedWalkableArea: case ImportIssueCode::WidthBelowMinimum: case ImportIssueCode::ConnectionSpanMisaligned: + case ImportIssueCode::ObstructedConnection: return true; default: return false; diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index c347e4b..0b0d013 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -310,6 +310,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) { case ImportIssueCode::WidthBelowMinimum: case ImportIssueCode::ConnectionSpanMisaligned: case ImportIssueCode::InvalidFloorReference: + case ImportIssueCode::ObstructedConnection: return true; default: return false; diff --git a/src/domain/ImportIssue.cpp b/src/domain/ImportIssue.cpp index 0c7fc41..5dc5dbb 100644 --- a/src/domain/ImportIssue.cpp +++ b/src/domain/ImportIssue.cpp @@ -49,6 +49,8 @@ const char* toString(ImportIssueCode code) noexcept { return "InvalidFloorReference"; case ImportIssueCode::ConnectionSpanMisaligned: return "ConnectionSpanMisaligned"; + case ImportIssueCode::ObstructedConnection: + return "ObstructedConnection"; } return "Unknown"; diff --git a/src/domain/ImportIssue.h b/src/domain/ImportIssue.h index a3cbb20..663231c 100644 --- a/src/domain/ImportIssue.h +++ b/src/domain/ImportIssue.h @@ -26,6 +26,7 @@ enum class ImportIssueCode { UnmappedElement, InvalidFloorReference, ConnectionSpanMisaligned, + ObstructedConnection, }; struct ImportIssue { diff --git a/src/domain/ImportValidationService.cpp b/src/domain/ImportValidationService.cpp index 9b1c96b..c31a70b 100644 --- a/src/domain/ImportValidationService.cpp +++ b/src/domain/ImportValidationService.cpp @@ -256,6 +256,57 @@ bool hasRouteToExit( return false; } +bool isPassageConnection(const Connection2D& connection) { + return connection.kind == ConnectionKind::Doorway + || connection.kind == ConnectionKind::Opening + || connection.kind == ConnectionKind::Exit; +} + +bool barrierSharesConnectionFloor(const Barrier2D& barrier, const Connection2D& connection) { + return barrier.floorId.empty() || connection.floorId.empty() || barrier.floorId == connection.floorId; +} + +// A blocking barrier obstructs a passage when one of its segments crosses the +// connection span through the span interior. Crossings at the span endpoints +// (where doorways legitimately meet flanking walls) and collinear/parallel +// barriers that run along a zone boundary are intentionally ignored. +bool barrierSegmentCrossesSpanInterior(const LineSegment2D& barrierSegment, const LineSegment2D& span) { + const auto spanDirection = subtract(span.end, span.start); + const auto barrierDirection = subtract(barrierSegment.end, barrierSegment.start); + const auto denominator = cross(spanDirection, barrierDirection); + if (std::abs(denominator) <= kGeometryEpsilon) { + return false; + } + + const auto delta = subtract(barrierSegment.start, span.start); + const auto spanFraction = cross(delta, barrierDirection) / denominator; + const auto barrierFraction = cross(delta, spanDirection) / denominator; + constexpr double kSpanInteriorMargin = 0.15; + return spanFraction > kSpanInteriorMargin + && spanFraction < 1.0 - kSpanInteriorMargin + && barrierFraction >= -kGeometryEpsilon + && barrierFraction <= 1.0 + kGeometryEpsilon; +} + +bool barrierObstructsConnection(const Barrier2D& barrier, const Connection2D& connection) { + if (!barrier.blocksMovement || barrier.geometry.vertices.size() < 2) { + return false; + } + + const auto& vertices = barrier.geometry.vertices; + const std::size_t segmentCount = barrier.geometry.closed ? vertices.size() : vertices.size() - 1; + for (std::size_t index = 0; index < segmentCount; ++index) { + const LineSegment2D segment{ + .start = vertices[index], + .end = vertices[(index + 1) % vertices.size()], + }; + if (barrierSegmentCrossesSpanInterior(segment, connection.centerSpan)) { + return true; + } + } + return false; +} + } // namespace std::vector ImportValidationService::validate(const FacilityLayout2D& layout) const { @@ -384,6 +435,26 @@ std::vector ImportValidationService::validate(const FacilityLayout2 } } + for (const auto& connection : layout.connections) { + if (!isPassageConnection(connection)) { + continue; + } + for (const auto& barrier : layout.barriers) { + if (!barrierSharesConnectionFloor(barrier, connection) + || !barrierObstructsConnection(barrier, connection)) { + continue; + } + issues.push_back({ + .severity = ImportIssueSeverity::Warning, + .code = ImportIssueCode::ObstructedConnection, + .message = "A wall or obstacle crosses a connection passage and may block movement through it.", + .sourceId = barrier.id, + .targetId = connection.id, + }); + break; + } + } + for (const auto& zone : layout.zones) { if (zone.kind == ZoneKind::Exit) { continue; diff --git a/tests/ImportValidationServiceTests.cpp b/tests/ImportValidationServiceTests.cpp new file mode 100644 index 0000000..15aa8e9 --- /dev/null +++ b/tests/ImportValidationServiceTests.cpp @@ -0,0 +1,108 @@ +#include +#include + +#include "TestSupport.h" + +#include "domain/FacilityLayout2D.h" +#include "domain/ImportIssue.h" +#include "domain/ImportValidationService.h" + +namespace { + +using namespace safecrowd::domain; + +// Builds a minimal but fully valid single-floor layout: one room reaching one +// exit through an aligned exit connection. validate() returns no issues for it, +// so any issue observed in a test is attributable to what that test adds. +FacilityLayout2D makeConnectedRoomAndExit() { + FacilityLayout2D layout; + layout.id = "layout"; + layout.floors.push_back({.id = "F1"}); + layout.zones.push_back({ + .id = "room", + .floorId = "F1", + .kind = ZoneKind::Room, + .area = {.outline = {{0.0, 0.0}, {10.0, 0.0}, {10.0, 8.0}, {0.0, 8.0}}}, + }); + layout.zones.push_back({ + .id = "exit", + .floorId = "F1", + .kind = ZoneKind::Exit, + .area = {.outline = {{10.0, 3.0}, {12.0, 3.0}, {12.0, 5.0}, {10.0, 5.0}}}, + }); + layout.connections.push_back({ + .id = "c1", + .floorId = "F1", + .kind = ConnectionKind::Exit, + .fromZoneId = "room", + .toZoneId = "exit", + .centerSpan = {.start = {10.0, 4.0}, .end = {11.0, 4.0}}, + }); + return layout; +} + +bool hasIssueCode(const std::vector& issues, ImportIssueCode code) { + return std::any_of(issues.begin(), issues.end(), [&](const auto& issue) { + return issue.code == code; + }); +} + +} // namespace + +SC_TEST(ImportValidationProducesNoIssuesForCleanLayout) { + const auto layout = makeConnectedRoomAndExit(); + + ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(issues.empty()); +} + +SC_TEST(ImportValidationFlagsBarrierCrossingConnectionPassage) { + auto layout = makeConnectedRoomAndExit(); + // Wall through the middle of the exit passage span (crosses at 50%). + layout.barriers.push_back({ + .id = "wall", + .floorId = "F1", + .geometry = {.vertices = {{10.5, 3.0}, {10.5, 5.0}}, .closed = false}, + .blocksMovement = true, + }); + + ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(hasIssueCode(issues, ImportIssueCode::ObstructedConnection)); + // The obstruction is reported for review but must not block simulation. + SC_EXPECT_TRUE(!hasBlockingImportIssue(issues)); +} + +SC_TEST(ImportValidationIgnoresNonBlockingBarrierOverConnection) { + auto layout = makeConnectedRoomAndExit(); + layout.barriers.push_back({ + .id = "marking", + .floorId = "F1", + .geometry = {.vertices = {{10.5, 3.0}, {10.5, 5.0}}, .closed = false}, + .blocksMovement = false, + }); + + ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(!hasIssueCode(issues, ImportIssueCode::ObstructedConnection)); +} + +SC_TEST(ImportValidationDoesNotFlagWallMeetingConnectionAtEndpoint) { + auto layout = makeConnectedRoomAndExit(); + // Boundary wall flanking the doorway: touches the span start endpoint only. + layout.barriers.push_back({ + .id = "flank", + .floorId = "F1", + .geometry = {.vertices = {{10.0, 3.0}, {10.0, 5.0}}, .closed = false}, + .blocksMovement = true, + }); + + ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(!hasIssueCode(issues, ImportIssueCode::ObstructedConnection)); +} From 8a03b71418e598500f88944afe92c511894562af Mon Sep 17 00:00:00 2001 From: Silversupplier Date: Wed, 3 Jun 2026 05:55:31 +0900 Subject: [PATCH 2/3] Cover contained obstacle passage obstructions --- src/domain/ImportValidationService.cpp | 26 ++++++++++++++++++++++++++ tests/ImportValidationServiceTests.cpp | 17 +++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/domain/ImportValidationService.cpp b/src/domain/ImportValidationService.cpp index c31a70b..17b053e 100644 --- a/src/domain/ImportValidationService.cpp +++ b/src/domain/ImportValidationService.cpp @@ -288,11 +288,37 @@ bool barrierSegmentCrossesSpanInterior(const LineSegment2D& barrierSegment, cons && barrierFraction <= 1.0 + kGeometryEpsilon; } +Point2D pointAlongSpan(const LineSegment2D& span, double fraction) { + return { + .x = span.start.x + ((span.end.x - span.start.x) * fraction), + .y = span.start.y + ((span.end.y - span.start.y) * fraction), + }; +} + +bool closedBarrierContainsSpanInterior(const Barrier2D& barrier, const LineSegment2D& span) { + if (!barrier.geometry.closed || barrier.geometry.vertices.size() < 3) { + return false; + } + + const Polygon2D barrierFootprint{.outline = barrier.geometry.vertices}; + constexpr double kInteriorSampleFractions[] = {0.25, 0.5, 0.75}; + for (const auto fraction : kInteriorSampleFractions) { + if (pointInPolygon(barrierFootprint, pointAlongSpan(span, fraction))) { + return true; + } + } + return false; +} + bool barrierObstructsConnection(const Barrier2D& barrier, const Connection2D& connection) { if (!barrier.blocksMovement || barrier.geometry.vertices.size() < 2) { return false; } + if (closedBarrierContainsSpanInterior(barrier, connection.centerSpan)) { + return true; + } + const auto& vertices = barrier.geometry.vertices; const std::size_t segmentCount = barrier.geometry.closed ? vertices.size() : vertices.size() - 1; for (std::size_t index = 0; index < segmentCount; ++index) { diff --git a/tests/ImportValidationServiceTests.cpp b/tests/ImportValidationServiceTests.cpp index 15aa8e9..555e474 100644 --- a/tests/ImportValidationServiceTests.cpp +++ b/tests/ImportValidationServiceTests.cpp @@ -76,6 +76,23 @@ SC_TEST(ImportValidationFlagsBarrierCrossingConnectionPassage) { SC_EXPECT_TRUE(!hasBlockingImportIssue(issues)); } +SC_TEST(ImportValidationFlagsClosedObstacleContainingConnectionPassage) { + auto layout = makeConnectedRoomAndExit(); + // Closed footprint fully contains the passage span, so no obstacle edge + // crosses the span even though movement through the passage is blocked. + layout.barriers.push_back({ + .id = "obstacle", + .floorId = "F1", + .geometry = {.vertices = {{9.5, 3.5}, {11.5, 3.5}, {11.5, 4.5}, {9.5, 4.5}}, .closed = true}, + .blocksMovement = true, + }); + + ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(hasIssueCode(issues, ImportIssueCode::ObstructedConnection)); +} + SC_TEST(ImportValidationIgnoresNonBlockingBarrierOverConnection) { auto layout = makeConnectedRoomAndExit(); layout.barriers.push_back({ From fa67ca30cb4b934772f03d248fce4355e4b69b6a Mon Sep 17 00:00:00 2001 From: learncold Date: Fri, 5 Jun 2026 00:20:55 +0900 Subject: [PATCH 3/3] Fix connection obstruction edge cases --- src/domain/ImportValidationService.cpp | 31 ++++++++++++----- tests/ImportValidationServiceTests.cpp | 47 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/domain/ImportValidationService.cpp b/src/domain/ImportValidationService.cpp index 17b053e..e33f013 100644 --- a/src/domain/ImportValidationService.cpp +++ b/src/domain/ImportValidationService.cpp @@ -266,24 +266,39 @@ bool barrierSharesConnectionFloor(const Barrier2D& barrier, const Connection2D& return barrier.floorId.empty() || connection.floorId.empty() || barrier.floorId == connection.floorId; } -// A blocking barrier obstructs a passage when one of its segments crosses the -// connection span through the span interior. Crossings at the span endpoints -// (where doorways legitimately meet flanking walls) and collinear/parallel -// barriers that run along a zone boundary are intentionally ignored. +// A blocking barrier obstructs a passage when one of its segments crosses or +// overlaps the connection span through the span interior. Endpoint-only contact +// is ignored because doorways legitimately meet flanking walls. bool barrierSegmentCrossesSpanInterior(const LineSegment2D& barrierSegment, const LineSegment2D& span) { const auto spanDirection = subtract(span.end, span.start); const auto barrierDirection = subtract(barrierSegment.end, barrierSegment.start); + const auto spanLengthSquared = dot(spanDirection, spanDirection); + if (spanLengthSquared <= kGeometryEpsilon) { + return false; + } + + constexpr double kSpanEndpointTolerance = 1e-6; const auto denominator = cross(spanDirection, barrierDirection); if (std::abs(denominator) <= kGeometryEpsilon) { - return false; + if (std::abs(cross(subtract(barrierSegment.start, span.start), spanDirection)) > kGeometryEpsilon + || std::abs(cross(subtract(barrierSegment.end, span.start), spanDirection)) > kGeometryEpsilon) { + return false; + } + + const auto startFraction = dot(subtract(barrierSegment.start, span.start), spanDirection) / spanLengthSquared; + const auto endFraction = dot(subtract(barrierSegment.end, span.start), spanDirection) / spanLengthSquared; + const auto overlapStart = std::max(std::min(startFraction, endFraction), 0.0); + const auto overlapEnd = std::min(std::max(startFraction, endFraction), 1.0); + return overlapEnd - overlapStart > kSpanEndpointTolerance + && overlapEnd > kSpanEndpointTolerance + && overlapStart < 1.0 - kSpanEndpointTolerance; } const auto delta = subtract(barrierSegment.start, span.start); const auto spanFraction = cross(delta, barrierDirection) / denominator; const auto barrierFraction = cross(delta, spanDirection) / denominator; - constexpr double kSpanInteriorMargin = 0.15; - return spanFraction > kSpanInteriorMargin - && spanFraction < 1.0 - kSpanInteriorMargin + return spanFraction > kSpanEndpointTolerance + && spanFraction < 1.0 - kSpanEndpointTolerance && barrierFraction >= -kGeometryEpsilon && barrierFraction <= 1.0 + kGeometryEpsilon; } diff --git a/tests/ImportValidationServiceTests.cpp b/tests/ImportValidationServiceTests.cpp index 555e474..613637b 100644 --- a/tests/ImportValidationServiceTests.cpp +++ b/tests/ImportValidationServiceTests.cpp @@ -76,6 +76,38 @@ SC_TEST(ImportValidationFlagsBarrierCrossingConnectionPassage) { SC_EXPECT_TRUE(!hasBlockingImportIssue(issues)); } +SC_TEST(ImportValidationFlagsBarrierCrossingNearConnectionEndpoint) { + auto layout = makeConnectedRoomAndExit(); + // Still inside the passage span, but close to the start endpoint. + layout.barriers.push_back({ + .id = "near-start-wall", + .floorId = "F1", + .geometry = {.vertices = {{10.05, 3.0}, {10.05, 5.0}}, .closed = false}, + .blocksMovement = true, + }); + + ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(hasIssueCode(issues, ImportIssueCode::ObstructedConnection)); +} + +SC_TEST(ImportValidationFlagsCollinearBarrierOverConnectionPassage) { + auto layout = makeConnectedRoomAndExit(); + // Unbroken wall segment left on the same span as the exit passage. + layout.barriers.push_back({ + .id = "collinear-wall", + .floorId = "F1", + .geometry = {.vertices = {{10.2, 4.0}, {10.8, 4.0}}, .closed = false}, + .blocksMovement = true, + }); + + ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(hasIssueCode(issues, ImportIssueCode::ObstructedConnection)); +} + SC_TEST(ImportValidationFlagsClosedObstacleContainingConnectionPassage) { auto layout = makeConnectedRoomAndExit(); // Closed footprint fully contains the passage span, so no obstacle edge @@ -108,6 +140,21 @@ SC_TEST(ImportValidationIgnoresNonBlockingBarrierOverConnection) { SC_EXPECT_TRUE(!hasIssueCode(issues, ImportIssueCode::ObstructedConnection)); } +SC_TEST(ImportValidationDoesNotFlagCollinearWallTouchingConnectionEndpoint) { + auto layout = makeConnectedRoomAndExit(); + layout.barriers.push_back({ + .id = "collinear-flank", + .floorId = "F1", + .geometry = {.vertices = {{9.0, 4.0}, {10.0, 4.0}}, .closed = false}, + .blocksMovement = true, + }); + + ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(!hasIssueCode(issues, ImportIssueCode::ObstructedConnection)); +} + SC_TEST(ImportValidationDoesNotFlagWallMeetingConnectionAtEndpoint) { auto layout = makeConnectedRoomAndExit(); // Boundary wall flanking the doorway: touches the span start endpoint only.