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 src/application/LayoutReviewWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/application/ProjectPersistence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/domain/ImportIssue.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const char* toString(ImportIssueCode code) noexcept {
return "UnmappedElement";
case ImportIssueCode::InvalidFloorReference:
return "InvalidFloorReference";
case ImportIssueCode::ConnectionSpanMisaligned:
return "ConnectionSpanMisaligned";
}

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 @@ -25,6 +25,7 @@ enum class ImportIssueCode {
WidthBelowMinimum,
UnmappedElement,
InvalidFloorReference,
ConnectionSpanMisaligned,
};

struct ImportIssue {
Expand Down
242 changes: 242 additions & 0 deletions src/domain/ImportValidationService.cpp
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
#include "domain/ImportValidationService.h"

#include <algorithm>
#include <cmath>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>

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<std::string>& floorIds, const std::string& floorId) {
return floorIds.empty() || (!floorId.empty() && floorIds.contains(floorId));
Expand All @@ -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<double, double> 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<Point2D>& 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<Point2D>& 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:
Expand Down Expand Up @@ -190,6 +422,16 @@ std::vector<ImportIssue> 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) {
Expand Down
58 changes: 58 additions & 0 deletions tests/DemoFixtureServiceTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,35 @@ bool containsBarrierId(
});
}

bool containsBlockingIssue(
const std::vector<safecrowd::domain::ImportIssue>& 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<safecrowd::domain::Point2D>& 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) {
Expand Down Expand Up @@ -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();

Expand Down
Loading