diff --git a/src/application/LayoutReviewWidget.cpp b/src/application/LayoutReviewWidget.cpp index 8f661ce..a719089 100644 --- a/src/application/LayoutReviewWidget.cpp +++ b/src/application/LayoutReviewWidget.cpp @@ -62,6 +62,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) { case ImportIssueCode::MissingRoom: case ImportIssueCode::DisconnectedWalkableArea: case ImportIssueCode::WidthBelowMinimum: + case ImportIssueCode::ConnectionSpanMisaligned: return true; default: return false; diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 7985fbe..68580d2 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1164,6 +1164,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) { case ImportIssueCode::MissingRoom: case ImportIssueCode::DisconnectedWalkableArea: case ImportIssueCode::WidthBelowMinimum: + case ImportIssueCode::ConnectionSpanMisaligned: case ImportIssueCode::InvalidFloorReference: return true; default: diff --git a/src/domain/ImportIssue.cpp b/src/domain/ImportIssue.cpp index e17ac7a..0c7fc41 100644 --- a/src/domain/ImportIssue.cpp +++ b/src/domain/ImportIssue.cpp @@ -47,6 +47,8 @@ const char* toString(ImportIssueCode code) noexcept { return "UnmappedElement"; case ImportIssueCode::InvalidFloorReference: return "InvalidFloorReference"; + case ImportIssueCode::ConnectionSpanMisaligned: + return "ConnectionSpanMisaligned"; } return "Unknown"; diff --git a/src/domain/ImportIssue.h b/src/domain/ImportIssue.h index b21d845..53b98c5 100644 --- a/src/domain/ImportIssue.h +++ b/src/domain/ImportIssue.h @@ -25,6 +25,7 @@ enum class ImportIssueCode { WidthBelowMinimum, UnmappedElement, InvalidFloorReference, + ConnectionSpanMisaligned, }; struct ImportIssue { diff --git a/src/domain/ImportValidationService.cpp b/src/domain/ImportValidationService.cpp index bd820f7..8e62f91 100644 --- a/src/domain/ImportValidationService.cpp +++ b/src/domain/ImportValidationService.cpp @@ -1,14 +1,24 @@ #include "domain/ImportValidationService.h" +#include +#include #include #include #include +#include #include namespace safecrowd::domain { namespace { constexpr double kMinimumConnectionWidth = 0.9; +constexpr double kConnectionBoundaryTolerance = 0.25; +constexpr double kGeometryEpsilon = 1e-9; + +struct Vector2D { + double x{0.0}; + double y{0.0}; +}; bool hasValidFloorReference(const std::unordered_set& floorIds, const std::string& floorId) { return floorIds.empty() || (!floorId.empty() && floorIds.contains(floorId)); @@ -19,6 +29,228 @@ bool isVerticalConnection(const Connection2D& connection) { || connection.isStair || connection.isRamp; } +Vector2D subtract(const Point2D& lhs, const Point2D& rhs) { + return { + .x = lhs.x - rhs.x, + .y = lhs.y - rhs.y, + }; +} + +Vector2D scale(const Vector2D& value, double factor) { + return { + .x = value.x * factor, + .y = value.y * factor, + }; +} + +double dot(const Vector2D& lhs, const Vector2D& rhs) { + return (lhs.x * rhs.x) + (lhs.y * rhs.y); +} + +double cross(const Vector2D& lhs, const Vector2D& rhs) { + return (lhs.x * rhs.y) - (lhs.y * rhs.x); +} + +double length(const Vector2D& value) { + return std::sqrt(dot(value, value)); +} + +Vector2D normalize(const Vector2D& value) { + const double magnitude = length(value); + if (magnitude <= 1e-12) { + return {}; + } + + return scale(value, 1.0 / magnitude); +} + +double distanceBetween(const Point2D& lhs, const Point2D& rhs) { + return length(subtract(lhs, rhs)); +} + +double distancePointToLine(const Point2D& point, const LineSegment2D& line) { + const auto direction = subtract(line.end, line.start); + const auto lineLength = length(direction); + if (lineLength <= kGeometryEpsilon) { + return distanceBetween(point, line.start); + } + + return std::abs(cross(subtract(point, line.start), direction)) / lineLength; +} + +double projectPoint(const Point2D& point, const Vector2D& axis) { + return point.x * axis.x + point.y * axis.y; +} + +std::pair projectedInterval(const LineSegment2D& segment, const Vector2D& axis) { + const auto start = projectPoint(segment.start, axis); + const auto end = projectPoint(segment.end, axis); + return {std::min(start, end), std::max(start, end)}; +} + +double distancePointToSegment(const Point2D& point, const LineSegment2D& segment) { + const auto direction = subtract(segment.end, segment.start); + const auto lengthSquared = dot(direction, direction); + if (lengthSquared <= kGeometryEpsilon) { + return distanceBetween(point, segment.start); + } + + const auto t = std::clamp(dot(subtract(point, segment.start), direction) / lengthSquared, 0.0, 1.0); + const Point2D projected{ + .x = segment.start.x + (direction.x * t), + .y = segment.start.y + (direction.y * t), + }; + return distanceBetween(point, projected); +} + +bool spansIntersect(const LineSegment2D& lhs, const LineSegment2D& rhs) { + const auto lhsDirection = subtract(lhs.end, lhs.start); + const auto rhsDirection = subtract(rhs.end, rhs.start); + const auto denominator = cross(lhsDirection, rhsDirection); + + if (std::abs(denominator) <= kGeometryEpsilon) { + if (distancePointToLine(lhs.start, rhs) > kConnectionBoundaryTolerance + || distancePointToLine(lhs.end, rhs) > kConnectionBoundaryTolerance) { + return false; + } + + const auto axis = normalize(rhsDirection); + const auto lhsInterval = projectedInterval(lhs, axis); + const auto rhsInterval = projectedInterval(rhs, axis); + const auto overlap = + std::min(lhsInterval.second, rhsInterval.second) - std::max(lhsInterval.first, rhsInterval.first); + return overlap > kGeometryEpsilon; + } + + const auto delta = subtract(rhs.start, lhs.start); + const auto lhsFraction = cross(delta, rhsDirection) / denominator; + const auto rhsFraction = cross(delta, lhsDirection) / denominator; + return lhsFraction >= -kGeometryEpsilon + && lhsFraction <= 1.0 + kGeometryEpsilon + && rhsFraction >= -kGeometryEpsilon + && rhsFraction <= 1.0 + kGeometryEpsilon; +} + +bool spanContactsBoundary(const LineSegment2D& span, const LineSegment2D& boundary) { + const auto spanDirection = subtract(span.end, span.start); + const auto boundaryDirection = subtract(boundary.end, boundary.start); + if (length(spanDirection) <= kGeometryEpsilon || length(boundaryDirection) <= kGeometryEpsilon) { + return false; + } + + if (spansIntersect(span, boundary)) { + return true; + } + + const auto bestDistance = std::min({ + distancePointToSegment(span.start, boundary), + distancePointToSegment(span.end, boundary), + distancePointToSegment(boundary.start, span), + distancePointToSegment(boundary.end, span), + }); + return bestDistance <= kConnectionBoundaryTolerance; +} + +bool spanContactsRingBoundary(const LineSegment2D& span, const std::vector& ring) { + if (ring.size() < 2) { + return false; + } + + for (std::size_t index = 0; index < ring.size(); ++index) { + const LineSegment2D boundary{ + .start = ring[index], + .end = ring[(index + 1) % ring.size()], + }; + if (spanContactsBoundary(span, boundary)) { + return true; + } + } + + return false; +} + +bool spanContactsPolygonBoundary(const LineSegment2D& span, const Polygon2D& polygon) { + if (spanContactsRingBoundary(span, polygon.outline)) { + return true; + } + + return std::any_of(polygon.holes.begin(), polygon.holes.end(), [&](const auto& hole) { + return spanContactsRingBoundary(span, hole); + }); +} + +bool pointInRing(const Point2D& point, const std::vector& ring) { + if (ring.size() < 3) { + return false; + } + + bool inside = false; + for (std::size_t index = 0, previous = ring.size() - 1; index < ring.size(); previous = index++) { + const auto& start = ring[previous]; + const auto& end = ring[index]; + const bool crossesY = (start.y > point.y) != (end.y > point.y); + if (!crossesY) { + continue; + } + + const auto xAtPointY = start.x + ((point.y - start.y) * (end.x - start.x) / (end.y - start.y)); + if (point.x <= xAtPointY + kGeometryEpsilon) { + inside = !inside; + } + } + + return inside; +} + +bool pointInPolygon(const Point2D& point, const Polygon2D& polygon) { + if (!pointInRing(point, polygon.outline)) { + return false; + } + + for (const auto& hole : polygon.holes) { + if (pointInRing(point, hole)) { + return false; + } + } + + return true; +} + +bool spanInteractsWithPolygon(const LineSegment2D& span, const Polygon2D& polygon) { + return spanContactsPolygonBoundary(span, polygon) + || pointInPolygon(span.start, polygon) + || pointInPolygon(span.end, polygon); +} + +const Zone2D* findZoneById(const FacilityLayout2D& layout, const std::string& zoneId) { + const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) { + return zone.id == zoneId; + }); + return it == layout.zones.end() ? nullptr : &(*it); +} + +bool connectionSpanMatchesReferencedZones(const FacilityLayout2D& layout, const Connection2D& connection) { + if (isVerticalConnection(connection)) { + return true; + } + + const auto* fromZone = findZoneById(layout, connection.fromZoneId); + const auto* toZone = findZoneById(layout, connection.toZoneId); + if (fromZone == nullptr || toZone == nullptr) { + return true; + } + + if (connection.kind == ConnectionKind::Exit) { + const auto* exitZone = fromZone->kind == ZoneKind::Exit ? fromZone : toZone; + const auto* walkableZone = fromZone->kind == ZoneKind::Exit ? toZone : fromZone; + return spanContactsPolygonBoundary(connection.centerSpan, walkableZone->area) + && spanInteractsWithPolygon(connection.centerSpan, exitZone->area); + } + + return spanContactsPolygonBoundary(connection.centerSpan, fromZone->area) + && spanContactsPolygonBoundary(connection.centerSpan, toZone->area); +} + bool canTravel(const Connection2D& connection, const std::string& fromZoneId, const std::string& toZoneId) { switch (connection.directionality) { case TravelDirection::Bidirectional: @@ -190,6 +422,16 @@ std::vector ImportValidationService::validate(const FacilityLayout2 .targetId = connection.toZoneId, }); } + if (!connectionSpanMatchesReferencedZones(layout, connection)) { + issues.push_back({ + .severity = ImportIssueSeverity::Error, + .code = ImportIssueCode::ConnectionSpanMisaligned, + .message = "Connection span is not aligned with the referenced zone boundary.", + .sourceId = connection.id, + .targetId = connection.toZoneId, + .isBlocking = true, + }); + } } for (const auto& zone : layout.zones) { diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index f1551b0..a92fc34 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -43,12 +43,35 @@ bool containsBarrierId( }); } +bool containsBlockingIssue( + const std::vector& issues, + safecrowd::domain::ImportIssueCode code, + const std::string& sourceId) { + return std::any_of(issues.begin(), issues.end(), [&](const auto& issue) { + return issue.code == code && issue.sourceId == sourceId && issue.blocksSimulation(); + }); +} + double spanLength(const safecrowd::domain::LineSegment2D& span) { const auto dx = span.end.x - span.start.x; const auto dy = span.end.y - span.start.y; return std::sqrt(dx * dx + dy * dy); } +void translatePolygon(safecrowd::domain::Polygon2D& polygon, double dx, double dy) { + auto translateRing = [&](std::vector& ring) { + for (auto& point : ring) { + point.x += dx; + point.y += dy; + } + }; + + translateRing(polygon.outline); + for (auto& hole : polygon.holes) { + translateRing(hole); + } +} + } // namespace SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) { @@ -93,6 +116,41 @@ SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) { SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); } +SC_TEST(DemoLayoutRejectsMovedConnectionSpan) { + auto layout = safecrowd::domain::DemoLayouts::demoFacility(); + auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [](const auto& connection) { + return connection.id == safecrowd::domain::DemoLayouts::Sprint1FacilityIds::OpeningConnectionId; + }); + SC_EXPECT_TRUE(it != layout.connections.end()); + + it->centerSpan.start.x += 1.0; + it->centerSpan.end.x += 1.0; + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + SC_EXPECT_TRUE(containsBlockingIssue( + issues, + safecrowd::domain::ImportIssueCode::ConnectionSpanMisaligned, + safecrowd::domain::DemoLayouts::Sprint1FacilityIds::OpeningConnectionId)); +} + +SC_TEST(DemoLayoutRejectsMovedExitZone) { + auto layout = safecrowd::domain::DemoLayouts::demoFacility(); + auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [](const auto& zone) { + return zone.id == safecrowd::domain::DemoLayouts::Sprint1FacilityIds::ExitZoneId; + }); + SC_EXPECT_TRUE(it != layout.zones.end()); + + translatePolygon(it->area, 2.0, 0.0); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + SC_EXPECT_TRUE(containsBlockingIssue( + issues, + safecrowd::domain::ImportIssueCode::ConnectionSpanMisaligned, + safecrowd::domain::DemoLayouts::Sprint1FacilityIds::ExitConnectionId)); +} + SC_TEST(DemoLayoutsProvidesRuntimeFacilityLayout) { const auto layout = safecrowd::domain::DemoLayouts::demoFacility();