Skip to content

Commit ea5c783

Browse files
committed
Validate connection span placement
1 parent a38d38a commit ea5c783

4 files changed

Lines changed: 237 additions & 0 deletions

File tree

src/application/LayoutReviewWidget.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) {
6262
case ImportIssueCode::MissingRoom:
6363
case ImportIssueCode::DisconnectedWalkableArea:
6464
case ImportIssueCode::WidthBelowMinimum:
65+
case ImportIssueCode::InvalidGeometry:
6566
return true;
6667
default:
6768
return false;

src/application/ProjectPersistence.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,7 @@ bool isLiveValidationIssue(safecrowd::domain::ImportIssueCode code) {
11641164
case ImportIssueCode::MissingRoom:
11651165
case ImportIssueCode::DisconnectedWalkableArea:
11661166
case ImportIssueCode::WidthBelowMinimum:
1167+
case ImportIssueCode::InvalidGeometry:
11671168
case ImportIssueCode::InvalidFloorReference:
11681169
return true;
11691170
default:

src/domain/ImportValidationService.cpp

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#include "domain/ImportValidationService.h"
22

3+
#include <algorithm>
4+
#include <cmath>
5+
#include <limits>
36
#include <string>
47
#include <unordered_map>
58
#include <unordered_set>
@@ -9,6 +12,12 @@ namespace safecrowd::domain {
912
namespace {
1013

1114
constexpr double kMinimumConnectionWidth = 0.9;
15+
constexpr double kConnectionBoundaryTolerance = 0.25;
16+
17+
struct Vector2D {
18+
double x{0.0};
19+
double y{0.0};
20+
};
1221

1322
bool hasValidFloorReference(const std::unordered_set<std::string>& floorIds, const std::string& floorId) {
1423
return floorIds.empty() || (!floorId.empty() && floorIds.contains(floorId));
@@ -19,6 +28,195 @@ bool isVerticalConnection(const Connection2D& connection) {
1928
|| connection.isStair || connection.isRamp;
2029
}
2130

31+
Vector2D subtract(const Point2D& lhs, const Point2D& rhs) {
32+
return {
33+
.x = lhs.x - rhs.x,
34+
.y = lhs.y - rhs.y,
35+
};
36+
}
37+
38+
Point2D add(const Point2D& point, const Vector2D& delta) {
39+
return {
40+
.x = point.x + delta.x,
41+
.y = point.y + delta.y,
42+
};
43+
}
44+
45+
Vector2D scale(const Vector2D& value, double factor) {
46+
return {
47+
.x = value.x * factor,
48+
.y = value.y * factor,
49+
};
50+
}
51+
52+
double dot(const Vector2D& lhs, const Vector2D& rhs) {
53+
return (lhs.x * rhs.x) + (lhs.y * rhs.y);
54+
}
55+
56+
double length(const Vector2D& value) {
57+
return std::sqrt(dot(value, value));
58+
}
59+
60+
Vector2D normalize(const Vector2D& value) {
61+
const double magnitude = length(value);
62+
if (magnitude <= 1e-12) {
63+
return {};
64+
}
65+
66+
return scale(value, 1.0 / magnitude);
67+
}
68+
69+
double distanceBetween(const Point2D& lhs, const Point2D& rhs) {
70+
return length(subtract(lhs, rhs));
71+
}
72+
73+
Point2D segmentMidpoint(const LineSegment2D& segment) {
74+
return {
75+
.x = (segment.start.x + segment.end.x) * 0.5,
76+
.y = (segment.start.y + segment.end.y) * 0.5,
77+
};
78+
}
79+
80+
Vector2D segmentNormal(const LineSegment2D& segment) {
81+
const auto direction = normalize(subtract(segment.end, segment.start));
82+
return {
83+
.x = -direction.y,
84+
.y = direction.x,
85+
};
86+
}
87+
88+
double distancePointToSegment(const Point2D& point, const LineSegment2D& segment) {
89+
const auto dx = segment.end.x - segment.start.x;
90+
const auto dy = segment.end.y - segment.start.y;
91+
const auto lengthSquared = dx * dx + dy * dy;
92+
if (lengthSquared <= 1e-12) {
93+
return distanceBetween(point, segment.start);
94+
}
95+
96+
const auto t = std::clamp(
97+
((point.x - segment.start.x) * dx + (point.y - segment.start.y) * dy) / lengthSquared,
98+
0.0,
99+
1.0);
100+
const Point2D projected{
101+
.x = segment.start.x + t * dx,
102+
.y = segment.start.y + t * dy,
103+
};
104+
return distanceBetween(point, projected);
105+
}
106+
107+
double distancePointToRingBoundary(const Point2D& point, const std::vector<Point2D>& ring) {
108+
if (ring.empty()) {
109+
return std::numeric_limits<double>::infinity();
110+
}
111+
112+
auto bestDistance = std::numeric_limits<double>::infinity();
113+
for (std::size_t index = 0; index < ring.size(); ++index) {
114+
const auto& start = ring[index];
115+
const auto& end = ring[(index + 1) % ring.size()];
116+
bestDistance = std::min(bestDistance, distancePointToSegment(point, {.start = start, .end = end}));
117+
}
118+
return bestDistance;
119+
}
120+
121+
double distancePointToPolygonBoundary(const Point2D& point, const Polygon2D& polygon) {
122+
auto bestDistance = distancePointToRingBoundary(point, polygon.outline);
123+
for (const auto& hole : polygon.holes) {
124+
bestDistance = std::min(bestDistance, distancePointToRingBoundary(point, hole));
125+
}
126+
return bestDistance;
127+
}
128+
129+
bool pointInRingInclusive(const Point2D& point, const std::vector<Point2D>& ring) {
130+
if (ring.size() < 3) {
131+
return false;
132+
}
133+
134+
bool inside = false;
135+
for (std::size_t index = 0, previous = ring.size() - 1; index < ring.size(); previous = index++) {
136+
const auto& start = ring[previous];
137+
const auto& end = ring[index];
138+
if (distancePointToSegment(point, {.start = start, .end = end}) <= kConnectionBoundaryTolerance) {
139+
return true;
140+
}
141+
142+
const bool crossesY = (start.y > point.y) != (end.y > point.y);
143+
if (!crossesY) {
144+
continue;
145+
}
146+
147+
const auto xAtPointY = start.x + ((point.y - start.y) * (end.x - start.x) / (end.y - start.y));
148+
if (point.x <= xAtPointY + 1e-12) {
149+
inside = !inside;
150+
}
151+
}
152+
153+
return inside;
154+
}
155+
156+
bool pointInPolygonInclusive(const Point2D& point, const Polygon2D& polygon) {
157+
if (!pointInRingInclusive(point, polygon.outline)) {
158+
return false;
159+
}
160+
161+
for (const auto& hole : polygon.holes) {
162+
if (pointInRingInclusive(point, hole)) {
163+
return false;
164+
}
165+
}
166+
167+
return true;
168+
}
169+
170+
bool spanTouchesPolygon(const LineSegment2D& span, const Polygon2D& polygon) {
171+
const auto midpoint = segmentMidpoint(span);
172+
const auto normal = segmentNormal(span);
173+
const double probeDistance = std::max(0.35, distanceBetween(span.start, span.end) * 0.35);
174+
175+
const std::vector<Point2D> probes = {
176+
span.start,
177+
span.end,
178+
midpoint,
179+
add(midpoint, scale(normal, probeDistance)),
180+
add(midpoint, scale(normal, -probeDistance)),
181+
};
182+
183+
for (const auto& probe : probes) {
184+
if (pointInPolygonInclusive(probe, polygon)
185+
|| distancePointToPolygonBoundary(probe, polygon) <= kConnectionBoundaryTolerance) {
186+
return true;
187+
}
188+
}
189+
190+
return false;
191+
}
192+
193+
const Zone2D* findZoneById(const FacilityLayout2D& layout, const std::string& zoneId) {
194+
const auto it = std::find_if(layout.zones.begin(), layout.zones.end(), [&](const auto& zone) {
195+
return zone.id == zoneId;
196+
});
197+
return it == layout.zones.end() ? nullptr : &(*it);
198+
}
199+
200+
bool connectionSpanMatchesReferencedZones(const FacilityLayout2D& layout, const Connection2D& connection) {
201+
if (isVerticalConnection(connection)) {
202+
return true;
203+
}
204+
205+
const auto* fromZone = findZoneById(layout, connection.fromZoneId);
206+
const auto* toZone = findZoneById(layout, connection.toZoneId);
207+
if (fromZone == nullptr || toZone == nullptr) {
208+
return true;
209+
}
210+
211+
if (connection.kind == ConnectionKind::Exit) {
212+
const auto* walkableZone = fromZone->kind == ZoneKind::Exit ? toZone : fromZone;
213+
return spanTouchesPolygon(connection.centerSpan, walkableZone->area);
214+
}
215+
216+
return spanTouchesPolygon(connection.centerSpan, fromZone->area)
217+
&& spanTouchesPolygon(connection.centerSpan, toZone->area);
218+
}
219+
22220
bool canTravel(const Connection2D& connection, const std::string& fromZoneId, const std::string& toZoneId) {
23221
switch (connection.directionality) {
24222
case TravelDirection::Bidirectional:
@@ -190,6 +388,16 @@ std::vector<ImportIssue> ImportValidationService::validate(const FacilityLayout2
190388
.targetId = connection.toZoneId,
191389
});
192390
}
391+
if (!connectionSpanMatchesReferencedZones(layout, connection)) {
392+
issues.push_back({
393+
.severity = ImportIssueSeverity::Error,
394+
.code = ImportIssueCode::InvalidGeometry,
395+
.message = "Connection span is not aligned with the referenced zone boundary.",
396+
.sourceId = connection.id,
397+
.targetId = connection.toZoneId,
398+
.isBlocking = true,
399+
});
400+
}
193401
}
194402

195403
for (const auto& zone : layout.zones) {

tests/DemoFixtureServiceTests.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ bool containsBarrierId(
4343
});
4444
}
4545

46+
bool containsBlockingIssue(
47+
const std::vector<safecrowd::domain::ImportIssue>& issues,
48+
safecrowd::domain::ImportIssueCode code,
49+
const std::string& sourceId) {
50+
return std::any_of(issues.begin(), issues.end(), [&](const auto& issue) {
51+
return issue.code == code && issue.sourceId == sourceId && issue.blocksSimulation();
52+
});
53+
}
54+
4655
double spanLength(const safecrowd::domain::LineSegment2D& span) {
4756
const auto dx = span.end.x - span.start.x;
4857
const auto dy = span.end.y - span.start.y;
@@ -93,6 +102,24 @@ SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) {
93102
SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues));
94103
}
95104

105+
SC_TEST(DemoLayoutRejectsMovedConnectionSpan) {
106+
auto layout = safecrowd::domain::DemoLayouts::demoFacility();
107+
auto it = std::find_if(layout.connections.begin(), layout.connections.end(), [](const auto& connection) {
108+
return connection.id == safecrowd::domain::DemoLayouts::Sprint1FacilityIds::OpeningConnectionId;
109+
});
110+
SC_EXPECT_TRUE(it != layout.connections.end());
111+
112+
it->centerSpan.start.x += 2.0;
113+
it->centerSpan.end.x += 2.0;
114+
115+
safecrowd::domain::ImportValidationService validator;
116+
const auto issues = validator.validate(layout);
117+
SC_EXPECT_TRUE(containsBlockingIssue(
118+
issues,
119+
safecrowd::domain::ImportIssueCode::InvalidGeometry,
120+
safecrowd::domain::DemoLayouts::Sprint1FacilityIds::OpeningConnectionId));
121+
}
122+
96123
SC_TEST(DemoLayoutsProvidesRuntimeFacilityLayout) {
97124
const auto layout = safecrowd::domain::DemoLayouts::demoFacility();
98125

0 commit comments

Comments
 (0)