From 1753d28f4a2549533af08d4677308ca545bf4822 Mon Sep 17 00:00:00 2001 From: learncold Date: Sun, 5 Apr 2026 02:19:14 +0900 Subject: [PATCH] Implement FacilityLayout2D inference and validation --- CMakeLists.txt | 5 + src/domain/DxfImportService.cpp | 52 +- src/domain/FacilityLayoutBuilder.cpp | 971 +++++++++++++++++++++++++ src/domain/FacilityLayoutBuilder.h | 21 + src/domain/ImportContracts.h | 2 + src/domain/ImportValidationService.cpp | 120 +++ src/domain/ImportValidationService.h | 15 + tests/DxfImportServiceTests.cpp | 354 ++++++++- tests/FacilityLayoutBuilderTests.cpp | 307 ++++++++ 9 files changed, 1844 insertions(+), 3 deletions(-) create mode 100644 src/domain/FacilityLayoutBuilder.cpp create mode 100644 src/domain/FacilityLayoutBuilder.h create mode 100644 src/domain/ImportValidationService.cpp create mode 100644 src/domain/ImportValidationService.h create mode 100644 tests/FacilityLayoutBuilderTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 276fb41..d46af40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,10 +71,14 @@ add_library(safecrowd_domain STATIC src/domain/DxfImportService.h src/domain/DxfImportService.cpp src/domain/FacilityLayout2D.h + src/domain/FacilityLayoutBuilder.h + src/domain/FacilityLayoutBuilder.cpp src/domain/ImportIssue.h src/domain/ImportIssue.cpp src/domain/ImportResult.h src/domain/ImportOrchestrator.h + src/domain/ImportValidationService.h + src/domain/ImportValidationService.cpp src/domain/ImportContracts.h ) @@ -104,6 +108,7 @@ if (BUILD_TESTING) tests/EcsCoreTests.cpp tests/ImportContractsTests.cpp tests/DxfImportServiceTests.cpp + tests/FacilityLayoutBuilderTests.cpp tests/WorldQueryTests.cpp ) diff --git a/src/domain/DxfImportService.cpp b/src/domain/DxfImportService.cpp index e4f475f..7a0d547 100644 --- a/src/domain/DxfImportService.cpp +++ b/src/domain/DxfImportService.cpp @@ -12,6 +12,9 @@ #include #include +#include "domain/FacilityLayoutBuilder.h" +#include "domain/ImportValidationService.h" + namespace safecrowd::domain { namespace { @@ -248,6 +251,32 @@ std::vector collectSourceIds(const SourceTrace& trace) { return sourceIds; } +void appendLayoutTraceRef(std::vector& traceRefs, const std::string& targetId, const ElementProvenance& provenance) { + traceRefs.push_back({ + .targetId = targetId, + .sourceIds = provenance.sourceIds, + .canonicalIds = provenance.canonicalIds, + }); +} + +void appendLayoutTraceRefs(const FacilityLayout2D& layout, std::vector& traceRefs) { + for (const auto& zone : layout.zones) { + appendLayoutTraceRef(traceRefs, zone.id, zone.provenance); + } + + for (const auto& connection : layout.connections) { + appendLayoutTraceRef(traceRefs, connection.id, connection.provenance); + } + + for (const auto& barrier : layout.barriers) { + appendLayoutTraceRef(traceRefs, barrier.id, barrier.provenance); + } + + for (const auto& control : layout.controls) { + appendLayoutTraceRef(traceRefs, control.id, control.provenance); + } +} + class DxfAsciiParser { public: DxfAsciiParser(std::filesystem::path sourcePath, std::vector groups) @@ -957,7 +986,28 @@ ImportResult DxfImportService::importFile(const ImportRequest& request) const { auto groups = loadGroups(request.sourcePath); DxfAsciiParser parser(request.sourcePath, std::move(groups)); - return parser.parse(); + result = parser.parse(); + + if (result.canonicalGeometry.has_value()) { + FacilityLayoutBuilder builder; + auto buildResult = builder.build(*result.canonicalGeometry); + result.layout = std::move(buildResult.layout); + result.issues.insert(result.issues.end(), buildResult.issues.begin(), buildResult.issues.end()); + appendLayoutTraceRefs(*result.layout, result.traceRefs); + + if (request.runValidation) { + ImportValidationService validator; + auto validationIssues = validator.validate(*result.layout); + result.issues.insert(result.issues.end(), validationIssues.begin(), validationIssues.end()); + } + } + + if (!request.preserveRawModel) { + result.rawModel.reset(); + } + + result.reviewStatus = hasBlockingImportIssue(result.issues) ? ImportReviewStatus::Rejected : ImportReviewStatus::Pending; + return result; } } // namespace safecrowd::domain diff --git a/src/domain/FacilityLayoutBuilder.cpp b/src/domain/FacilityLayoutBuilder.cpp new file mode 100644 index 0000000..2fcea47 --- /dev/null +++ b/src/domain/FacilityLayoutBuilder.cpp @@ -0,0 +1,971 @@ +#include "domain/FacilityLayoutBuilder.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace safecrowd::domain { +namespace { + +constexpr double kEpsilon = 1e-6; +constexpr double kBoundaryTolerance = 0.25; + +struct Vector2D { + double x{0.0}; + double y{0.0}; +}; + +struct Bounds2D { + double minX{0.0}; + double minY{0.0}; + double maxX{0.0}; + double maxY{0.0}; +}; + +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 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 <= kEpsilon) { + return {}; + } + + return scale(value, 1.0 / magnitude); +} + +Vector2D segmentDirection(const LineSegment2D& segment) { + return subtract(segment.end, segment.start); +} + +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(segmentDirection(segment)); + return { + .x = -direction.y, + .y = direction.x, + }; +} + +double distanceSquared(const Point2D& lhs, const Point2D& rhs) { + const auto delta = subtract(lhs, rhs); + return dot(delta, delta); +} + +double distanceBetween(const Point2D& lhs, const Point2D& rhs) { + return std::sqrt(distanceSquared(lhs, rhs)); +} + +double distancePointToSegment(const Point2D& point, const LineSegment2D& segment) { + const auto direction = subtract(segment.end, segment.start); + const double magnitudeSquared = dot(direction, direction); + + if (magnitudeSquared <= kEpsilon) { + return distanceBetween(point, segment.start); + } + + const auto startToPoint = subtract(point, segment.start); + const double t = std::clamp(dot(startToPoint, direction) / magnitudeSquared, 0.0, 1.0); + const Point2D projection{ + .x = segment.start.x + (direction.x * t), + .y = segment.start.y + (direction.y * t), + }; + return distanceBetween(point, projection); +} + +bool pointOnSegment(const Point2D& point, const LineSegment2D& segment, double tolerance = kBoundaryTolerance) { + return distancePointToSegment(point, segment) <= tolerance; +} + +std::vector polygonEdges(const Polygon2D& polygon) { + std::vector edges; + if (polygon.outline.size() < 2) { + return edges; + } + + edges.reserve(polygon.outline.size()); + for (std::size_t index = 0; index < polygon.outline.size(); ++index) { + edges.push_back({ + .start = polygon.outline[index], + .end = polygon.outline[(index + 1) % polygon.outline.size()], + }); + } + + return edges; +} + +Bounds2D computeBounds(const Polygon2D& polygon) { + Bounds2D bounds; + if (polygon.outline.empty()) { + return bounds; + } + + bounds.minX = bounds.maxX = polygon.outline.front().x; + bounds.minY = bounds.maxY = polygon.outline.front().y; + + for (const auto& vertex : polygon.outline) { + bounds.minX = std::min(bounds.minX, vertex.x); + bounds.minY = std::min(bounds.minY, vertex.y); + bounds.maxX = std::max(bounds.maxX, vertex.x); + bounds.maxY = std::max(bounds.maxY, vertex.y); + } + + return bounds; +} + +double signedRingArea(const std::vector& ring) { + if (ring.size() < 3) { + return 0.0; + } + + double area = 0.0; + for (std::size_t index = 0; index < ring.size(); ++index) { + const auto& current = ring[index]; + const auto& next = ring[(index + 1) % ring.size()]; + area += (current.x * next.y) - (next.x * current.y); + } + + return area * 0.5; +} + +double polygonArea(const Polygon2D& polygon) { + double area = std::fabs(signedRingArea(polygon.outline)); + for (const auto& hole : polygon.holes) { + area -= std::fabs(signedRingArea(hole)); + } + return std::max(0.0, area); +} + +Point2D polygonCentroid(const Polygon2D& polygon) { + const auto area = signedRingArea(polygon.outline); + if (std::fabs(area) <= kEpsilon) { + Point2D centroid{}; + if (polygon.outline.empty()) { + return centroid; + } + + for (const auto& vertex : polygon.outline) { + centroid.x += vertex.x; + centroid.y += vertex.y; + } + + const double count = static_cast(polygon.outline.size()); + centroid.x /= count; + centroid.y /= count; + return centroid; + } + + double factor = 0.0; + Point2D centroid{}; + for (std::size_t index = 0; index < polygon.outline.size(); ++index) { + const auto& current = polygon.outline[index]; + const auto& next = polygon.outline[(index + 1) % polygon.outline.size()]; + const double cross = (current.x * next.y) - (next.x * current.y); + factor += cross; + centroid.x += (current.x + next.x) * cross; + centroid.y += (current.y + next.y) * cross; + } + + const double denominator = 3.0 * factor; + if (std::fabs(denominator) <= kEpsilon) { + return centroid; + } + + centroid.x /= denominator; + centroid.y /= denominator; + return centroid; +} + +bool pointInRingInclusive(const Point2D& point, const std::vector& ring) { + if (ring.size() < 3) { + return false; + } + + for (std::size_t index = 0; index < ring.size(); ++index) { + const auto& start = ring[index]; + const auto& end = ring[(index + 1) % ring.size()]; + if (pointOnSegment(point, {.start = start, .end = end})) { + return true; + } + } + + bool inside = false; + for (std::size_t index = 0, previous = ring.size() - 1; index < ring.size(); previous = index++) { + const auto& vertex = ring[index]; + const auto& prior = ring[previous]; + const bool crossesY = ((vertex.y > point.y) != (prior.y > point.y)); + if (!crossesY) { + continue; + } + + const double denominator = prior.y - vertex.y; + if (std::fabs(denominator) <= kEpsilon) { + continue; + } + + const double xAtPointY = ((prior.x - vertex.x) * (point.y - vertex.y) / denominator) + vertex.x; + if (point.x <= xAtPointY + kEpsilon) { + 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; +} + +double distancePointToPolygonBoundary(const Point2D& point, const Polygon2D& polygon) { + if (polygon.outline.size() < 2) { + return std::numeric_limits::infinity(); + } + + double distance = std::numeric_limits::infinity(); + for (std::size_t index = 0; index < polygon.outline.size(); ++index) { + const auto& start = polygon.outline[index]; + const auto& end = polygon.outline[(index + 1) % polygon.outline.size()]; + distance = std::min(distance, distancePointToSegment(point, {.start = start, .end = end})); + } + return distance; +} + +double distancePointToInfiniteLine(const Point2D& point, const LineSegment2D& line) { + const auto direction = subtract(line.end, line.start); + const double magnitude = length(direction); + if (magnitude <= kEpsilon) { + return distanceBetween(point, line.start); + } + + const auto fromStart = subtract(point, line.start); + return std::fabs(cross(fromStart, direction)) / magnitude; +} + +bool pointInPolygonStrict(const Point2D& point, const Polygon2D& polygon) { + return pointInPolygonInclusive(point, polygon) + && distancePointToPolygonBoundary(point, polygon) > kBoundaryTolerance; +} + +bool touchesPolygon(const Polygon2D& polygon, const LineSegment2D& segment) { + const auto midpoint = segmentMidpoint(segment); + const auto normal = segmentNormal(segment); + const double probeDistance = std::max(0.35, distanceBetween(segment.start, segment.end) * 0.35); + + const std::vector probes = { + segment.start, + segment.end, + midpoint, + add(midpoint, scale(normal, probeDistance)), + add(midpoint, scale(normal, -probeDistance)), + }; + + for (const auto& probe : probes) { + if (pointInPolygonInclusive(probe, polygon)) { + return true; + } + + if (distancePointToPolygonBoundary(probe, polygon) <= kBoundaryTolerance) { + return true; + } + } + + return false; +} + +bool boundsOverlap(const Bounds2D& lhs, const Bounds2D& rhs, double tolerance = kBoundaryTolerance) { + return lhs.minX <= rhs.maxX + tolerance + && lhs.maxX >= rhs.minX - tolerance + && lhs.minY <= rhs.maxY + tolerance + && lhs.maxY >= rhs.minY - tolerance; +} + +Point2D pointAlongEdgeForAxis(const LineSegment2D& edge, double axisValue, bool useX) { + const double startAxis = useX ? edge.start.x : edge.start.y; + const double endAxis = useX ? edge.end.x : edge.end.y; + const double deltaAxis = endAxis - startAxis; + + if (std::fabs(deltaAxis) <= kEpsilon) { + return edge.start; + } + + const double t = (axisValue - startAxis) / deltaAxis; + return { + .x = edge.start.x + ((edge.end.x - edge.start.x) * t), + .y = edge.start.y + ((edge.end.y - edge.start.y) * t), + }; +} + +struct InferredConnectionGeometry { + LineSegment2D span{}; + double width{0.0}; + bool derivedFromSharedBoundary{false}; +}; + +std::optional inferSharedBoundaryPortal(const Polygon2D& lhs, const Polygon2D& rhs) { + std::optional best; + + for (const auto& lhsEdge : polygonEdges(lhs)) { + for (const auto& rhsEdge : polygonEdges(rhs)) { + const auto lhsDirection = subtract(lhsEdge.end, lhsEdge.start); + const auto rhsDirection = subtract(rhsEdge.end, rhsEdge.start); + + if (length(lhsDirection) <= kEpsilon || length(rhsDirection) <= kEpsilon) { + continue; + } + + if (std::fabs(cross(normalize(lhsDirection), normalize(rhsDirection))) > 0.05) { + continue; + } + + if (distancePointToInfiniteLine(lhsEdge.start, rhsEdge) > kBoundaryTolerance + || distancePointToInfiniteLine(rhsEdge.start, lhsEdge) > kBoundaryTolerance) { + continue; + } + + const bool useX = std::fabs(lhsDirection.x) >= std::fabs(lhsDirection.y); + const double lhsMin = std::min(useX ? lhsEdge.start.x : lhsEdge.start.y, useX ? lhsEdge.end.x : lhsEdge.end.y); + const double lhsMax = std::max(useX ? lhsEdge.start.x : lhsEdge.start.y, useX ? lhsEdge.end.x : lhsEdge.end.y); + const double rhsMin = std::min(useX ? rhsEdge.start.x : rhsEdge.start.y, useX ? rhsEdge.end.x : rhsEdge.end.y); + const double rhsMax = std::max(useX ? rhsEdge.start.x : rhsEdge.start.y, useX ? rhsEdge.end.x : rhsEdge.end.y); + const double overlapMin = std::max(lhsMin, rhsMin); + const double overlapMax = std::min(lhsMax, rhsMax); + const double overlapLength = overlapMax - overlapMin; + + if (overlapLength <= kBoundaryTolerance) { + continue; + } + + const auto start = pointAlongEdgeForAxis(lhsEdge, overlapMin, useX); + const auto end = pointAlongEdgeForAxis(lhsEdge, overlapMax, useX); + if (!best.has_value() || overlapLength > best->width) { + best = InferredConnectionGeometry{ + .span = {.start = start, .end = end}, + .width = overlapLength, + .derivedFromSharedBoundary = true, + }; + } + } + } + + return best; +} + +bool polygonsOverlapByArea(const Polygon2D& lhs, const Polygon2D& rhs) { + for (const auto& vertex : lhs.outline) { + if (pointInPolygonStrict(vertex, rhs)) { + return true; + } + } + + for (const auto& vertex : rhs.outline) { + if (pointInPolygonStrict(vertex, lhs)) { + return true; + } + } + + const auto lhsCentroid = polygonCentroid(lhs); + const auto rhsCentroid = polygonCentroid(rhs); + return pointInPolygonStrict(lhsCentroid, rhs) || pointInPolygonStrict(rhsCentroid, lhs); +} + +std::optional inferOverlapPortal(const Polygon2D& lhs, const Polygon2D& rhs) { + const auto lhsBounds = computeBounds(lhs); + const auto rhsBounds = computeBounds(rhs); + if (!boundsOverlap(lhsBounds, rhsBounds, 0.0)) { + return std::nullopt; + } + + if (!polygonsOverlapByArea(lhs, rhs)) { + return std::nullopt; + } + + const double overlapMinX = std::max(lhsBounds.minX, rhsBounds.minX); + const double overlapMaxX = std::min(lhsBounds.maxX, rhsBounds.maxX); + const double overlapMinY = std::max(lhsBounds.minY, rhsBounds.minY); + const double overlapMaxY = std::min(lhsBounds.maxY, rhsBounds.maxY); + const double overlapWidth = overlapMaxX - overlapMinX; + const double overlapHeight = overlapMaxY - overlapMinY; + + if (overlapWidth <= kBoundaryTolerance && overlapHeight <= kBoundaryTolerance) { + return std::nullopt; + } + + const Point2D center{ + .x = (overlapMinX + overlapMaxX) * 0.5, + .y = (overlapMinY + overlapMaxY) * 0.5, + }; + + if (overlapWidth >= overlapHeight) { + return InferredConnectionGeometry{ + .span = { + .start = {overlapMinX, center.y}, + .end = {overlapMaxX, center.y}, + }, + .width = overlapWidth, + .derivedFromSharedBoundary = false, + }; + } + + return InferredConnectionGeometry{ + .span = { + .start = {center.x, overlapMinY}, + .end = {center.x, overlapMaxY}, + }, + .width = overlapHeight, + .derivedFromSharedBoundary = false, + }; +} + +std::optional carvePortalAgainstWalls( + const InferredConnectionGeometry& candidate, + const std::vector& walls) { + if (!candidate.derivedFromSharedBoundary) { + return candidate; + } + + const auto portalDirection = subtract(candidate.span.end, candidate.span.start); + if (length(portalDirection) <= kEpsilon) { + return std::nullopt; + } + + const bool useX = std::fabs(portalDirection.x) >= std::fabs(portalDirection.y); + const double portalMin = std::min(useX ? candidate.span.start.x : candidate.span.start.y, useX ? candidate.span.end.x : candidate.span.end.y); + const double portalMax = std::max(useX ? candidate.span.start.x : candidate.span.start.y, useX ? candidate.span.end.x : candidate.span.end.y); + + std::vector> blockedIntervals; + for (const auto& wall : walls) { + const auto wallDirection = subtract(wall.segment.end, wall.segment.start); + if (length(wallDirection) <= kEpsilon) { + continue; + } + + if (std::fabs(cross(normalize(portalDirection), normalize(wallDirection))) > 0.05) { + continue; + } + + if (distancePointToInfiniteLine(wall.segment.start, candidate.span) > kBoundaryTolerance + || distancePointToInfiniteLine(wall.segment.end, candidate.span) > kBoundaryTolerance) { + continue; + } + + const double wallMin = std::min(useX ? wall.segment.start.x : wall.segment.start.y, useX ? wall.segment.end.x : wall.segment.end.y); + const double wallMax = std::max(useX ? wall.segment.start.x : wall.segment.start.y, useX ? wall.segment.end.x : wall.segment.end.y); + const double blockedMin = std::max(portalMin, wallMin); + const double blockedMax = std::min(portalMax, wallMax); + if (blockedMax - blockedMin > kBoundaryTolerance) { + blockedIntervals.push_back({blockedMin, blockedMax}); + } + } + + if (blockedIntervals.empty()) { + return candidate; + } + + std::sort(blockedIntervals.begin(), blockedIntervals.end(), [](const auto& lhs, const auto& rhs) { + return lhs.first < rhs.first; + }); + + std::vector> mergedIntervals; + for (const auto& interval : blockedIntervals) { + if (mergedIntervals.empty() || interval.first > mergedIntervals.back().second + kBoundaryTolerance) { + mergedIntervals.push_back(interval); + continue; + } + + mergedIntervals.back().second = std::max(mergedIntervals.back().second, interval.second); + } + + double bestGapMin = 0.0; + double bestGapMax = 0.0; + double cursor = portalMin; + + for (const auto& interval : mergedIntervals) { + const double gapMin = cursor; + const double gapMax = std::min(interval.first, portalMax); + if (gapMax - gapMin > bestGapMax - bestGapMin) { + bestGapMin = gapMin; + bestGapMax = gapMax; + } + cursor = std::max(cursor, interval.second); + if (cursor >= portalMax) { + break; + } + } + + if (portalMax - cursor > bestGapMax - bestGapMin) { + bestGapMin = cursor; + bestGapMax = portalMax; + } + + if (bestGapMax - bestGapMin <= kBoundaryTolerance) { + return std::nullopt; + } + + return InferredConnectionGeometry{ + .span = { + .start = pointAlongEdgeForAxis(candidate.span, bestGapMin, useX), + .end = pointAlongEdgeForAxis(candidate.span, bestGapMax, useX), + }, + .width = bestGapMax - bestGapMin, + .derivedFromSharedBoundary = true, + }; +} + +std::optional inferZoneAdjacencyPortal( + const Polygon2D& lhs, + const Polygon2D& rhs, + const std::vector& walls) { + const auto lhsBounds = computeBounds(lhs); + const auto rhsBounds = computeBounds(rhs); + if (!boundsOverlap(lhsBounds, rhsBounds)) { + return std::nullopt; + } + + if (auto sharedBoundary = inferSharedBoundaryPortal(lhs, rhs); sharedBoundary.has_value()) { + return carvePortalAgainstWalls(*sharedBoundary, walls); + } + + return inferOverlapPortal(lhs, rhs); +} + +ZoneKind classifyWalkableZoneKind(const Polygon2D& polygon) { + const auto bounds = computeBounds(polygon); + const double width = std::max(0.0, bounds.maxX - bounds.minX); + const double height = std::max(0.0, bounds.maxY - bounds.minY); + const double shorterSide = std::min(width, height); + const double longerSide = std::max(width, height); + + if (shorterSide <= kEpsilon) { + return ZoneKind::Unknown; + } + + const double aspectRatio = longerSide / shorterSide; + const double boxArea = width * height; + const double occupancy = boxArea <= kEpsilon ? 0.0 : polygonArea(polygon) / boxArea; + + if (aspectRatio >= 2.5 && occupancy >= 0.55) { + return ZoneKind::Corridor; + } + + return ZoneKind::Room; +} + +std::string makeLayoutId(const CanonicalGeometry& geometry) { + if (geometry.levelId.empty()) { + return "layout-import"; + } + return "layout-" + geometry.levelId; +} + +std::string makeLayoutName(const CanonicalGeometry& geometry) { + if (geometry.levelId.empty()) { + return "Imported Layout"; + } + return "Imported Layout " + geometry.levelId; +} + +std::size_t estimateCapacity(const Polygon2D& polygon) { + const double area = polygonArea(polygon); + if (area <= kEpsilon) { + return 1; + } + + return std::max(1, static_cast(std::lround(area / 1.8))); +} + +Polygon2D makeExitZoneArea(const Opening2D& opening) { + const auto direction = normalize(segmentDirection(opening.span)); + if (length(direction) <= kEpsilon) { + const Point2D anchor = opening.span.start; + return { + .outline = { + {anchor.x - 0.5, anchor.y - 0.5}, + {anchor.x + 0.5, anchor.y - 0.5}, + {anchor.x + 0.5, anchor.y + 0.5}, + {anchor.x - 0.5, anchor.y + 0.5}, + }, + }; + } + + const auto normal = segmentNormal(opening.span); + const double halfDepth = std::max(0.5, opening.width * 0.5); + + return { + .outline = { + add(opening.span.start, scale(normal, -halfDepth)), + add(opening.span.end, scale(normal, -halfDepth)), + add(opening.span.end, scale(normal, halfDepth)), + add(opening.span.start, scale(normal, halfDepth)), + }, + }; +} + +void appendIssue( + std::vector& issues, + ImportIssueSeverity severity, + ImportIssueCode code, + std::string message, + std::string sourceId = {}, + std::string targetId = {}, + bool isBlocking = false) { + issues.push_back({ + .severity = severity, + .code = code, + .message = std::move(message), + .sourceId = std::move(sourceId), + .targetId = std::move(targetId), + .isBlocking = isBlocking, + }); +} + +Point2D zoneAnchor(const Zone2D& zone) { + return polygonCentroid(zone.area); +} + +ElementProvenance mergeProvenance(const ElementProvenance& lhs, const ElementProvenance& rhs) { + ElementProvenance merged = lhs; + merged.sourceIds.insert(merged.sourceIds.end(), rhs.sourceIds.begin(), rhs.sourceIds.end()); + merged.canonicalIds.insert(merged.canonicalIds.end(), rhs.canonicalIds.begin(), rhs.canonicalIds.end()); + + std::sort(merged.sourceIds.begin(), merged.sourceIds.end()); + merged.sourceIds.erase(std::unique(merged.sourceIds.begin(), merged.sourceIds.end()), merged.sourceIds.end()); + std::sort(merged.canonicalIds.begin(), merged.canonicalIds.end()); + merged.canonicalIds.erase(std::unique(merged.canonicalIds.begin(), merged.canonicalIds.end()), merged.canonicalIds.end()); + return merged; +} + +std::vector sortZoneCandidatesByDistance(const std::vector& zones, const std::vector& candidates, const Point2D& anchor) { + auto ordered = candidates; + std::sort(ordered.begin(), ordered.end(), [&](std::size_t lhs, std::size_t rhs) { + return distanceSquared(zoneAnchor(zones[lhs]), anchor) < distanceSquared(zoneAnchor(zones[rhs]), anchor); + }); + ordered.erase(std::unique(ordered.begin(), ordered.end()), ordered.end()); + return ordered; +} + +} // namespace + +FacilityLayoutBuildResult FacilityLayoutBuilder::build(const CanonicalGeometry& geometry) const { + FacilityLayoutBuildResult result; + result.layout.id = makeLayoutId(geometry); + result.layout.name = makeLayoutName(geometry); + result.layout.levelId = geometry.levelId; + + std::size_t roomCounter = 0; + std::size_t corridorCounter = 0; + std::size_t exitCounter = 0; + std::size_t stairCounter = 0; + std::size_t connectionCounter = 0; + std::size_t barrierCounter = 0; + std::size_t controlCounter = 0; + + for (const auto& walkable : geometry.walkableAreas) { + const auto kind = classifyWalkableZoneKind(walkable.polygon); + std::string label; + switch (kind) { + case ZoneKind::Corridor: + ++corridorCounter; + label = "Corridor " + std::to_string(corridorCounter); + break; + case ZoneKind::Room: + case ZoneKind::Unknown: + default: + ++roomCounter; + label = "Room " + std::to_string(roomCounter); + break; + } + + result.layout.zones.push_back({ + .id = "zone-" + std::to_string(result.layout.zones.size() + 1), + .kind = kind == ZoneKind::Unknown ? ZoneKind::Room : kind, + .label = std::move(label), + .area = walkable.polygon, + .defaultCapacity = estimateCapacity(walkable.polygon), + .provenance = { + .sourceIds = walkable.sourceIds, + .canonicalIds = {walkable.id}, + }, + }); + } + + const auto walkableZoneCount = result.layout.zones.size(); + + for (const auto& verticalLink : geometry.verticalLinks) { + const bool isRamp = verticalLink.kind == VerticalLinkKind::Ramp; + Polygon2D linkArea{ + .outline = { + {verticalLink.anchor.x - 0.75, verticalLink.anchor.y - 0.75}, + {verticalLink.anchor.x + 0.75, verticalLink.anchor.y - 0.75}, + {verticalLink.anchor.x + 0.75, verticalLink.anchor.y + 0.75}, + {verticalLink.anchor.x - 0.75, verticalLink.anchor.y + 0.75}, + }, + }; + + ++stairCounter; + result.layout.zones.push_back({ + .id = "zone-" + std::to_string(result.layout.zones.size() + 1), + .kind = ZoneKind::Stair, + .label = isRamp ? "Ramp " + std::to_string(stairCounter) : "Stair " + std::to_string(stairCounter), + .area = std::move(linkArea), + .defaultCapacity = 8, + .isStair = !isRamp, + .isRamp = isRamp, + .provenance = { + .sourceIds = verticalLink.sourceIds, + .canonicalIds = {verticalLink.id}, + }, + }); + } + + const auto nonExitZoneCount = result.layout.zones.size(); + std::vector exitZoneIndices; + exitZoneIndices.reserve(geometry.openings.size()); + + for (const auto& opening : geometry.openings) { + if (opening.kind != OpeningKind::Exit) { + continue; + } + + ++exitCounter; + result.layout.zones.push_back({ + .id = "zone-" + std::to_string(result.layout.zones.size() + 1), + .kind = ZoneKind::Exit, + .label = "Exit " + std::to_string(exitCounter), + .area = makeExitZoneArea(opening), + .defaultCapacity = std::max(1, static_cast(std::lround(std::max(1.0, opening.width) * 4.0))), + .provenance = { + .sourceIds = opening.sourceIds, + .canonicalIds = {opening.id}, + }, + }); + exitZoneIndices.push_back(result.layout.zones.size() - 1); + } + + auto findTouchingNonExitZones = [&](const Opening2D& opening) { + std::vector candidates; + for (std::size_t index = 0; index < nonExitZoneCount; ++index) { + if (touchesPolygon(result.layout.zones[index].area, opening.span)) { + candidates.push_back(index); + } + } + return sortZoneCandidatesByDistance(result.layout.zones, candidates, segmentMidpoint(opening.span)); + }; + + std::size_t exitOpeningIndex = 0; + for (const auto& opening : geometry.openings) { + const auto candidates = findTouchingNonExitZones(opening); + + if (opening.kind == OpeningKind::Exit) { + if (exitOpeningIndex >= exitZoneIndices.size()) { + continue; + } + const auto exitZoneIndex = exitZoneIndices[exitOpeningIndex++]; + + if (candidates.empty()) { + appendIssue( + result.issues, + ImportIssueSeverity::Warning, + ImportIssueCode::UnmappedElement, + "Exit opening could not be matched to a walkable zone.", + opening.id, + result.layout.zones[exitZoneIndex].id); + continue; + } + + ++connectionCounter; + result.layout.connections.push_back({ + .id = "connection-" + std::to_string(connectionCounter), + .kind = ConnectionKind::Exit, + .fromZoneId = result.layout.zones[candidates.front()].id, + .toZoneId = result.layout.zones[exitZoneIndex].id, + .effectiveWidth = opening.width, + .directionality = TravelDirection::Bidirectional, + .centerSpan = opening.span, + .provenance = { + .sourceIds = opening.sourceIds, + .canonicalIds = {opening.id}, + }, + }); + continue; + } + + if (candidates.size() < 2) { + appendIssue( + result.issues, + ImportIssueSeverity::Warning, + ImportIssueCode::UnmappedElement, + "Opening could not be matched to two walkable zones.", + opening.id); + continue; + } + + ++connectionCounter; + result.layout.connections.push_back({ + .id = "connection-" + std::to_string(connectionCounter), + .kind = ConnectionKind::Doorway, + .fromZoneId = result.layout.zones[candidates[0]].id, + .toZoneId = result.layout.zones[candidates[1]].id, + .effectiveWidth = opening.width, + .directionality = TravelDirection::Bidirectional, + .centerSpan = opening.span, + .provenance = { + .sourceIds = opening.sourceIds, + .canonicalIds = {opening.id}, + }, + }); + } + + const auto makeZonePairKey = [](const std::string& lhs, const std::string& rhs) { + return lhs < rhs ? lhs + "|" + rhs : rhs + "|" + lhs; + }; + + std::unordered_set connectedZonePairs; + for (const auto& connection : result.layout.connections) { + connectedZonePairs.insert(makeZonePairKey(connection.fromZoneId, connection.toZoneId)); + } + + for (std::size_t lhsIndex = 0; lhsIndex < walkableZoneCount; ++lhsIndex) { + for (std::size_t rhsIndex = lhsIndex + 1; rhsIndex < walkableZoneCount; ++rhsIndex) { + const auto zonePairKey = makeZonePairKey(result.layout.zones[lhsIndex].id, result.layout.zones[rhsIndex].id); + if (connectedZonePairs.contains(zonePairKey)) { + continue; + } + + const auto inferredPortal = inferZoneAdjacencyPortal( + result.layout.zones[lhsIndex].area, + result.layout.zones[rhsIndex].area, + geometry.walls); + if (!inferredPortal.has_value()) { + continue; + } + + ++connectionCounter; + result.layout.connections.push_back({ + .id = "connection-" + std::to_string(connectionCounter), + .kind = ConnectionKind::Opening, + .fromZoneId = result.layout.zones[lhsIndex].id, + .toZoneId = result.layout.zones[rhsIndex].id, + .effectiveWidth = inferredPortal->width, + .directionality = TravelDirection::Bidirectional, + .centerSpan = inferredPortal->span, + .provenance = mergeProvenance( + result.layout.zones[lhsIndex].provenance, + result.layout.zones[rhsIndex].provenance), + }); + connectedZonePairs.insert(zonePairKey); + } + } + + for (const auto& wall : geometry.walls) { + ++barrierCounter; + result.layout.barriers.push_back({ + .id = "barrier-" + std::to_string(barrierCounter), + .geometry = { + .vertices = {wall.segment.start, wall.segment.end}, + .closed = false, + }, + .blocksMovement = true, + .provenance = { + .sourceIds = wall.sourceIds, + .canonicalIds = {wall.id}, + }, + }); + } + + for (const auto& obstacle : geometry.obstacles) { + ++barrierCounter; + result.layout.barriers.push_back({ + .id = "barrier-" + std::to_string(barrierCounter), + .geometry = { + .vertices = obstacle.footprint.outline, + .closed = true, + }, + .blocksMovement = true, + .provenance = { + .sourceIds = obstacle.sourceIds, + .canonicalIds = {obstacle.id}, + }, + }); + } + + for (const auto& verticalLink : geometry.verticalLinks) { + std::vector candidates; + for (std::size_t index = 0; index < nonExitZoneCount; ++index) { + if (pointInPolygonInclusive(verticalLink.anchor, result.layout.zones[index].area) + || distancePointToPolygonBoundary(verticalLink.anchor, result.layout.zones[index].area) <= kBoundaryTolerance) { + candidates.push_back(index); + } + } + + const auto ordered = sortZoneCandidatesByDistance(result.layout.zones, candidates, verticalLink.anchor); + if (ordered.empty()) { + continue; + } + + ++controlCounter; + result.layout.controls.push_back({ + .id = "control-" + std::to_string(controlCounter), + .kind = verticalLink.kind == VerticalLinkKind::Elevator ? ControlKind::Gate : ControlKind::Unknown, + .targetId = result.layout.zones[ordered.front()].id, + .provenance = { + .sourceIds = verticalLink.sourceIds, + .canonicalIds = {verticalLink.id}, + }, + }); + } + + return result; +} + +} // namespace safecrowd::domain diff --git a/src/domain/FacilityLayoutBuilder.h b/src/domain/FacilityLayoutBuilder.h new file mode 100644 index 0000000..a301761 --- /dev/null +++ b/src/domain/FacilityLayoutBuilder.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include "domain/CanonicalGeometry.h" +#include "domain/FacilityLayout2D.h" +#include "domain/ImportIssue.h" + +namespace safecrowd::domain { + +struct FacilityLayoutBuildResult { + FacilityLayout2D layout{}; + std::vector issues{}; +}; + +class FacilityLayoutBuilder { +public: + FacilityLayoutBuildResult build(const CanonicalGeometry& geometry) const; +}; + +} // namespace safecrowd::domain diff --git a/src/domain/ImportContracts.h b/src/domain/ImportContracts.h index 44bdc1e..c65b89f 100644 --- a/src/domain/ImportContracts.h +++ b/src/domain/ImportContracts.h @@ -3,8 +3,10 @@ #include "domain/CanonicalGeometry.h" #include "domain/DxfImportService.h" #include "domain/FacilityLayout2D.h" +#include "domain/FacilityLayoutBuilder.h" #include "domain/Geometry2D.h" #include "domain/ImportIssue.h" #include "domain/ImportOrchestrator.h" #include "domain/ImportResult.h" +#include "domain/ImportValidationService.h" #include "domain/RawImportModel.h" diff --git a/src/domain/ImportValidationService.cpp b/src/domain/ImportValidationService.cpp new file mode 100644 index 0000000..b56693a --- /dev/null +++ b/src/domain/ImportValidationService.cpp @@ -0,0 +1,120 @@ +#include "domain/ImportValidationService.h" + +#include +#include +#include + +namespace safecrowd::domain { +namespace { + +constexpr double kMinimumConnectionWidth = 0.9; + +bool canTravel(const Connection2D& connection, const std::string& fromZoneId, const std::string& toZoneId) { + switch (connection.directionality) { + case TravelDirection::Bidirectional: + return (connection.fromZoneId == fromZoneId && connection.toZoneId == toZoneId) + || (connection.fromZoneId == toZoneId && connection.toZoneId == fromZoneId); + case TravelDirection::ForwardOnly: + return connection.fromZoneId == fromZoneId && connection.toZoneId == toZoneId; + case TravelDirection::ReverseOnly: + return connection.fromZoneId == toZoneId && connection.toZoneId == fromZoneId; + case TravelDirection::Closed: + return false; + } + + return false; +} + +bool hasRouteToExit( + const FacilityLayout2D& layout, + const std::unordered_set& exitZoneIds, + const std::string& startZoneId) { + if (exitZoneIds.contains(startZoneId)) { + return true; + } + + std::vector frontier = {startZoneId}; + std::unordered_set visited = {startZoneId}; + + while (!frontier.empty()) { + const auto currentZoneId = frontier.back(); + frontier.pop_back(); + + if (exitZoneIds.contains(currentZoneId)) { + return true; + } + + for (const auto& connection : layout.connections) { + if (connection.directionality == TravelDirection::Closed) { + continue; + } + + if (canTravel(connection, currentZoneId, connection.toZoneId) && !visited.contains(connection.toZoneId)) { + frontier.push_back(connection.toZoneId); + visited.insert(connection.toZoneId); + } + + if (canTravel(connection, currentZoneId, connection.fromZoneId) && !visited.contains(connection.fromZoneId)) { + frontier.push_back(connection.fromZoneId); + visited.insert(connection.fromZoneId); + } + } + } + + return false; +} + +} // namespace + +std::vector ImportValidationService::validate(const FacilityLayout2D& layout) const { + std::vector issues; + + std::unordered_set exitZoneIds; + for (const auto& zone : layout.zones) { + if (zone.kind == ZoneKind::Exit) { + exitZoneIds.insert(zone.id); + } + } + + if (exitZoneIds.empty()) { + issues.push_back({ + .severity = ImportIssueSeverity::Error, + .code = ImportIssueCode::MissingExit, + .message = "Imported layout does not contain an inferred exit zone.", + .targetId = layout.id, + .isBlocking = true, + }); + } + + for (const auto& connection : layout.connections) { + if (connection.effectiveWidth > 0.0 && connection.effectiveWidth < kMinimumConnectionWidth) { + issues.push_back({ + .severity = ImportIssueSeverity::Warning, + .code = ImportIssueCode::WidthBelowMinimum, + .message = "Connection width is below the demo minimum threshold.", + .sourceId = connection.id, + .targetId = connection.toZoneId, + }); + } + } + + for (const auto& zone : layout.zones) { + if (zone.kind == ZoneKind::Exit) { + continue; + } + + if (!hasRouteToExit(layout, exitZoneIds, zone.id)) { + issues.push_back({ + .severity = ImportIssueSeverity::Error, + .code = ImportIssueCode::DisconnectedWalkableArea, + .message = "Walkable zone is not connected to any inferred exit.", + .targetId = zone.id, + .isBlocking = true, + }); + } + } + + return issues; +} + +} // namespace safecrowd::domain diff --git a/src/domain/ImportValidationService.h b/src/domain/ImportValidationService.h new file mode 100644 index 0000000..bd69086 --- /dev/null +++ b/src/domain/ImportValidationService.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include "domain/FacilityLayout2D.h" +#include "domain/ImportIssue.h" + +namespace safecrowd::domain { + +class ImportValidationService { +public: + std::vector validate(const FacilityLayout2D& layout) const; +}; + +} // namespace safecrowd::domain diff --git a/tests/DxfImportServiceTests.cpp b/tests/DxfImportServiceTests.cpp index 89dc917..dfea6b2 100644 --- a/tests/DxfImportServiceTests.cpp +++ b/tests/DxfImportServiceTests.cpp @@ -1,6 +1,8 @@ +#include #include #include #include +#include #include "TestSupport.h" @@ -268,6 +270,270 @@ SEQEND EOF )"; +const char* kNoExitDxf = R"(0 +SECTION +2 +ENTITIES +0 +LWPOLYLINE +8 +WALKABLE +90 +4 +70 +1 +10 +0 +20 +0 +10 +8 +20 +0 +10 +8 +20 +6 +10 +0 +20 +6 +0 +ENDSEC +0 +EOF +)"; + +const char* kDisconnectedDxf = R"(0 +SECTION +2 +ENTITIES +0 +LWPOLYLINE +8 +WALKABLE +90 +4 +70 +1 +10 +0 +20 +0 +10 +4 +20 +0 +10 +4 +20 +4 +10 +0 +20 +4 +0 +LWPOLYLINE +8 +WALKABLE +90 +4 +70 +1 +10 +8 +20 +0 +10 +12 +20 +0 +10 +12 +20 +4 +10 +8 +20 +4 +0 +LINE +8 +EXIT +10 +4 +20 +1.4 +11 +4 +21 +2.6 +0 +ENDSEC +0 +EOF +)"; + +const char* kAdjacentWalkablesDxf = R"(0 +SECTION +2 +ENTITIES +0 +LWPOLYLINE +8 +WALKABLE +90 +4 +70 +1 +10 +0 +20 +0 +10 +6 +20 +0 +10 +6 +20 +4 +10 +0 +20 +4 +0 +LWPOLYLINE +8 +WALKABLE +90 +4 +70 +1 +10 +6 +20 +0 +10 +12 +20 +0 +10 +12 +20 +4 +10 +6 +20 +4 +0 +LINE +8 +EXIT +10 +12 +20 +1.0 +11 +12 +21 +3.0 +0 +ENDSEC +0 +EOF +)"; + +const char* kAdjacentWalkablesBlockedByWallDxf = R"(0 +SECTION +2 +ENTITIES +0 +LWPOLYLINE +8 +WALKABLE +90 +4 +70 +1 +10 +0 +20 +0 +10 +6 +20 +0 +10 +6 +20 +4 +10 +0 +20 +4 +0 +LWPOLYLINE +8 +WALKABLE +90 +4 +70 +1 +10 +6 +20 +0 +10 +12 +20 +0 +10 +12 +20 +4 +10 +6 +20 +4 +0 +LINE +8 +WALL +10 +6 +20 +0 +11 +6 +21 +4 +0 +LINE +8 +EXIT +10 +12 +20 +1.0 +11 +12 +21 +3.0 +0 +ENDSEC +0 +EOF +)"; + +bool containsIssueCode( + const std::vector& issues, + safecrowd::domain::ImportIssueCode code) { + return std::any_of(issues.begin(), issues.end(), [&](const auto& issue) { + return issue.code == code; + }); +} + } // namespace SC_TEST(DxfImportServiceBuildsCanonicalGeometryFromHappyPathSample) { @@ -282,15 +548,21 @@ SC_TEST(DxfImportServiceBuildsCanonicalGeometryFromHappyPathSample) { SC_EXPECT_TRUE(result.rawModel.has_value()); SC_EXPECT_TRUE(result.canonicalGeometry.has_value()); + SC_EXPECT_TRUE(result.layout.has_value()); SC_EXPECT_EQ(result.rawModel->unit, safecrowd::domain::ImportUnit::Meter); SC_EXPECT_EQ(result.rawModel->entities.size(), std::size_t{4}); SC_EXPECT_EQ(result.canonicalGeometry->walkableAreas.size(), std::size_t{1}); SC_EXPECT_EQ(result.canonicalGeometry->walls.size(), std::size_t{4}); SC_EXPECT_EQ(result.canonicalGeometry->openings.size(), std::size_t{1}); SC_EXPECT_EQ(result.canonicalGeometry->obstacles.size(), std::size_t{1}); + SC_EXPECT_EQ(result.layout->zones.size(), std::size_t{2}); + SC_EXPECT_EQ(result.layout->connections.size(), std::size_t{1}); + SC_EXPECT_EQ(result.layout->barriers.size(), std::size_t{5}); + SC_EXPECT_TRUE(result.layout->barriers.back().geometry.closed); + SC_EXPECT_EQ(result.layout->zones.back().kind, safecrowd::domain::ZoneKind::Exit); SC_EXPECT_EQ(result.canonicalGeometry->openings.front().kind, safecrowd::domain::OpeningKind::Exit); SC_EXPECT_NEAR(result.canonicalGeometry->openings.front().width, 1.2, 1e-9); - SC_EXPECT_EQ(result.traceRefs.size(), std::size_t{7}); + SC_EXPECT_EQ(result.traceRefs.size(), std::size_t{15}); SC_EXPECT_EQ(result.traceRefs.front().canonicalIds.front(), result.traceRefs.front().targetId); SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(result.issues)); SC_EXPECT_EQ(result.reviewStatus, safecrowd::domain::ImportReviewStatus::Pending); @@ -310,6 +582,7 @@ SC_TEST(DxfImportServiceReportsMissingBlockDefinitions) { SC_EXPECT_TRUE(result.rawModel.has_value()); SC_EXPECT_TRUE(result.canonicalGeometry.has_value()); + SC_EXPECT_TRUE(result.layout.has_value()); SC_EXPECT_TRUE(safecrowd::domain::hasBlockingImportIssue(result.issues)); SC_EXPECT_EQ(result.issues.front().code, safecrowd::domain::ImportIssueCode::MissingBlockDefinition); SC_EXPECT_EQ(result.reviewStatus, safecrowd::domain::ImportReviewStatus::Rejected); @@ -329,11 +602,88 @@ SC_TEST(DxfImportServiceImportsClassicPolylineEntities) { SC_EXPECT_TRUE(result.rawModel.has_value()); SC_EXPECT_TRUE(result.canonicalGeometry.has_value()); + SC_EXPECT_TRUE(result.layout.has_value()); SC_EXPECT_EQ(result.rawModel->entities.size(), std::size_t{2}); SC_EXPECT_EQ(result.canonicalGeometry->walkableAreas.size(), std::size_t{1}); SC_EXPECT_EQ(result.canonicalGeometry->walls.size(), std::size_t{1}); - SC_EXPECT_EQ(result.traceRefs.size(), std::size_t{2}); + SC_EXPECT_EQ(result.layout->zones.size(), std::size_t{1}); + SC_EXPECT_EQ(result.traceRefs.size(), std::size_t{4}); + SC_EXPECT_TRUE(safecrowd::domain::hasBlockingImportIssue(result.issues)); + + std::filesystem::remove(sourcePath); +} + +SC_TEST(DxfImportServiceRejectsLayoutsWithoutInferredExit) { + const auto sourcePath = writeTempFile("safecrowd-no-exit.dxf", kNoExitDxf); + + safecrowd::domain::DxfImportService importer; + safecrowd::domain::ImportRequest request; + request.sourcePath = sourcePath; + request.requestedFormat = safecrowd::domain::ImportedFileFormat::Dxf; + + const auto result = importer.importFile(request); + + SC_EXPECT_TRUE(result.layout.has_value()); + SC_EXPECT_EQ(result.layout->zones.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsIssueCode(result.issues, safecrowd::domain::ImportIssueCode::MissingExit)); + SC_EXPECT_EQ(result.reviewStatus, safecrowd::domain::ImportReviewStatus::Rejected); + + std::filesystem::remove(sourcePath); +} + +SC_TEST(DxfImportServiceRejectsDisconnectedWalkableAreas) { + const auto sourcePath = writeTempFile("safecrowd-disconnected.dxf", kDisconnectedDxf); + + safecrowd::domain::DxfImportService importer; + safecrowd::domain::ImportRequest request; + request.sourcePath = sourcePath; + request.requestedFormat = safecrowd::domain::ImportedFileFormat::Dxf; + + const auto result = importer.importFile(request); + + SC_EXPECT_TRUE(result.layout.has_value()); + SC_EXPECT_EQ(result.layout->zones.size(), std::size_t{3}); + SC_EXPECT_TRUE(containsIssueCode(result.issues, safecrowd::domain::ImportIssueCode::DisconnectedWalkableArea)); + SC_EXPECT_EQ(result.reviewStatus, safecrowd::domain::ImportReviewStatus::Rejected); + + std::filesystem::remove(sourcePath); +} + +SC_TEST(DxfImportServiceInfersConnectionsBetweenAdjacentWalkableZones) { + const auto sourcePath = writeTempFile("safecrowd-adjacent-walkables.dxf", kAdjacentWalkablesDxf); + + safecrowd::domain::DxfImportService importer; + safecrowd::domain::ImportRequest request; + request.sourcePath = sourcePath; + request.requestedFormat = safecrowd::domain::ImportedFileFormat::Dxf; + + const auto result = importer.importFile(request); + + SC_EXPECT_TRUE(result.layout.has_value()); + SC_EXPECT_EQ(result.layout->zones.size(), std::size_t{3}); + SC_EXPECT_EQ(result.layout->connections.size(), std::size_t{2}); + SC_EXPECT_EQ(result.layout->connections.back().kind, safecrowd::domain::ConnectionKind::Opening); + SC_EXPECT_TRUE(!containsIssueCode(result.issues, safecrowd::domain::ImportIssueCode::DisconnectedWalkableArea)); SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(result.issues)); std::filesystem::remove(sourcePath); } + +SC_TEST(DxfImportServiceDoesNotInferConnectionsAcrossWallSeam) { + const auto sourcePath = writeTempFile("safecrowd-adjacent-walkables-wall-seam.dxf", kAdjacentWalkablesBlockedByWallDxf); + + safecrowd::domain::DxfImportService importer; + safecrowd::domain::ImportRequest request; + request.sourcePath = sourcePath; + request.requestedFormat = safecrowd::domain::ImportedFileFormat::Dxf; + + const auto result = importer.importFile(request); + + SC_EXPECT_TRUE(result.layout.has_value()); + SC_EXPECT_EQ(result.layout->zones.size(), std::size_t{3}); + SC_EXPECT_EQ(result.layout->connections.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsIssueCode(result.issues, safecrowd::domain::ImportIssueCode::DisconnectedWalkableArea)); + SC_EXPECT_TRUE(safecrowd::domain::hasBlockingImportIssue(result.issues)); + + std::filesystem::remove(sourcePath); +} diff --git a/tests/FacilityLayoutBuilderTests.cpp b/tests/FacilityLayoutBuilderTests.cpp new file mode 100644 index 0000000..72a18ab --- /dev/null +++ b/tests/FacilityLayoutBuilderTests.cpp @@ -0,0 +1,307 @@ +#include +#include +#include + +#include "TestSupport.h" + +#include "domain/FacilityLayoutBuilder.h" +#include "domain/ImportValidationService.h" + +namespace { + +bool containsIssueCode( + const std::vector& issues, + safecrowd::domain::ImportIssueCode code) { + return std::any_of(issues.begin(), issues.end(), [&](const auto& issue) { + return issue.code == code; + }); +} + +} // namespace + +SC_TEST(FacilityLayoutBuilderBuildsWalkableExitAndBarrierElements) { + safecrowd::domain::CanonicalGeometry geometry; + geometry.levelId = "L1"; + geometry.walkableAreas.push_back({ + .id = "walkable-01", + .polygon = { + .outline = { + {0.0, 0.0}, + {12.0, 0.0}, + {12.0, 8.0}, + {0.0, 8.0}, + }, + }, + .sourceIds = {"polyline-1"}, + }); + geometry.walls.push_back({ + .id = "wall-01", + .segment = { + .start = {0.0, 0.0}, + .end = {12.0, 0.0}, + }, + .sourceIds = {"line-1"}, + }); + geometry.obstacles.push_back({ + .id = "obstacle-01", + .footprint = { + .outline = { + {4.0, 3.0}, + {5.0, 3.0}, + {5.0, 4.0}, + {4.0, 4.0}, + }, + }, + .sourceIds = {"polyline-obs"}, + }); + geometry.openings.push_back({ + .id = "opening-01", + .kind = safecrowd::domain::OpeningKind::Exit, + .span = { + .start = {12.0, 3.0}, + .end = {12.0, 4.2}, + }, + .width = 1.2, + .sourceIds = {"insert-1"}, + }); + + safecrowd::domain::FacilityLayoutBuilder builder; + const auto buildResult = builder.build(geometry); + + SC_EXPECT_EQ(buildResult.layout.id, std::string("layout-L1")); + SC_EXPECT_EQ(buildResult.layout.zones.size(), std::size_t{2}); + SC_EXPECT_EQ(buildResult.layout.zones.front().kind, safecrowd::domain::ZoneKind::Room); + SC_EXPECT_EQ(buildResult.layout.zones.back().kind, safecrowd::domain::ZoneKind::Exit); + SC_EXPECT_EQ(buildResult.layout.connections.size(), std::size_t{1}); + SC_EXPECT_EQ(buildResult.layout.connections.front().kind, safecrowd::domain::ConnectionKind::Exit); + SC_EXPECT_EQ(buildResult.layout.barriers.size(), std::size_t{2}); + SC_EXPECT_TRUE(buildResult.layout.barriers.back().geometry.closed); + SC_EXPECT_TRUE(buildResult.issues.empty()); +} + +SC_TEST(ImportValidationServiceReportsMissingExitDisconnectedAreaAndNarrowConnections) { + safecrowd::domain::FacilityLayout2D layout; + layout.id = "layout-L1"; + layout.levelId = "L1"; + layout.zones.push_back({ + .id = "zone-room-1", + .kind = safecrowd::domain::ZoneKind::Room, + .label = "Room 1", + .area = { + .outline = { + {0.0, 0.0}, + {4.0, 0.0}, + {4.0, 4.0}, + {0.0, 4.0}, + }, + }, + }); + layout.zones.push_back({ + .id = "zone-room-2", + .kind = safecrowd::domain::ZoneKind::Room, + .label = "Room 2", + .area = { + .outline = { + {6.0, 0.0}, + {10.0, 0.0}, + {10.0, 4.0}, + {6.0, 4.0}, + }, + }, + }); + layout.connections.push_back({ + .id = "connection-1", + .kind = safecrowd::domain::ConnectionKind::Doorway, + .fromZoneId = "zone-room-1", + .toZoneId = "zone-room-2", + .effectiveWidth = 0.6, + }); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(layout); + + SC_EXPECT_TRUE(containsIssueCode(issues, safecrowd::domain::ImportIssueCode::MissingExit)); + SC_EXPECT_TRUE(containsIssueCode(issues, safecrowd::domain::ImportIssueCode::DisconnectedWalkableArea)); + SC_EXPECT_TRUE(containsIssueCode(issues, safecrowd::domain::ImportIssueCode::WidthBelowMinimum)); + SC_EXPECT_TRUE(safecrowd::domain::hasBlockingImportIssue(issues)); +} + +SC_TEST(FacilityLayoutBuilderInfersAdjacencyConnectionsBetweenTouchingWalkableZones) { + safecrowd::domain::CanonicalGeometry geometry; + geometry.levelId = "L2"; + geometry.walkableAreas.push_back({ + .id = "walkable-01", + .polygon = { + .outline = { + {0.0, 0.0}, + {6.0, 0.0}, + {6.0, 4.0}, + {0.0, 4.0}, + }, + }, + .sourceIds = {"polyline-1"}, + }); + geometry.walkableAreas.push_back({ + .id = "walkable-02", + .polygon = { + .outline = { + {6.0, 0.0}, + {12.0, 0.0}, + {12.0, 4.0}, + {6.0, 4.0}, + }, + }, + .sourceIds = {"polyline-2"}, + }); + geometry.openings.push_back({ + .id = "opening-exit", + .kind = safecrowd::domain::OpeningKind::Exit, + .span = { + .start = {12.0, 1.2}, + .end = {12.0, 2.8}, + }, + .width = 1.6, + .sourceIds = {"line-exit"}, + }); + + safecrowd::domain::FacilityLayoutBuilder builder; + const auto buildResult = builder.build(geometry); + + SC_EXPECT_EQ(buildResult.layout.zones.size(), std::size_t{3}); + SC_EXPECT_EQ(buildResult.layout.connections.size(), std::size_t{2}); + SC_EXPECT_EQ(buildResult.layout.connections.front().kind, safecrowd::domain::ConnectionKind::Exit); + SC_EXPECT_EQ(buildResult.layout.connections.back().kind, safecrowd::domain::ConnectionKind::Opening); + SC_EXPECT_NEAR(buildResult.layout.connections.back().effectiveWidth, 4.0, 1e-9); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(buildResult.layout); + + SC_EXPECT_TRUE(!containsIssueCode(issues, safecrowd::domain::ImportIssueCode::DisconnectedWalkableArea)); + SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(issues)); +} + +SC_TEST(FacilityLayoutBuilderDoesNotInferAdjacencyAcrossWallSeam) { + safecrowd::domain::CanonicalGeometry geometry; + geometry.levelId = "L3"; + geometry.walkableAreas.push_back({ + .id = "walkable-01", + .polygon = { + .outline = { + {0.0, 0.0}, + {6.0, 0.0}, + {6.0, 4.0}, + {0.0, 4.0}, + }, + }, + .sourceIds = {"polyline-1"}, + }); + geometry.walkableAreas.push_back({ + .id = "walkable-02", + .polygon = { + .outline = { + {6.0, 0.0}, + {12.0, 0.0}, + {12.0, 4.0}, + {6.0, 4.0}, + }, + }, + .sourceIds = {"polyline-2"}, + }); + geometry.walls.push_back({ + .id = "wall-seam", + .segment = { + .start = {6.0, 0.0}, + .end = {6.0, 4.0}, + }, + .sourceIds = {"line-wall"}, + }); + geometry.openings.push_back({ + .id = "opening-exit", + .kind = safecrowd::domain::OpeningKind::Exit, + .span = { + .start = {12.0, 1.2}, + .end = {12.0, 2.8}, + }, + .width = 1.6, + .sourceIds = {"line-exit"}, + }); + + safecrowd::domain::FacilityLayoutBuilder builder; + const auto buildResult = builder.build(geometry); + + SC_EXPECT_EQ(buildResult.layout.connections.size(), std::size_t{1}); + SC_EXPECT_EQ(buildResult.layout.connections.front().kind, safecrowd::domain::ConnectionKind::Exit); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(buildResult.layout); + + SC_EXPECT_TRUE(containsIssueCode(issues, safecrowd::domain::ImportIssueCode::DisconnectedWalkableArea)); + SC_EXPECT_TRUE(safecrowd::domain::hasBlockingImportIssue(issues)); +} + +SC_TEST(FacilityLayoutBuilderShrinksAdjacencyPortalToRemainingWallGap) { + safecrowd::domain::CanonicalGeometry geometry; + geometry.levelId = "L4"; + geometry.walkableAreas.push_back({ + .id = "walkable-01", + .polygon = { + .outline = { + {0.0, 0.0}, + {6.0, 0.0}, + {6.0, 4.0}, + {0.0, 4.0}, + }, + }, + .sourceIds = {"polyline-1"}, + }); + geometry.walkableAreas.push_back({ + .id = "walkable-02", + .polygon = { + .outline = { + {6.0, 0.0}, + {12.0, 0.0}, + {12.0, 4.0}, + {6.0, 4.0}, + }, + }, + .sourceIds = {"polyline-2"}, + }); + geometry.walls.push_back({ + .id = "wall-lower", + .segment = { + .start = {6.0, 0.0}, + .end = {6.0, 1.5}, + }, + .sourceIds = {"line-wall-1"}, + }); + geometry.walls.push_back({ + .id = "wall-upper", + .segment = { + .start = {6.0, 2.5}, + .end = {6.0, 4.0}, + }, + .sourceIds = {"line-wall-2"}, + }); + geometry.openings.push_back({ + .id = "opening-exit", + .kind = safecrowd::domain::OpeningKind::Exit, + .span = { + .start = {12.0, 1.2}, + .end = {12.0, 2.8}, + }, + .width = 1.6, + .sourceIds = {"line-exit"}, + }); + + safecrowd::domain::FacilityLayoutBuilder builder; + const auto buildResult = builder.build(geometry); + + SC_EXPECT_EQ(buildResult.layout.connections.size(), std::size_t{2}); + SC_EXPECT_EQ(buildResult.layout.connections.back().kind, safecrowd::domain::ConnectionKind::Opening); + SC_EXPECT_NEAR(buildResult.layout.connections.back().effectiveWidth, 1.0, 1e-9); + + safecrowd::domain::ImportValidationService validator; + const auto issues = validator.validate(buildResult.layout); + + SC_EXPECT_TRUE(!containsIssueCode(issues, safecrowd::domain::ImportIssueCode::DisconnectedWalkableArea)); +}