From ea5c783ec7112f6cfd23827c6d65eb38ee8b1414 Mon Sep 17 00:00:00 2001 From: muzygosu Date: Tue, 5 May 2026 23:00:54 +0900 Subject: [PATCH 1/2] Validate connection span placement --- src/application/LayoutReviewWidget.cpp | 1 + src/application/ProjectPersistence.cpp | 1 + src/domain/ImportValidationService.cpp | 208 +++++++++++++++++++++++++ tests/DemoFixtureServiceTests.cpp | 27 ++++ 4 files changed, 237 insertions(+) diff --git a/src/application/LayoutReviewWidget.cpp b/src/application/LayoutReviewWidget.cpp index 8f661ce..067cfff 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::InvalidGeometry: return true; default: return false; diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index 7985fbe..e76a310 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::InvalidGeometry: case ImportIssueCode::InvalidFloorReference: return true; default: diff --git a/src/domain/ImportValidationService.cpp b/src/domain/ImportValidationService.cpp index bd820f7..a279f25 100644 --- a/src/domain/ImportValidationService.cpp +++ b/src/domain/ImportValidationService.cpp @@ -1,5 +1,8 @@ #include "domain/ImportValidationService.h" +#include +#include +#include #include #include #include @@ -9,6 +12,12 @@ namespace safecrowd::domain { namespace { constexpr double kMinimumConnectionWidth = 0.9; +constexpr double kConnectionBoundaryTolerance = 0.25; + +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 +28,195 @@ 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, + }; +} + +Point2D add(const Point2D& point, const Vector2D& delta) { + return { + .x = point.x + delta.x, + .y = point.y + delta.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 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)); +} + +Point2D segmentMidpoint(const LineSegment2D& segment) { + return { + .x = (segment.start.x + segment.end.x) * 0.5, + .y = (segment.start.y + segment.end.y) * 0.5, + }; +} + +Vector2D segmentNormal(const LineSegment2D& segment) { + const auto direction = normalize(subtract(segment.end, segment.start)); + return { + .x = -direction.y, + .y = direction.x, + }; +} + +double distancePointToSegment(const Point2D& point, const LineSegment2D& segment) { + const auto dx = segment.end.x - segment.start.x; + const auto dy = segment.end.y - segment.start.y; + const auto lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= 1e-12) { + return distanceBetween(point, segment.start); + } + + const auto t = std::clamp( + ((point.x - segment.start.x) * dx + (point.y - segment.start.y) * dy) / lengthSquared, + 0.0, + 1.0); + const Point2D projected{ + .x = segment.start.x + t * dx, + .y = segment.start.y + t * dy, + }; + return distanceBetween(point, projected); +} + +double distancePointToRingBoundary(const Point2D& point, const std::vector& ring) { + if (ring.empty()) { + return std::numeric_limits::infinity(); + } + + auto bestDistance = std::numeric_limits::infinity(); + for (std::size_t index = 0; index < ring.size(); ++index) { + const auto& start = ring[index]; + const auto& end = ring[(index + 1) % ring.size()]; + bestDistance = std::min(bestDistance, distancePointToSegment(point, {.start = start, .end = end})); + } + return bestDistance; +} + +double distancePointToPolygonBoundary(const Point2D& point, const Polygon2D& polygon) { + auto bestDistance = distancePointToRingBoundary(point, polygon.outline); + for (const auto& hole : polygon.holes) { + bestDistance = std::min(bestDistance, distancePointToRingBoundary(point, hole)); + } + return bestDistance; +} + +bool pointInRingInclusive(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]; + if (distancePointToSegment(point, {.start = start, .end = end}) <= kConnectionBoundaryTolerance) { + return true; + } + + 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 + 1e-12) { + inside = !inside; + } + } + + return inside; +} + +bool pointInPolygonInclusive(const Point2D& point, const Polygon2D& polygon) { + if (!pointInRingInclusive(point, polygon.outline)) { + return false; + } + + for (const auto& hole : polygon.holes) { + if (pointInRingInclusive(point, hole)) { + return false; + } + } + + return true; +} + +bool spanTouchesPolygon(const LineSegment2D& span, const Polygon2D& polygon) { + const auto midpoint = segmentMidpoint(span); + const auto normal = segmentNormal(span); + const double probeDistance = std::max(0.35, distanceBetween(span.start, span.end) * 0.35); + + const std::vector probes = { + span.start, + span.end, + midpoint, + add(midpoint, scale(normal, probeDistance)), + add(midpoint, scale(normal, -probeDistance)), + }; + + for (const auto& probe : probes) { + if (pointInPolygonInclusive(probe, polygon) + || distancePointToPolygonBoundary(probe, polygon) <= kConnectionBoundaryTolerance) { + return true; + } + } + + return false; +} + +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* walkableZone = fromZone->kind == ZoneKind::Exit ? toZone : fromZone; + return spanTouchesPolygon(connection.centerSpan, walkableZone->area); + } + + return spanTouchesPolygon(connection.centerSpan, fromZone->area) + && spanTouchesPolygon(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 +388,16 @@ std::vector ImportValidationService::validate(const FacilityLayout2 .targetId = connection.toZoneId, }); } + if (!connectionSpanMatchesReferencedZones(layout, connection)) { + issues.push_back({ + .severity = ImportIssueSeverity::Error, + .code = ImportIssueCode::InvalidGeometry, + .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..81430e4 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -43,6 +43,15 @@ 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; @@ -93,6 +102,24 @@ 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 += 2.0; + it->centerSpan.end.x += 2.0; + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + SC_EXPECT_TRUE(containsBlockingIssue( + issues, + safecrowd::domain::ImportIssueCode::InvalidGeometry, + safecrowd::domain::DemoLayouts::Sprint1FacilityIds::OpeningConnectionId)); +} + SC_TEST(DemoLayoutsProvidesRuntimeFacilityLayout) { const auto layout = safecrowd::domain::DemoLayouts::demoFacility(); From 3a1284ae2e88457d0eeccf41386cbad3b1961943 Mon Sep 17 00:00:00 2001 From: learncold Date: Thu, 7 May 2026 17:18:54 +0900 Subject: [PATCH 2/2] [Domain] Tighten connection span validation --- src/application/LayoutReviewWidget.cpp | 2 +- src/application/ProjectPersistence.cpp | 2 +- src/domain/ImportIssue.cpp | 2 + src/domain/ImportIssue.h | 1 + src/domain/ImportValidationService.cpp | 186 +++++++++++++++---------- tests/DemoFixtureServiceTests.cpp | 37 ++++- 6 files changed, 149 insertions(+), 81 deletions(-) diff --git a/src/application/LayoutReviewWidget.cpp b/src/application/LayoutReviewWidget.cpp index 067cfff..a719089 100644 --- a/src/application/LayoutReviewWidget.cpp +++ b/src/application/LayoutReviewWidget.cpp @@ -62,7 +62,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) { case ImportIssueCode::MissingRoom: case ImportIssueCode::DisconnectedWalkableArea: case ImportIssueCode::WidthBelowMinimum: - case ImportIssueCode::InvalidGeometry: + case ImportIssueCode::ConnectionSpanMisaligned: return true; default: return false; diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index e76a310..68580d2 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1164,7 +1164,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) { case ImportIssueCode::MissingRoom: case ImportIssueCode::DisconnectedWalkableArea: case ImportIssueCode::WidthBelowMinimum: - case ImportIssueCode::InvalidGeometry: + 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 a279f25..8e62f91 100644 --- a/src/domain/ImportValidationService.cpp +++ b/src/domain/ImportValidationService.cpp @@ -2,10 +2,10 @@ #include #include -#include #include #include #include +#include #include namespace safecrowd::domain { @@ -13,6 +13,7 @@ namespace { constexpr double kMinimumConnectionWidth = 0.9; constexpr double kConnectionBoundaryTolerance = 0.25; +constexpr double kGeometryEpsilon = 1e-9; struct Vector2D { double x{0.0}; @@ -35,13 +36,6 @@ Vector2D subtract(const Point2D& lhs, const Point2D& rhs) { }; } -Point2D add(const Point2D& point, const Vector2D& delta) { - return { - .x = point.x + delta.x, - .y = point.y + delta.y, - }; -} - Vector2D scale(const Vector2D& value, double factor) { return { .x = value.x * factor, @@ -53,6 +47,10 @@ 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)); } @@ -70,63 +68,118 @@ double distanceBetween(const Point2D& lhs, const Point2D& rhs) { return length(subtract(lhs, rhs)); } -Point2D segmentMidpoint(const LineSegment2D& segment) { - return { - .x = (segment.start.x + segment.end.x) * 0.5, - .y = (segment.start.y + segment.end.y) * 0.5, - }; +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; } -Vector2D segmentNormal(const LineSegment2D& segment) { - const auto direction = normalize(subtract(segment.end, segment.start)); - return { - .x = -direction.y, - .y = direction.x, - }; +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 dx = segment.end.x - segment.start.x; - const auto dy = segment.end.y - segment.start.y; - const auto lengthSquared = dx * dx + dy * dy; - if (lengthSquared <= 1e-12) { + 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( - ((point.x - segment.start.x) * dx + (point.y - segment.start.y) * dy) / lengthSquared, - 0.0, - 1.0); + const auto t = std::clamp(dot(subtract(point, segment.start), direction) / lengthSquared, 0.0, 1.0); const Point2D projected{ - .x = segment.start.x + t * dx, - .y = segment.start.y + t * dy, + .x = segment.start.x + (direction.x * t), + .y = segment.start.y + (direction.y * t), }; return distanceBetween(point, projected); } -double distancePointToRingBoundary(const Point2D& point, const std::vector& ring) { - if (ring.empty()) { - return std::numeric_limits::infinity(); +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; } - auto bestDistance = std::numeric_limits::infinity(); for (std::size_t index = 0; index < ring.size(); ++index) { - const auto& start = ring[index]; - const auto& end = ring[(index + 1) % ring.size()]; - bestDistance = std::min(bestDistance, distancePointToSegment(point, {.start = start, .end = end})); + const LineSegment2D boundary{ + .start = ring[index], + .end = ring[(index + 1) % ring.size()], + }; + if (spanContactsBoundary(span, boundary)) { + return true; + } } - return bestDistance; + + return false; } -double distancePointToPolygonBoundary(const Point2D& point, const Polygon2D& polygon) { - auto bestDistance = distancePointToRingBoundary(point, polygon.outline); - for (const auto& hole : polygon.holes) { - bestDistance = std::min(bestDistance, distancePointToRingBoundary(point, hole)); +bool spanContactsPolygonBoundary(const LineSegment2D& span, const Polygon2D& polygon) { + if (spanContactsRingBoundary(span, polygon.outline)) { + return true; } - return bestDistance; + + return std::any_of(polygon.holes.begin(), polygon.holes.end(), [&](const auto& hole) { + return spanContactsRingBoundary(span, hole); + }); } -bool pointInRingInclusive(const Point2D& point, const std::vector& ring) { +bool pointInRing(const Point2D& point, const std::vector& ring) { if (ring.size() < 3) { return false; } @@ -135,17 +188,13 @@ bool pointInRingInclusive(const Point2D& point, const std::vector& ring 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]; - if (distancePointToSegment(point, {.start = start, .end = end}) <= kConnectionBoundaryTolerance) { - return true; - } - 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 + 1e-12) { + if (point.x <= xAtPointY + kGeometryEpsilon) { inside = !inside; } } @@ -153,13 +202,13 @@ bool pointInRingInclusive(const Point2D& point, const std::vector& ring return inside; } -bool pointInPolygonInclusive(const Point2D& point, const Polygon2D& polygon) { - if (!pointInRingInclusive(point, polygon.outline)) { +bool pointInPolygon(const Point2D& point, const Polygon2D& polygon) { + if (!pointInRing(point, polygon.outline)) { return false; } for (const auto& hole : polygon.holes) { - if (pointInRingInclusive(point, hole)) { + if (pointInRing(point, hole)) { return false; } } @@ -167,27 +216,10 @@ bool pointInPolygonInclusive(const Point2D& point, const Polygon2D& polygon) { return true; } -bool spanTouchesPolygon(const LineSegment2D& span, const Polygon2D& polygon) { - const auto midpoint = segmentMidpoint(span); - const auto normal = segmentNormal(span); - const double probeDistance = std::max(0.35, distanceBetween(span.start, span.end) * 0.35); - - const std::vector probes = { - span.start, - span.end, - midpoint, - add(midpoint, scale(normal, probeDistance)), - add(midpoint, scale(normal, -probeDistance)), - }; - - for (const auto& probe : probes) { - if (pointInPolygonInclusive(probe, polygon) - || distancePointToPolygonBoundary(probe, polygon) <= kConnectionBoundaryTolerance) { - return true; - } - } - - return false; +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) { @@ -209,12 +241,14 @@ bool connectionSpanMatchesReferencedZones(const FacilityLayout2D& layout, const } if (connection.kind == ConnectionKind::Exit) { + const auto* exitZone = fromZone->kind == ZoneKind::Exit ? fromZone : toZone; const auto* walkableZone = fromZone->kind == ZoneKind::Exit ? toZone : fromZone; - return spanTouchesPolygon(connection.centerSpan, walkableZone->area); + return spanContactsPolygonBoundary(connection.centerSpan, walkableZone->area) + && spanInteractsWithPolygon(connection.centerSpan, exitZone->area); } - return spanTouchesPolygon(connection.centerSpan, fromZone->area) - && spanTouchesPolygon(connection.centerSpan, toZone->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) { @@ -391,7 +425,7 @@ std::vector ImportValidationService::validate(const FacilityLayout2 if (!connectionSpanMatchesReferencedZones(layout, connection)) { issues.push_back({ .severity = ImportIssueSeverity::Error, - .code = ImportIssueCode::InvalidGeometry, + .code = ImportIssueCode::ConnectionSpanMisaligned, .message = "Connection span is not aligned with the referenced zone boundary.", .sourceId = connection.id, .targetId = connection.toZoneId, diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index 81430e4..a92fc34 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -58,6 +58,20 @@ double spanLength(const safecrowd::domain::LineSegment2D& span) { 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) { @@ -109,17 +123,34 @@ SC_TEST(DemoLayoutRejectsMovedConnectionSpan) { }); SC_EXPECT_TRUE(it != layout.connections.end()); - it->centerSpan.start.x += 2.0; - it->centerSpan.end.x += 2.0; + 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::InvalidGeometry, + 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();