From afcb482d6e95ab9cf5e204a2174b980a3c22c6de Mon Sep 17 00:00:00 2001 From: learncold Date: Fri, 3 Apr 2026 23:41:28 +0900 Subject: [PATCH] implement DXF happy-path import to canonical geometry --- CMakeLists.txt | 3 + src/domain/DxfImportService.cpp | 963 ++++++++++++++++++++++++++++++++ src/domain/DxfImportService.h | 13 + src/domain/ImportContracts.h | 1 + src/domain/ImportIssue.cpp | 6 + src/domain/ImportIssue.h | 3 + src/domain/RawImportModel.h | 16 +- tests/DxfImportServiceTests.cpp | 339 +++++++++++ tests/ImportContractsTests.cpp | 18 +- 9 files changed, 1354 insertions(+), 8 deletions(-) create mode 100644 src/domain/DxfImportService.cpp create mode 100644 src/domain/DxfImportService.h create mode 100644 tests/DxfImportServiceTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f37dc4..d52bce9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,6 +67,8 @@ add_library(safecrowd_domain STATIC src/domain/Geometry2D.h src/domain/RawImportModel.h src/domain/CanonicalGeometry.h + src/domain/DxfImportService.h + src/domain/DxfImportService.cpp src/domain/FacilityLayout2D.h src/domain/ImportIssue.h src/domain/ImportIssue.cpp @@ -100,6 +102,7 @@ if (BUILD_TESTING) tests/SafeCrowdDomainTests.cpp tests/EcsCoreTests.cpp tests/ImportContractsTests.cpp + tests/DxfImportServiceTests.cpp ) target_include_directories(safecrowd_tests diff --git a/src/domain/DxfImportService.cpp b/src/domain/DxfImportService.cpp new file mode 100644 index 0000000..e4f475f --- /dev/null +++ b/src/domain/DxfImportService.cpp @@ -0,0 +1,963 @@ +#include "domain/DxfImportService.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace safecrowd::domain { +namespace { + +constexpr double kPi = 3.14159265358979323846; + +struct DxfGroup { + int code{0}; + std::string value{}; +}; + +struct BlockDefinition { + std::string name{}; + std::vector polylines{}; + std::vector polygons{}; +}; + +enum class GeometrySemantic { + Unknown, + Wall, + Opening, + Exit, + Obstacle, + Walkable, +}; + +std::string trim(std::string value) { + const auto isSpace = [](unsigned char ch) { return std::isspace(ch) != 0; }; + + value.erase(value.begin(), std::find_if(value.begin(), value.end(), [&](unsigned char ch) { return !isSpace(ch); })); + value.erase(std::find_if(value.rbegin(), value.rend(), [&](unsigned char ch) { return !isSpace(ch); }).base(), value.end()); + return value; +} + +std::string toUpper(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { + return static_cast(std::toupper(ch)); + }); + return value; +} + +std::optional parseDouble(const std::string& text) { + try { + std::size_t index = 0; + const double value = std::stod(text, &index); + if (index == text.size()) { + return value; + } + } catch (...) { + } + + return std::nullopt; +} + +std::optional parseInt(const std::string& text) { + try { + std::size_t index = 0; + const int value = std::stoi(text, &index); + if (index == text.size()) { + return value; + } + } catch (...) { + } + + return std::nullopt; +} + +std::vector loadGroups(const std::filesystem::path& sourcePath) { + std::ifstream input(sourcePath); + if (!input) { + return {}; + } + + std::vector groups; + std::string codeLine; + std::string valueLine; + + while (std::getline(input, codeLine)) { + if (!std::getline(input, valueLine)) { + break; + } + + const auto code = parseInt(trim(codeLine)); + if (!code.has_value()) { + continue; + } + + groups.push_back({ + .code = *code, + .value = trim(valueLine), + }); + } + + return groups; +} + +ImportUnit parseUnits(const std::vector& groups) { + for (std::size_t i = 0; i + 1 < groups.size(); ++i) { + if (groups[i].code == 9 && toUpper(groups[i].value) == "$INSUNITS" && groups[i + 1].code == 70) { + const auto unitCode = parseInt(groups[i + 1].value); + if (!unitCode.has_value()) { + return ImportUnit::Unknown; + } + + switch (*unitCode) { + case 4: + return ImportUnit::Millimeter; + case 5: + return ImportUnit::Centimeter; + case 6: + return ImportUnit::Meter; + default: + return ImportUnit::Unknown; + } + } + } + + return ImportUnit::Unknown; +} + +bool hasMinimumVertices(const std::vector& vertices, std::size_t minimum) { + return vertices.size() >= minimum; +} + +Polygon2D toPolygon(const Polyline2D& polyline) { + return {.outline = polyline.vertices}; +} + +double segmentLength(const LineSegment2D& segment) { + const auto dx = segment.end.x - segment.start.x; + const auto dy = segment.end.y - segment.start.y; + return std::sqrt((dx * dx) + (dy * dy)); +} + +Point2D transformPoint(const Point2D& point, const RawBlockReference2D& block) { + const auto scaledX = point.x * block.scaleX; + const auto scaledY = point.y * block.scaleY; + const auto cosTheta = std::cos(block.rotationRadians); + const auto sinTheta = std::sin(block.rotationRadians); + + return { + .x = (scaledX * cosTheta) - (scaledY * sinTheta) + block.insertionPoint.x, + .y = (scaledX * sinTheta) + (scaledY * cosTheta) + block.insertionPoint.y, + }; +} + +Polyline2D transformPolyline(const Polyline2D& polyline, const RawBlockReference2D& block) { + Polyline2D transformed{.closed = polyline.closed}; + transformed.vertices.reserve(polyline.vertices.size()); + + for (const auto& vertex : polyline.vertices) { + transformed.vertices.push_back(transformPoint(vertex, block)); + } + + return transformed; +} + +Polygon2D transformPolygon(const Polygon2D& polygon, const RawBlockReference2D& block) { + Polygon2D transformed; + transformed.outline.reserve(polygon.outline.size()); + + for (const auto& vertex : polygon.outline) { + transformed.outline.push_back(transformPoint(vertex, block)); + } + + transformed.holes.reserve(polygon.holes.size()); + for (const auto& hole : polygon.holes) { + std::vector transformedHole; + transformedHole.reserve(hole.size()); + for (const auto& vertex : hole) { + transformedHole.push_back(transformPoint(vertex, block)); + } + transformed.holes.push_back(std::move(transformedHole)); + } + + return transformed; +} + +GeometrySemantic classifySemantic(const SourceTrace& trace) { + const auto signature = toUpper(trace.layerName + " " + trace.objectName); + + if (signature.find("EXIT") != std::string::npos) { + return GeometrySemantic::Exit; + } + if (signature.find("OPEN") != std::string::npos || signature.find("DOOR") != std::string::npos || signature.find("PORTAL") != std::string::npos) { + return GeometrySemantic::Opening; + } + if (signature.find("WALL") != std::string::npos || signature.find("BARRIER") != std::string::npos) { + return GeometrySemantic::Wall; + } + if (signature.find("OBST") != std::string::npos || signature.find("COLUMN") != std::string::npos || signature.find("FURN") != std::string::npos) { + return GeometrySemantic::Obstacle; + } + if (signature.find("WALK") != std::string::npos || signature.find("FLOOR") != std::string::npos || signature.find("SPACE") != std::string::npos) { + return GeometrySemantic::Walkable; + } + + return GeometrySemantic::Unknown; +} + +OpeningKind toOpeningKind(GeometrySemantic semantic) { + switch (semantic) { + case GeometrySemantic::Exit: + return OpeningKind::Exit; + case GeometrySemantic::Opening: + return OpeningKind::Doorway; + default: + return OpeningKind::Unknown; + } +} + +SourceTrace inheritBlockChildTrace(const SourceTrace& insertTrace, SourceTrace childTrace) { + childTrace.parentSourceId = insertTrace.sourceId; + + if (childTrace.layerName.empty() || childTrace.layerName == "0") { + childTrace.layerName = insertTrace.layerName; + } + if (childTrace.objectName.empty()) { + childTrace.objectName = insertTrace.objectName; + } + + return childTrace; +} + +std::vector collectSourceIds(const SourceTrace& trace) { + std::vector sourceIds; + + if (!trace.sourceId.empty()) { + sourceIds.push_back(trace.sourceId); + } + if (!trace.parentSourceId.empty() && trace.parentSourceId != trace.sourceId) { + sourceIds.push_back(trace.parentSourceId); + } + + return sourceIds; +} + +class DxfAsciiParser { +public: + DxfAsciiParser(std::filesystem::path sourcePath, std::vector groups) + : sourcePath_(std::move(sourcePath)), + groups_(std::move(groups)) { + } + + ImportResult parse() { + ImportResult result; + + if (groups_.empty()) { + result.issues.push_back({ + .severity = ImportIssueSeverity::Error, + .code = ImportIssueCode::FileReadFailed, + .message = "Failed to read DXF file.", + .sourceId = sourcePath_.generic_string(), + }); + result.reviewStatus = ImportReviewStatus::Rejected; + return result; + } + + rawModel_.format = ImportedFileFormat::Dxf; + rawModel_.unit = parseUnits(groups_); + rawModel_.sourceDocumentId = sourcePath_.filename().generic_string(); + rawModel_.levelId = sourcePath_.stem().generic_string(); + canonicalGeometry_.levelId = rawModel_.levelId; + + parseSections(); + buildCanonicalGeometry(); + + if (canonicalGeometry_.walkableAreas.empty() + && canonicalGeometry_.walls.empty() + && canonicalGeometry_.openings.empty() + && canonicalGeometry_.obstacles.empty()) { + issues_.push_back({ + .severity = ImportIssueSeverity::Error, + .code = ImportIssueCode::MissingSourceGeometry, + .message = "No importable geometry was extracted from the DXF file.", + .sourceId = rawModel_.sourceDocumentId, + }); + } + + result.rawModel = rawModel_; + result.canonicalGeometry = canonicalGeometry_; + result.issues = issues_; + result.traceRefs = traceRefs_; + result.reviewStatus = hasBlockingImportIssue(result.issues) ? ImportReviewStatus::Rejected : ImportReviewStatus::Pending; + return result; + } + +private: + std::filesystem::path sourcePath_{}; + std::vector groups_{}; + std::size_t index_{0}; + RawImportModel rawModel_{}; + CanonicalGeometry canonicalGeometry_{}; + std::vector issues_{}; + std::vector traceRefs_{}; + std::map blockDefinitions_{}; + std::size_t sourceCounter_{0}; + std::size_t walkableCounter_{0}; + std::size_t wallCounter_{0}; + std::size_t openingCounter_{0}; + std::size_t obstacleCounter_{0}; + + bool hasMore() const noexcept { + return index_ < groups_.size(); + } + + const DxfGroup& current() const { + return groups_[index_]; + } + + bool at(int code, std::string_view value) const { + return hasMore() && current().code == code && current().value == value; + } + + const DxfGroup& advance() { + return groups_[index_++]; + } + + void parseSections() { + while (hasMore()) { + if (!at(0, "SECTION")) { + advance(); + continue; + } + + advance(); + if (!hasMore() || current().code != 2) { + continue; + } + + const auto sectionName = current().value; + advance(); + + if (sectionName == "BLOCKS") { + parseBlocksSection(); + } else if (sectionName == "ENTITIES") { + parseEntitiesSection(); + } else { + skipToSectionEnd(); + } + } + } + + void skipToSectionEnd() { + while (hasMore()) { + if (at(0, "ENDSEC")) { + advance(); + return; + } + advance(); + } + } + + void parseBlocksSection() { + while (hasMore() && !at(0, "ENDSEC")) { + if (at(0, "BLOCK")) { + parseBlockDefinition(); + } else { + advance(); + } + } + + if (at(0, "ENDSEC")) { + advance(); + } + } + + void parseBlockDefinition() { + advance(); + + BlockDefinition definition; + + while (hasMore()) { + if (at(0, "ENDBLK")) { + advance(); + break; + } + + if (at(0, "LWPOLYLINE")) { + auto entity = parseLwPolylineEntity(); + if (std::holds_alternative(entity.payload)) { + definition.polylines.push_back({ + .trace = entity.trace, + .geometry = std::get(entity.payload), + .metadata = entity.metadata, + }); + } else if (std::holds_alternative(entity.payload)) { + definition.polygons.push_back({ + .trace = entity.trace, + .geometry = std::get(entity.payload), + .metadata = entity.metadata, + }); + } + continue; + } + + if (at(0, "POLYLINE")) { + auto entity = parseClassicPolylineEntity(); + if (std::holds_alternative(entity.payload)) { + definition.polylines.push_back({ + .trace = entity.trace, + .geometry = std::get(entity.payload), + .metadata = entity.metadata, + }); + } else if (std::holds_alternative(entity.payload)) { + definition.polygons.push_back({ + .trace = entity.trace, + .geometry = std::get(entity.payload), + .metadata = entity.metadata, + }); + } + continue; + } + + if (at(0, "LINE")) { + auto entity = parseLineEntity(); + if (std::holds_alternative(entity.payload)) { + const auto& segment = std::get(entity.payload); + definition.polylines.push_back({ + .trace = entity.trace, + .geometry = { + .vertices = {segment.start, segment.end}, + .closed = false, + }, + .metadata = entity.metadata, + }); + } + continue; + } + + if (current().code == 2 && definition.name.empty()) { + definition.name = current().value; + } + + advance(); + } + + if (!definition.name.empty()) { + blockDefinitions_[definition.name] = std::move(definition); + } + } + + void parseEntitiesSection() { + while (hasMore() && !at(0, "ENDSEC")) { + if (at(0, "LWPOLYLINE")) { + rawModel_.entities.push_back(parseLwPolylineEntity()); + continue; + } + + if (at(0, "POLYLINE")) { + rawModel_.entities.push_back(parseClassicPolylineEntity()); + continue; + } + + if (at(0, "LINE")) { + rawModel_.entities.push_back(parseLineEntity()); + continue; + } + + if (at(0, "INSERT")) { + rawModel_.entities.push_back(parseInsertEntity()); + continue; + } + + if (current().code == 0) { + issues_.push_back({ + .severity = ImportIssueSeverity::Warning, + .code = ImportIssueCode::UnsupportedEntity, + .message = "Unsupported DXF entity type: " + current().value, + .sourceId = rawModel_.sourceDocumentId, + }); + } + + advance(); + } + + if (at(0, "ENDSEC")) { + advance(); + } + } + + RawEntity2D parseLineEntity() { + RawEntity2D entity; + entity.kind = RawEntityKind::Line; + + advance(); + + Point2D start{}; + Point2D end{}; + + while (hasMore() && current().code != 0) { + const auto group = advance(); + + switch (group.code) { + case 8: + entity.trace.layerName = group.value; + break; + case 10: + start.x = parseDouble(group.value).value_or(start.x); + break; + case 20: + start.y = parseDouble(group.value).value_or(start.y); + break; + case 11: + end.x = parseDouble(group.value).value_or(end.x); + break; + case 21: + end.y = parseDouble(group.value).value_or(end.y); + break; + default: + break; + } + } + + entity.trace.sourceId = nextSourceId("line"); + entity.payload = LineSegment2D{.start = start, .end = end}; + return entity; + } + + RawEntity2D parseLwPolylineEntity() { + RawEntity2D entity; + entity.kind = RawEntityKind::Polyline; + + advance(); + + Polyline2D polyline; + std::optional pendingX; + + while (hasMore() && current().code != 0) { + const auto group = advance(); + + switch (group.code) { + case 8: + entity.trace.layerName = group.value; + break; + case 70: { + const auto flags = parseInt(group.value).value_or(0); + polyline.closed = (flags & 1) != 0; + break; + } + case 10: + pendingX = parseDouble(group.value); + break; + case 20: + if (pendingX.has_value()) { + polyline.vertices.push_back({ + .x = *pendingX, + .y = parseDouble(group.value).value_or(0.0), + }); + pendingX.reset(); + } + break; + default: + break; + } + } + + entity.trace.sourceId = nextSourceId("polyline"); + + if (polyline.closed && hasMinimumVertices(polyline.vertices, 3)) { + entity.kind = RawEntityKind::Polygon; + entity.payload = toPolygon(polyline); + } else { + entity.payload = polyline; + } + + return entity; + } + + RawEntity2D parseClassicPolylineEntity() { + RawEntity2D entity; + entity.kind = RawEntityKind::Polyline; + + advance(); + + Polyline2D polyline; + + while (hasMore() && current().code != 0) { + const auto group = advance(); + + switch (group.code) { + case 8: + entity.trace.layerName = group.value; + break; + case 70: { + const auto flags = parseInt(group.value).value_or(0); + polyline.closed = (flags & 1) != 0; + break; + } + default: + break; + } + } + + while (hasMore()) { + if (at(0, "SEQEND")) { + advance(); + break; + } + + if (!at(0, "VERTEX")) { + if (current().code == 0) { + break; + } + advance(); + continue; + } + + advance(); + + Point2D vertex{}; + std::optional vertexX; + + while (hasMore() && current().code != 0) { + const auto group = advance(); + + switch (group.code) { + case 8: + if (entity.trace.layerName.empty() || entity.trace.layerName == "0") { + entity.trace.layerName = group.value; + } + break; + case 10: + vertexX = parseDouble(group.value); + break; + case 20: + if (vertexX.has_value()) { + vertex = { + .x = *vertexX, + .y = parseDouble(group.value).value_or(0.0), + }; + } + break; + default: + break; + } + } + + polyline.vertices.push_back(vertex); + } + + entity.trace.sourceId = nextSourceId("polyline"); + + if (polyline.closed && hasMinimumVertices(polyline.vertices, 3)) { + entity.kind = RawEntityKind::Polygon; + entity.payload = toPolygon(polyline); + } else { + entity.payload = polyline; + } + + return entity; + } + + RawEntity2D parseInsertEntity() { + RawEntity2D entity; + entity.kind = RawEntityKind::BlockReference; + + RawBlockReference2D blockReference; + std::string blockName; + + advance(); + + while (hasMore() && current().code != 0) { + const auto group = advance(); + + switch (group.code) { + case 2: + blockName = group.value; + blockReference.blockName = group.value; + entity.trace.objectName = group.value; + break; + case 8: + entity.trace.layerName = group.value; + break; + case 10: + blockReference.insertionPoint.x = parseDouble(group.value).value_or(blockReference.insertionPoint.x); + break; + case 20: + blockReference.insertionPoint.y = parseDouble(group.value).value_or(blockReference.insertionPoint.y); + break; + case 41: + blockReference.scaleX = parseDouble(group.value).value_or(blockReference.scaleX); + break; + case 42: + blockReference.scaleY = parseDouble(group.value).value_or(blockReference.scaleY); + break; + case 50: + blockReference.rotationRadians = parseDouble(group.value).value_or(0.0) * (kPi / 180.0); + break; + default: + break; + } + } + + entity.trace.sourceId = nextSourceId("insert"); + + const auto blockIt = blockDefinitions_.find(blockName); + if (blockIt != blockDefinitions_.end()) { + blockReference.polylines = blockIt->second.polylines; + blockReference.polygons = blockIt->second.polygons; + } else { + issues_.push_back({ + .severity = ImportIssueSeverity::Error, + .code = ImportIssueCode::MissingBlockDefinition, + .message = "DXF insert references a missing block definition: " + blockName, + .sourceId = entity.trace.sourceId, + .targetId = blockName, + }); + } + + entity.payload = std::move(blockReference); + return entity; + } + + std::string nextSourceId(std::string_view prefix) { + ++sourceCounter_; + std::ostringstream stream; + stream << prefix << '-' << sourceCounter_; + return stream.str(); + } + + void appendTraceRef(const std::string& targetId, const std::vector& sourceIds) { + traceRefs_.push_back({ + .targetId = targetId, + .sourceIds = sourceIds, + .canonicalIds = {targetId}, + }); + } + + void addWallFromPolyline(const Polyline2D& polyline, const SourceTrace& trace) { + if (!hasMinimumVertices(polyline.vertices, 2)) { + issues_.push_back({ + .severity = ImportIssueSeverity::Warning, + .code = ImportIssueCode::InvalidGeometry, + .message = "Wall polyline does not have enough vertices.", + .sourceId = trace.sourceId, + }); + return; + } + + const auto sourceIds = collectSourceIds(trace); + const auto segmentCount = polyline.closed ? polyline.vertices.size() : polyline.vertices.size() - 1; + for (std::size_t i = 0; i < segmentCount; ++i) { + const auto& start = polyline.vertices[i]; + const auto& end = polyline.vertices[(i + 1) % polyline.vertices.size()]; + const auto canonicalId = nextCanonicalId("wall", wallCounter_); + + canonicalGeometry_.walls.push_back({ + .id = canonicalId, + .segment = {.start = start, .end = end}, + .sourceIds = sourceIds, + }); + appendTraceRef(canonicalId, sourceIds); + } + } + + void addOpeningFromPolyline(const Polyline2D& polyline, GeometrySemantic semantic, const SourceTrace& trace) { + if (!hasMinimumVertices(polyline.vertices, 2)) { + issues_.push_back({ + .severity = ImportIssueSeverity::Warning, + .code = ImportIssueCode::InvalidGeometry, + .message = "Opening geometry does not have enough vertices.", + .sourceId = trace.sourceId, + }); + return; + } + + const LineSegment2D span{ + .start = polyline.vertices.front(), + .end = polyline.vertices.back(), + }; + const auto sourceIds = collectSourceIds(trace); + const auto canonicalId = nextCanonicalId("opening", openingCounter_); + + canonicalGeometry_.openings.push_back({ + .id = canonicalId, + .kind = toOpeningKind(semantic), + .span = span, + .width = segmentLength(span), + .sourceIds = sourceIds, + }); + appendTraceRef(canonicalId, sourceIds); + } + + void addWalkablePolygon(const Polygon2D& polygon, const SourceTrace& trace) { + if (!hasMinimumVertices(polygon.outline, 3)) { + issues_.push_back({ + .severity = ImportIssueSeverity::Warning, + .code = ImportIssueCode::InvalidGeometry, + .message = "Walkable polygon does not have enough vertices.", + .sourceId = trace.sourceId, + }); + return; + } + + const auto sourceIds = collectSourceIds(trace); + const auto canonicalId = nextCanonicalId("walkable", walkableCounter_); + canonicalGeometry_.walkableAreas.push_back({ + .id = canonicalId, + .polygon = polygon, + .sourceIds = sourceIds, + }); + appendTraceRef(canonicalId, sourceIds); + } + + void addObstaclePolygon(const Polygon2D& polygon, const SourceTrace& trace) { + if (!hasMinimumVertices(polygon.outline, 3)) { + issues_.push_back({ + .severity = ImportIssueSeverity::Warning, + .code = ImportIssueCode::InvalidGeometry, + .message = "Obstacle polygon does not have enough vertices.", + .sourceId = trace.sourceId, + }); + return; + } + + const auto sourceIds = collectSourceIds(trace); + const auto canonicalId = nextCanonicalId("obstacle", obstacleCounter_); + canonicalGeometry_.obstacles.push_back({ + .id = canonicalId, + .footprint = polygon, + .sourceIds = sourceIds, + }); + appendTraceRef(canonicalId, sourceIds); + } + + void buildCanonicalGeometry() { + for (const auto& entity : rawModel_.entities) { + const auto semantic = classifySemantic(entity.trace); + const auto sourceIds = collectSourceIds(entity.trace); + + if (std::holds_alternative(entity.payload)) { + const auto& line = std::get(entity.payload); + if (semantic == GeometrySemantic::Wall) { + const auto canonicalId = nextCanonicalId("wall", wallCounter_); + canonicalGeometry_.walls.push_back({ + .id = canonicalId, + .segment = line, + .sourceIds = sourceIds, + }); + appendTraceRef(canonicalId, sourceIds); + } else if (semantic == GeometrySemantic::Opening || semantic == GeometrySemantic::Exit) { + const auto canonicalId = nextCanonicalId("opening", openingCounter_); + canonicalGeometry_.openings.push_back({ + .id = canonicalId, + .kind = toOpeningKind(semantic), + .span = line, + .width = segmentLength(line), + .sourceIds = sourceIds, + }); + appendTraceRef(canonicalId, sourceIds); + } + continue; + } + + if (std::holds_alternative(entity.payload)) { + const auto& polyline = std::get(entity.payload); + if (semantic == GeometrySemantic::Wall) { + addWallFromPolyline(polyline, entity.trace); + } else if (semantic == GeometrySemantic::Opening || semantic == GeometrySemantic::Exit) { + addOpeningFromPolyline(polyline, semantic, entity.trace); + } + continue; + } + + if (std::holds_alternative(entity.payload)) { + const auto& polygon = std::get(entity.payload); + if (semantic == GeometrySemantic::Walkable) { + addWalkablePolygon(polygon, entity.trace); + } else if (semantic == GeometrySemantic::Obstacle) { + addObstaclePolygon(polygon, entity.trace); + } else if (semantic == GeometrySemantic::Wall) { + addWallFromPolyline({ + .vertices = polygon.outline, + .closed = true, + }, entity.trace); + } + continue; + } + + if (!std::holds_alternative(entity.payload)) { + continue; + } + + const auto& block = std::get(entity.payload); + for (const auto& polylineChild : block.polylines) { + const auto childTrace = inheritBlockChildTrace(entity.trace, polylineChild.trace); + const auto childSemantic = classifySemantic(childTrace); + const auto transformed = transformPolyline(polylineChild.geometry, block); + if (childSemantic == GeometrySemantic::Wall) { + addWallFromPolyline(transformed, childTrace); + } else if (childSemantic == GeometrySemantic::Opening || childSemantic == GeometrySemantic::Exit) { + addOpeningFromPolyline(transformed, childSemantic, childTrace); + } else if (childSemantic == GeometrySemantic::Obstacle && transformed.closed) { + addObstaclePolygon(toPolygon(transformed), childTrace); + } else if (childSemantic == GeometrySemantic::Walkable && transformed.closed) { + addWalkablePolygon(toPolygon(transformed), childTrace); + } + } + + for (const auto& polygonChild : block.polygons) { + const auto childTrace = inheritBlockChildTrace(entity.trace, polygonChild.trace); + const auto childSemantic = classifySemantic(childTrace); + const auto transformed = transformPolygon(polygonChild.geometry, block); + if (childSemantic == GeometrySemantic::Obstacle) { + addObstaclePolygon(transformed, childTrace); + } else if (childSemantic == GeometrySemantic::Walkable) { + addWalkablePolygon(transformed, childTrace); + } else if (childSemantic == GeometrySemantic::Wall) { + addWallFromPolyline({ + .vertices = transformed.outline, + .closed = true, + }, childTrace); + } + } + } + } + + std::string nextCanonicalId(std::string_view prefix, std::size_t& counter) { + ++counter; + std::ostringstream stream; + stream << prefix << '-' << counter; + return stream.str(); + } +}; + +} // namespace + +ImportResult DxfImportService::importFile(const ImportRequest& request) const { + ImportResult result; + + const auto extension = toUpper(request.sourcePath.extension().generic_string()); + if (request.requestedFormat == ImportedFileFormat::Ifc || (!extension.empty() && extension != ".DXF")) { + result.issues.push_back({ + .severity = ImportIssueSeverity::Error, + .code = ImportIssueCode::UnsupportedFormat, + .message = "DxfImportService only supports DXF files.", + .sourceId = request.sourcePath.generic_string(), + .isBlocking = true, + }); + result.reviewStatus = ImportReviewStatus::Rejected; + return result; + } + + auto groups = loadGroups(request.sourcePath); + DxfAsciiParser parser(request.sourcePath, std::move(groups)); + return parser.parse(); +} + +} // namespace safecrowd::domain diff --git a/src/domain/DxfImportService.h b/src/domain/DxfImportService.h new file mode 100644 index 0000000..a51f9c9 --- /dev/null +++ b/src/domain/DxfImportService.h @@ -0,0 +1,13 @@ +#pragma once + +#include "domain/ImportOrchestrator.h" +#include "domain/ImportResult.h" + +namespace safecrowd::domain { + +class DxfImportService { +public: + ImportResult importFile(const ImportRequest& request) const; +}; + +} // namespace safecrowd::domain diff --git a/src/domain/ImportContracts.h b/src/domain/ImportContracts.h index 4b383f7..44bdc1e 100644 --- a/src/domain/ImportContracts.h +++ b/src/domain/ImportContracts.h @@ -1,6 +1,7 @@ #pragma once #include "domain/CanonicalGeometry.h" +#include "domain/DxfImportService.h" #include "domain/FacilityLayout2D.h" #include "domain/Geometry2D.h" #include "domain/ImportIssue.h" diff --git a/src/domain/ImportIssue.cpp b/src/domain/ImportIssue.cpp index 64f8f74..7454a93 100644 --- a/src/domain/ImportIssue.cpp +++ b/src/domain/ImportIssue.cpp @@ -23,10 +23,16 @@ const char* toString(ImportIssueCode code) noexcept { switch (code) { case ImportIssueCode::Unknown: return "Unknown"; + case ImportIssueCode::UnsupportedFormat: + return "UnsupportedFormat"; + case ImportIssueCode::FileReadFailed: + return "FileReadFailed"; case ImportIssueCode::UnsupportedEntity: return "UnsupportedEntity"; case ImportIssueCode::MissingSourceGeometry: return "MissingSourceGeometry"; + case ImportIssueCode::MissingBlockDefinition: + return "MissingBlockDefinition"; case ImportIssueCode::InvalidGeometry: return "InvalidGeometry"; case ImportIssueCode::DisconnectedWalkableArea: diff --git a/src/domain/ImportIssue.h b/src/domain/ImportIssue.h index 3875987..14f530d 100644 --- a/src/domain/ImportIssue.h +++ b/src/domain/ImportIssue.h @@ -13,8 +13,11 @@ enum class ImportIssueSeverity { enum class ImportIssueCode { Unknown, + UnsupportedFormat, + FileReadFailed, UnsupportedEntity, MissingSourceGeometry, + MissingBlockDefinition, InvalidGeometry, DisconnectedWalkableArea, MissingExit, diff --git a/src/domain/RawImportModel.h b/src/domain/RawImportModel.h index 1477563..b64a2bc 100644 --- a/src/domain/RawImportModel.h +++ b/src/domain/RawImportModel.h @@ -40,14 +40,26 @@ struct SourceTrace { std::string externalId{}; }; +struct RawTracedPolyline2D { + SourceTrace trace{}; + Polyline2D geometry{}; + std::map metadata{}; +}; + +struct RawTracedPolygon2D { + SourceTrace trace{}; + Polygon2D geometry{}; + std::map metadata{}; +}; + struct RawBlockReference2D { std::string blockName{}; Point2D insertionPoint{}; double rotationRadians{0.0}; double scaleX{1.0}; double scaleY{1.0}; - std::vector polylines{}; - std::vector polygons{}; + std::vector polylines{}; + std::vector polygons{}; }; struct RawIfcElement2D { diff --git a/tests/DxfImportServiceTests.cpp b/tests/DxfImportServiceTests.cpp new file mode 100644 index 0000000..89dc917 --- /dev/null +++ b/tests/DxfImportServiceTests.cpp @@ -0,0 +1,339 @@ +#include +#include +#include + +#include "TestSupport.h" + +#include "domain/DxfImportService.h" + +namespace { + +std::filesystem::path writeTempFile(const std::string& name, const std::string& content) { + const auto path = std::filesystem::temp_directory_path() / name; + std::ofstream output(path, std::ios::trunc); + output << content; + output.close(); + return path; +} + +const char* kHappyPathDxf = R"(0 +SECTION +2 +HEADER +9 +$INSUNITS +70 +6 +0 +ENDSEC +0 +SECTION +2 +BLOCKS +0 +BLOCK +2 +EXIT_PORTAL +8 +0 +0 +LWPOLYLINE +8 +0 +90 +2 +70 +0 +10 +0 +20 +0 +10 +1.2 +20 +0 +0 +ENDBLK +0 +BLOCK +2 +OBSTACLE_BOX +8 +0 +0 +LWPOLYLINE +8 +0 +90 +4 +70 +1 +10 +0 +20 +0 +10 +1 +20 +0 +10 +1 +20 +1 +10 +0 +20 +1 +0 +ENDBLK +0 +ENDSEC +0 +SECTION +2 +ENTITIES +0 +LWPOLYLINE +8 +WALKABLE +90 +4 +70 +1 +10 +0 +20 +0 +10 +12 +20 +0 +10 +12 +20 +8 +10 +0 +20 +8 +0 +LWPOLYLINE +8 +WALL +90 +4 +70 +1 +10 +0 +20 +0 +10 +12 +20 +0 +10 +12 +20 +8 +10 +0 +20 +8 +0 +INSERT +8 +EXIT +2 +EXIT_PORTAL +10 +12 +20 +3 +41 +1 +42 +1 +50 +0 +0 +INSERT +8 +OBSTACLE +2 +OBSTACLE_BOX +10 +4 +20 +3 +41 +1.5 +42 +1 +50 +0 +0 +ENDSEC +0 +EOF +)"; + +const char* kMissingBlockDxf = R"(0 +SECTION +2 +ENTITIES +0 +INSERT +8 +EXIT +2 +MISSING_BLOCK +10 +1 +20 +2 +0 +ENDSEC +0 +EOF +)"; + +const char* kClassicPolylineDxf = R"(0 +SECTION +2 +HEADER +9 +$INSUNITS +70 +6 +0 +ENDSEC +0 +SECTION +2 +ENTITIES +0 +POLYLINE +8 +WALKABLE +70 +1 +0 +VERTEX +10 +0 +20 +0 +0 +VERTEX +10 +6 +20 +0 +0 +VERTEX +10 +6 +20 +4 +0 +VERTEX +10 +0 +20 +4 +0 +SEQEND +0 +POLYLINE +8 +WALL +70 +0 +0 +VERTEX +10 +0 +20 +0 +0 +VERTEX +10 +6 +20 +0 +0 +SEQEND +0 +EOF +)"; + +} // namespace + +SC_TEST(DxfImportServiceBuildsCanonicalGeometryFromHappyPathSample) { + const auto sourcePath = writeTempFile("safecrowd-happy-path.dxf", kHappyPathDxf); + + 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.rawModel.has_value()); + SC_EXPECT_TRUE(result.canonicalGeometry.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.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.front().canonicalIds.front(), result.traceRefs.front().targetId); + SC_EXPECT_TRUE(!safecrowd::domain::hasBlockingImportIssue(result.issues)); + SC_EXPECT_EQ(result.reviewStatus, safecrowd::domain::ImportReviewStatus::Pending); + + std::filesystem::remove(sourcePath); +} + +SC_TEST(DxfImportServiceReportsMissingBlockDefinitions) { + const auto sourcePath = writeTempFile("safecrowd-missing-block.dxf", kMissingBlockDxf); + + 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.rawModel.has_value()); + SC_EXPECT_TRUE(result.canonicalGeometry.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); + + std::filesystem::remove(sourcePath); +} + +SC_TEST(DxfImportServiceImportsClassicPolylineEntities) { + const auto sourcePath = writeTempFile("safecrowd-classic-polyline.dxf", kClassicPolylineDxf); + + 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.rawModel.has_value()); + SC_EXPECT_TRUE(result.canonicalGeometry.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_TRUE(!safecrowd::domain::hasBlockingImportIssue(result.issues)); + + std::filesystem::remove(sourcePath); +} diff --git a/tests/ImportContractsTests.cpp b/tests/ImportContractsTests.cpp index 4fc8cef..d1e369a 100644 --- a/tests/ImportContractsTests.cpp +++ b/tests/ImportContractsTests.cpp @@ -57,13 +57,19 @@ SC_TEST(ImportContractsCaptureSprintOneLayoutFields) { .scaleY = 1.0, .polylines = { { - .vertices = { - {1.5, 1.0}, - {2.5, 1.0}, - {2.5, 2.0}, - {1.5, 2.0}, + .trace = { + .sourceId = "block-child-01", + .layerName = "STAIR", + }, + .geometry = { + .vertices = { + {1.5, 1.0}, + {2.5, 1.0}, + {2.5, 2.0}, + {1.5, 2.0}, + }, + .closed = true, }, - .closed = true, }, }, };