Skip to content
Merged
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
112 changes: 112 additions & 0 deletions src/domain/ImportValidationService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,98 @@ 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 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) {
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;
return spanFraction > kSpanEndpointTolerance
&& spanFraction < 1.0 - kSpanEndpointTolerance
&& 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 +476,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
172 changes: 172 additions & 0 deletions tests/ImportValidationServiceTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#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(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
// 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(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.
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