Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/application/LayoutReviewWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/application/ProjectPersistence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/domain/ImportIssue.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const char* toString(ImportIssueCode code) noexcept {
return "InvalidFloorReference";
case ImportIssueCode::ConnectionSpanMisaligned:
return "ConnectionSpanMisaligned";
case ImportIssueCode::ObstructedConnection:
return "ObstructedConnection";
}

return "Unknown";
Expand Down
1 change: 1 addition & 0 deletions src/domain/ImportIssue.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ enum class ImportIssueCode {
UnmappedElement,
InvalidFloorReference,
ConnectionSpanMisaligned,
ObstructedConnection,
};

struct ImportIssue {
Expand Down
97 changes: 97 additions & 0 deletions src/domain/ImportValidationService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,83 @@ 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;
}

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) {
const LineSegment2D segment{
.start = vertices[index],
.end = vertices[(index + 1) % vertices.size()],
};
if (barrierSegmentCrossesSpanInterior(segment, connection.centerSpan)) {
return true;
}
}
return false;
}

} // namespace

std::vector<ImportIssue> ImportValidationService::validate(const FacilityLayout2D& layout) const {
Expand Down Expand Up @@ -384,6 +461,26 @@ std::vector<ImportIssue> 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;
Expand Down
125 changes: 125 additions & 0 deletions tests/ImportValidationServiceTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#include <algorithm>
#include <vector>

#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<ImportIssue>& 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(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({
.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));
}
Loading