diff --git a/CMakeLists.txt b/CMakeLists.txt index e87b919..68681f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ add_library(fesa_core STATIC src/fesa/fem/dof_manager.cpp src/fesa/fem/gauss_rule.cpp src/fesa/fem/line2_shape.cpp + src/fesa/io/abaqus/active_input.cpp src/fesa/io/abaqus/parser.cpp src/fesa/io/abaqus/semantic_mapper.cpp src/fesa/io/abaqus/set_resolver.cpp diff --git a/include/fesa/io/abaqus/active_input.hpp b/include/fesa/io/abaqus/active_input.hpp new file mode 100644 index 0000000..69b7c55 --- /dev/null +++ b/include/fesa/io/abaqus/active_input.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace fesa { + +struct ActiveInputView final { + bool flat = true; + std::string part_name; + std::string instance_name; + std::span part_records; + std::span assembly_records; +}; + +struct ActiveInputResult final { + std::optional input; + std::vector diagnostics; +}; + +[[nodiscard]] ActiveInputResult select_active_input(const ParsedDeck& deck); + +} // namespace fesa diff --git a/include/fesa/io/abaqus/deck_record.hpp b/include/fesa/io/abaqus/deck_record.hpp index fa2bcb9..8e54db2 100644 --- a/include/fesa/io/abaqus/deck_record.hpp +++ b/include/fesa/io/abaqus/deck_record.hpp @@ -28,6 +28,7 @@ struct ParsedInstance final { std::string name; std::string part_name; std::vector> transform_data; + std::vector transform_sources; SourceLocation source; }; diff --git a/src/fesa/io/abaqus/active_input.cpp b/src/fesa/io/abaqus/active_input.cpp new file mode 100644 index 0000000..3be7c63 --- /dev/null +++ b/src/fesa/io/abaqus/active_input.cpp @@ -0,0 +1,135 @@ +#include + +#include +#include +#include +#include + +namespace fesa { +namespace { + +const std::string* parameter( + const DeckRecord& record, + const std::string_view name) { + const auto found = record.parameters.find(name); + return found == record.parameters.end() ? nullptr : &found->second; +} + +bool is_mesh_record(const DeckRecord& record) { + return record.keyword == "NODE" || record.keyword == "ELEMENT"; +} + +ActiveInputResult failure( + std::string code, + std::string message, + const SourceLocation& source) { + return { + std::nullopt, + {{ + DiagnosticStage::semantic, + Severity::error, + std::move(code), + std::move(message), + source, + }}, + }; +} + +} // namespace + +ActiveInputResult select_active_input(const ParsedDeck& deck) { + const bool hierarchical = + !deck.parts.empty() || deck.assembly.has_value(); + if (!hierarchical) { + return { + ActiveInputView{ + true, + {}, + {}, + deck.global_records, + {}, + }, + {}, + }; + } + + const auto flat_mesh = + std::ranges::find_if(deck.global_records, is_mesh_record); + if (flat_mesh != deck.global_records.end()) { + return failure( + "abaqus.semantic.mixed_mesh_organization", + "Flat mesh records cannot be mixed with Part/Assembly input.", + flat_mesh->source); + } + + if (!deck.assembly.has_value()) { + const SourceLocation source = + deck.parts.empty() ? SourceLocation{} : deck.parts.front().source; + return failure( + "abaqus.semantic.assembly_count", + "Hierarchical Phase 1 input requires exactly one Assembly.", + source); + } + + const ParsedAssembly& assembly = *deck.assembly; + if (assembly.instances.size() != 1U) { + const SourceLocation& source = assembly.instances.size() > 1U + ? assembly.instances[1].source + : assembly.source; + return failure( + "abaqus.semantic.instance_count", + "Phase 1 requires exactly one Instance.", + source); + } + + const ParsedInstance& instance = assembly.instances.front(); + if (!instance.transform_data.empty()) { + const SourceLocation& source = instance.transform_sources.empty() + ? instance.source + : instance.transform_sources.front(); + return failure( + "abaqus.semantic.instance_transform", + "Instance translation and rotation data are unsupported.", + source); + } + + const auto part = + std::ranges::find(deck.parts, instance.part_name, &ParsedPart::name); + if (part == deck.parts.end()) { + return failure( + "abaqus.semantic.missing_part", + "Instance '" + instance.name + "' references missing Part '" + + instance.part_name + "'.", + instance.source); + } + + for (const DeckRecord& record : assembly.records) { + if (record.keyword != "NSET" && record.keyword != "ELSET") { + continue; + } + const std::string* record_instance = parameter(record, "INSTANCE"); + if (record_instance == nullptr || *record_instance != instance.name) { + const std::string* name = parameter( + record, record.keyword == "NSET" ? "NSET" : "ELSET"); + return failure( + "abaqus.semantic.wrong_instance", + "Assembly set '" + + (name == nullptr ? std::string{} : *name) + + "' must reference the active Instance.", + record.source); + } + } + + return { + ActiveInputView{ + false, + part->name, + instance.name, + part->records, + assembly.records, + }, + {}, + }; +} + +} // namespace fesa diff --git a/src/fesa/io/abaqus/parser.cpp b/src/fesa/io/abaqus/parser.cpp index e182e30..2820614 100644 --- a/src/fesa/io/abaqus/parser.cpp +++ b/src/fesa/io/abaqus/parser.cpp @@ -214,6 +214,11 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { const std::vector fields = split_fields(content); if (scope == Scope::instance) { current_instance->transform_data.push_back(fields); + current_instance->transform_sources.push_back(SourceLocation{ + path, + line_number, + first_nonspace + 1U, + }); } else if (current_record != nullptr) { current_record->data.push_back(fields); current_record->data_sources.push_back(SourceLocation{ @@ -327,7 +332,7 @@ ParseDeckResult parse_deck(const std::filesystem::path& path) { return missing_parameter(keyword, "PART"); } current_instance = - ParsedInstance{*name, *part_name, {}, source}; + ParsedInstance{*name, *part_name, {}, {}, source}; scope = Scope::instance; continue; } diff --git a/src/fesa/io/abaqus/semantic_mapper.cpp b/src/fesa/io/abaqus/semantic_mapper.cpp index fefcda7..9dc792f 100644 --- a/src/fesa/io/abaqus/semantic_mapper.cpp +++ b/src/fesa/io/abaqus/semantic_mapper.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include #include @@ -26,24 +28,26 @@ const std::string* parameter( return found == record.parameters.end() ? nullptr : &found->second; } -bool is_mesh_record(const DeckRecord& record) { - return record.keyword == "NODE" || record.keyword == "ELEMENT"; -} - class DeckMapper final { public: explicit DeckMapper(const ParsedDeck& deck) : deck_{deck} {} [[nodiscard]] DomainBuildResult map() { - if (!select_active_scope()) { + ActiveInputResult selected = select_active_input(deck_); + if (!selected.input.has_value()) { + diagnostics_ = std::move(selected.diagnostics); return failure(); } + active_records_ = selected.input->part_records; + assembly_records_ = selected.input->assembly_records; + part_name_ = std::move(selected.input->part_name); + instance_name_ = std::move(selected.input->instance_name); collect_materials(); collect_nodes(); - collect_raw_sets(*active_records_, false); - if (active_assembly_ != nullptr) { - collect_raw_sets(active_assembly_->records, true); + collect_raw_sets(active_records_, false); + if (!assembly_records_.empty()) { + collect_raw_sets(assembly_records_, true); } collect_sections(); collect_elements(); @@ -153,71 +157,6 @@ private: return value; } - bool select_active_scope() { - const bool has_hierarchical_input = - !deck_.parts.empty() || deck_.assembly.has_value(); - if (!has_hierarchical_input) { - active_records_ = &deck_.global_records; - return true; - } - - if (std::ranges::any_of(deck_.global_records, is_mesh_record)) { - add_error( - "abaqus.semantic.mixed_mesh_organization", - "Flat mesh records cannot be mixed with Part/Assembly input.", - std::ranges::find_if( - deck_.global_records, is_mesh_record)->source); - return false; - } - if (!deck_.assembly.has_value()) { - const SourceLocation source = - deck_.parts.empty() ? SourceLocation{} : deck_.parts[0].source; - add_error( - "abaqus.semantic.assembly_count", - "Hierarchical Phase 1 input requires exactly one Assembly.", - source); - return false; - } - - active_assembly_ = &*deck_.assembly; - if (active_assembly_->instances.size() != 1U) { - const SourceLocation& source = - active_assembly_->instances.size() > 1U - ? active_assembly_->instances[1].source - : active_assembly_->source; - add_error( - "abaqus.semantic.instance_count", - "Phase 1 requires exactly one Instance.", - source); - return false; - } - - const ParsedInstance& instance = active_assembly_->instances.front(); - if (!instance.transform_data.empty()) { - add_error( - "abaqus.semantic.instance_transform", - "Instance translation and rotation data are unsupported.", - instance.source); - return false; - } - - const auto part = std::ranges::find( - deck_.parts, instance.part_name, &ParsedPart::name); - if (part == deck_.parts.end()) { - add_error( - "abaqus.semantic.missing_part", - "Instance '" + instance.name + "' references missing Part '" + - instance.part_name + "'.", - instance.source); - return false; - } - - active_records_ = &part->records; - part_name_ = part->name; - instance_name_ = instance.name; - return true; - } - void collect_materials() { const std::string* current_name = nullptr; for (const DeckRecord& record : deck_.global_records) { @@ -264,7 +203,7 @@ private: } void collect_nodes() { - for (const DeckRecord& record : *active_records_) { + for (const DeckRecord& record : active_records_) { if (record.keyword != "NODE") { continue; } @@ -298,7 +237,7 @@ private: } void collect_raw_sets( - const std::vector& records, + const std::span records, const bool assembly_scope) { for (const DeckRecord& record : records) { const bool is_node_set = record.keyword == "NSET"; @@ -343,7 +282,7 @@ private: } void collect_sections() { - for (const DeckRecord& record : *active_records_) { + for (const DeckRecord& record : active_records_) { if (record.keyword != "BEAM GENERAL SECTION") { continue; } @@ -422,7 +361,7 @@ private: } void collect_elements() { - for (const DeckRecord& record : *active_records_) { + for (const DeckRecord& record : active_records_) { if (record.keyword != "ELEMENT") { continue; } @@ -661,8 +600,8 @@ private: const ParsedDeck& deck_; DomainBuilder builder_; std::vector diagnostics_; - const std::vector* active_records_ = nullptr; - const ParsedAssembly* active_assembly_ = nullptr; + std::span active_records_; + std::span assembly_records_; std::string part_name_; std::string instance_name_; LabelMap node_ids_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c51a102..113ed03 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -118,6 +118,7 @@ add_test( ) add_executable(fesa_abaqus_parser_tests + unit/io/abaqus/active_input_test.cpp unit/io/abaqus/input_contract_test.cpp unit/io/abaqus/parser_test.cpp unit/io/abaqus/set_resolution_test.cpp @@ -175,6 +176,18 @@ add_test( --gtest_filter=AssemblySet.* ) +add_test( + NAME ActiveInput + COMMAND "$" + --gtest_filter=ActiveInput.* +) + +add_test( + NAME SemanticScope + COMMAND "$" + --gtest_filter=SemanticScope.* +) + add_executable(fesa_deck_to_domain_tests integration/io/minimal_deck_to_domain_test.cpp ) @@ -200,7 +213,7 @@ add_test( ) add_test( - NAME ActiveInstance + NAME SingleInstance COMMAND "$" --gtest_filter=ActiveInstance.* ) diff --git a/tests/integration/io/minimal_deck_to_domain_test.cpp b/tests/integration/io/minimal_deck_to_domain_test.cpp index 1f04648..c7c79b2 100644 --- a/tests/integration/io/minimal_deck_to_domain_test.cpp +++ b/tests/integration/io/minimal_deck_to_domain_test.cpp @@ -219,7 +219,7 @@ TEST(ActiveInstance, RejectsInstanceTransformWithSourceDiagnostic) { ASSERT_TRUE(has_diagnostic(result, "abaqus.semantic.instance_transform")); ASSERT_FALSE(result.diagnostics.empty()); ASSERT_TRUE(result.diagnostics.front().source.has_value()); - EXPECT_EQ(result.diagnostics.front().source->line, 4U); + EXPECT_EQ(result.diagnostics.front().source->line, 5U); } TEST(ActiveInstance, RejectsMissingPartReferenceWithSourceDiagnostic) { diff --git a/tests/unit/io/abaqus/active_input_test.cpp b/tests/unit/io/abaqus/active_input_test.cpp new file mode 100644 index 0000000..292d440 --- /dev/null +++ b/tests/unit/io/abaqus/active_input_test.cpp @@ -0,0 +1,213 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +class TemporaryDeck final { +public: + TemporaryDeck(std::string_view name, std::string_view contents) + : path_{std::filesystem::path{testing::TempDir()} / name} { + std::ofstream output{path_, std::ios::binary}; + output.write( + contents.data(), static_cast(contents.size())); + if (!output) { + throw std::runtime_error{"Failed to write temporary Abaqus deck."}; + } + } + + ~TemporaryDeck() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + TemporaryDeck(const TemporaryDeck&) = delete; + TemporaryDeck& operator=(const TemporaryDeck&) = delete; + + [[nodiscard]] const std::filesystem::path& path() const noexcept { + return path_; + } + +private: + std::filesystem::path path_; +}; + +std::filesystem::path fixture_path(std::string_view name) { + return std::filesystem::path{FESA_TEST_SOURCE_DIR} / "fixtures" / + "abaqus" / name; +} + +fesa::ParsedDeck parse(const std::filesystem::path& path) { + auto parsed = fesa::parse_deck(path); + if (!parsed.deck.has_value()) { + throw std::runtime_error{"Test deck did not parse."}; + } + return std::move(*parsed.deck); +} + +fesa::Diagnostic expect_failure( + const fesa::ParsedDeck& deck, + const std::string_view code, + const std::size_t line) { + const fesa::ActiveInputResult result = fesa::select_active_input(deck); + EXPECT_FALSE(result.input.has_value()); + const auto diagnostic = std::ranges::find( + result.diagnostics, code, &fesa::Diagnostic::code); + if (diagnostic == result.diagnostics.end()) { + throw std::runtime_error{"Expected active-input diagnostic was not found."}; + } + EXPECT_EQ(diagnostic->stage, fesa::DiagnosticStage::semantic); + EXPECT_TRUE(diagnostic->source.has_value()); + if (diagnostic->source.has_value()) { + EXPECT_EQ(diagnostic->source->line, line); + } + return *diagnostic; +} + +TEST(ActiveInput, SelectsFlatGlobalRecords) { + const fesa::ParsedDeck deck = + parse(fixture_path("minimal_cantilever.inp")); + + const fesa::ActiveInputResult result = fesa::select_active_input(deck); + + ASSERT_TRUE(result.input.has_value()); + EXPECT_TRUE(result.diagnostics.empty()); + EXPECT_TRUE(result.input->flat); + EXPECT_TRUE(result.input->part_name.empty()); + EXPECT_TRUE(result.input->instance_name.empty()); + EXPECT_EQ(result.input->part_records.data(), deck.global_records.data()); + EXPECT_EQ(result.input->part_records.size(), deck.global_records.size()); + EXPECT_TRUE(result.input->assembly_records.empty()); +} + +TEST(ActiveInput, SelectsOnlyTheReferencedPartAndAssemblyRecords) { + const TemporaryDeck input{ + "fesa-active-input-selection.inp", + "*PART, NAME=Unused\n" + "*NODE\n" + "99, 9.0, 0.0, 0.0\n" + "*END PART\n" + "*PART, NAME=BeamPart\n" + "*NODE\n" + "1, 0.0, 0.0, 0.0\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "*END INSTANCE\n" + "*NSET, NSET=Fixed, INSTANCE=Beam-1\n" + "1\n" + "*END ASSEMBLY\n"}; + const fesa::ParsedDeck deck = parse(input.path()); + + const fesa::ActiveInputResult result = fesa::select_active_input(deck); + + ASSERT_TRUE(result.input.has_value()); + EXPECT_FALSE(result.input->flat); + EXPECT_EQ(result.input->part_name, "BeamPart"); + EXPECT_EQ(result.input->instance_name, "Beam-1"); + ASSERT_EQ(result.input->part_records.size(), 1U); + EXPECT_EQ(result.input->part_records[0].keyword, "NODE"); + ASSERT_EQ(result.input->assembly_records.size(), 1U); + EXPECT_EQ(result.input->assembly_records[0].keyword, "NSET"); +} + +TEST(ActiveInput, RejectsMixedFlatAndHierarchicalMeshAtTheFlatRecord) { + const fesa::ParsedDeck deck = + parse(fixture_path("invalid/mixed_mesh.inp")); + + const fesa::Diagnostic diagnostic = expect_failure( + deck, "abaqus.semantic.mixed_mesh_organization", 1U); + + EXPECT_EQ(diagnostic.source->file, fixture_path("invalid/mixed_mesh.inp")); +} + +TEST(ActiveInput, RejectsHierarchicalInputWithoutAnAssembly) { + const TemporaryDeck input{ + "fesa-missing-assembly.inp", + "*PART, NAME=BeamPart\n" + "*END PART\n"}; + const fesa::ParsedDeck deck = parse(input.path()); + + expect_failure(deck, "abaqus.semantic.assembly_count", 1U); +} + +TEST(ActiveInput, RejectsAnAssemblyWithoutExactlyOneInstance) { + const TemporaryDeck no_instance{ + "fesa-no-instance.inp", + "*PART, NAME=BeamPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*END ASSEMBLY\n"}; + const TemporaryDeck multiple_instances{ + "fesa-multiple-instances.inp", + "*PART, NAME=BeamPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=BeamPart\n" + "*END INSTANCE\n" + "*INSTANCE, NAME=Beam-2, PART=BeamPart\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n"}; + + expect_failure( + parse(no_instance.path()), "abaqus.semantic.instance_count", 3U); + expect_failure( + parse(multiple_instances.path()), + "abaqus.semantic.instance_count", + 6U); +} + +TEST(ActiveInput, RejectsTransformAtTheFirstTransformDataRow) { + const fesa::ParsedDeck deck = + parse(fixture_path("invalid/instance_transform.inp")); + + expect_failure(deck, "abaqus.semantic.instance_transform", 5U); +} + +TEST(ActiveInput, RejectsAnInstanceThatReferencesAMissingPart) { + const TemporaryDeck input{ + "fesa-missing-part.inp", + "*PART, NAME=OtherPart\n" + "*END PART\n" + "*ASSEMBLY, NAME=RootAssembly\n" + "*INSTANCE, NAME=Beam-1, PART=MissingPart\n" + "*END INSTANCE\n" + "*END ASSEMBLY\n"}; + const fesa::ParsedDeck deck = parse(input.path()); + + expect_failure(deck, "abaqus.semantic.missing_part", 4U); +} + +TEST(ActiveInput, RejectsAssemblySetsForAnotherInstance) { + const fesa::ParsedDeck deck = + parse(fixture_path("invalid/assembly_set_wrong_instance.inp")); + + expect_failure(deck, "abaqus.semantic.wrong_instance", 6U); +} + +TEST(SemanticScope, RejectsInstanceLocalMeshAtTheKeywordRow) { + const auto path = fixture_path("invalid/instance_local_mesh.inp"); + + const fesa::ParseDeckResult result = fesa::parse_deck(path); + + EXPECT_FALSE(result.deck.has_value()); + ASSERT_EQ(result.diagnostics.size(), 1U); + EXPECT_EQ(result.diagnostics[0].stage, fesa::DiagnosticStage::syntax); + EXPECT_EQ( + result.diagnostics[0].code, + "abaqus.syntax.instance_local_keyword"); + ASSERT_TRUE(result.diagnostics[0].source.has_value()); + EXPECT_EQ(result.diagnostics[0].source->file, path); + EXPECT_EQ(result.diagnostics[0].source->line, 5U); +} + +} // namespace