From f2c150b4907d50be451b37ad9b4e8001485f09c1 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Sun, 16 Aug 2026 11:45:34 +0900 Subject: [PATCH] feat(cpp-object-oriented-modular-refactoring): step 21 - domain-mapper-modules --- src/fesa/CMakeLists.txt | 5 + src/fesa/io/abaqus/domain_builder.cpp | 11 + src/fesa/io/abaqus/domain_builder.h | 20 + src/fesa/io/abaqus/domain_mapper.cpp | 2191 +--------------- src/fesa/io/abaqus/domain_mapping_context.cpp | 2247 +++++++++++++++++ src/fesa/io/abaqus/domain_mapping_context.h | 79 + .../io/abaqus/material_property_mapper.cpp | 11 + src/fesa/io/abaqus/material_property_mapper.h | 19 + src/fesa/io/abaqus/step_definition_mapper.cpp | 11 + src/fesa/io/abaqus/step_definition_mapper.h | 19 + src/fesa/io/abaqus/topology_mapper.cpp | 15 + src/fesa/io/abaqus/topology_mapper.h | 22 + tests/CMakeLists.txt | 1 + .../abaqus/domain_mapping_components_test.cpp | 236 ++ 14 files changed, 2721 insertions(+), 2166 deletions(-) create mode 100644 src/fesa/io/abaqus/domain_builder.cpp create mode 100644 src/fesa/io/abaqus/domain_builder.h create mode 100644 src/fesa/io/abaqus/domain_mapping_context.cpp create mode 100644 src/fesa/io/abaqus/domain_mapping_context.h create mode 100644 src/fesa/io/abaqus/material_property_mapper.cpp create mode 100644 src/fesa/io/abaqus/material_property_mapper.h create mode 100644 src/fesa/io/abaqus/step_definition_mapper.cpp create mode 100644 src/fesa/io/abaqus/step_definition_mapper.h create mode 100644 src/fesa/io/abaqus/topology_mapper.cpp create mode 100644 src/fesa/io/abaqus/topology_mapper.h create mode 100644 tests/unit/io/abaqus/domain_mapping_components_test.cpp diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index 8ac22af..b5c799a 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -18,8 +18,13 @@ add_library( elements/euler_beam_3d.cpp elements/mitc4_shell.cpp fem/dof_manager.cpp + io/abaqus/domain_builder.cpp + io/abaqus/domain_mapping_context.cpp io/abaqus/domain_mapper.cpp io/abaqus/input_reader.cpp + io/abaqus/material_property_mapper.cpp + io/abaqus/step_definition_mapper.cpp + io/abaqus/topology_mapper.cpp io/hdf5/hdf5_results_writer.cpp loads/concentrated_nodal_load.cpp materials/isotropic_linear_elastic_material.cpp diff --git a/src/fesa/io/abaqus/domain_builder.cpp b/src/fesa/io/abaqus/domain_builder.cpp new file mode 100644 index 0000000..f4b73bf --- /dev/null +++ b/src/fesa/io/abaqus/domain_builder.cpp @@ -0,0 +1,11 @@ +#include "io/abaqus/domain_builder.h" + +#include "io/abaqus/domain_mapping_context.h" + +namespace fesa::abaqus_internal { + +Result DomainBuilder::Build(DomainMappingContext& context) const { + return context.Commit(); +} + +} // namespace fesa::abaqus_internal diff --git a/src/fesa/io/abaqus/domain_builder.h b/src/fesa/io/abaqus/domain_builder.h new file mode 100644 index 0000000..ae6448b --- /dev/null +++ b/src/fesa/io/abaqus/domain_builder.h @@ -0,0 +1,20 @@ +#ifndef FESA_IO_ABAQUS_DOMAIN_BUILDER_H_ +#define FESA_IO_ABAQUS_DOMAIN_BUILDER_H_ + +#include "fesa/core/status.h" +#include "fesa/model/domain.h" + +namespace fesa::abaqus_internal { + +class DomainMappingContext; + +/// @brief Commits a complete validated mapping candidate atomically. +class DomainBuilder { + public: + /// @brief Transfers the candidate to Domain only after every stage succeeds. + Result Build(DomainMappingContext& context) const; +}; + +} // namespace fesa::abaqus_internal + +#endif // FESA_IO_ABAQUS_DOMAIN_BUILDER_H_ diff --git a/src/fesa/io/abaqus/domain_mapper.cpp b/src/fesa/io/abaqus/domain_mapper.cpp index 07eb4e2..9260ea4 100644 --- a/src/fesa/io/abaqus/domain_mapper.cpp +++ b/src/fesa/io/abaqus/domain_mapper.cpp @@ -1,2177 +1,36 @@ #include "fesa/io/abaqus/domain_mapper.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include "fesa/core/ascii.h" -#include "fesa/math/vector3.h" -#include "fesa/model/shell_geometry.h" -#include "fesa/model/source_target_resolver.h" +#include "io/abaqus/domain_builder.h" +#include "io/abaqus/domain_mapping_context.h" +#include "io/abaqus/material_property_mapper.h" +#include "io/abaqus/step_definition_mapper.h" +#include "io/abaqus/topology_mapper.h" namespace fesa { -namespace { - -std::vector WithoutTrailingEmpty(std::vector fields) { - while (!fields.empty() && fields.back().empty()) { - fields.pop_back(); - } - return fields; -} - -struct RawNode { - std::int64_t label; - std::string label_text; - std::array coordinates; - SourceLocation location; -}; - -struct RawElement { - enum class Type { kB33, kS4, kS4r }; - - std::int64_t label; - std::string label_text; - Type type; - std::vector node_labels; - SourceLocation location; -}; - -enum class ElementFamily { kBeam, kShell }; - -struct RawSet { - std::string name; - std::vector members; - SourceLocation location; -}; - -struct RawSection { - std::string element_set_name; - std::string material_name; - std::array properties; - std::array first_axis; - std::vector> section_points; - SourceLocation location; -}; - -struct RawShellSection { - std::string element_set_name; - std::string material_name; - double thickness; - SourceLocation location; -}; - -struct RawPart { - std::string name; - SourceLocation location; - std::vector nodes; - std::vector elements; - std::vector node_sets; - std::vector element_sets; - std::vector sections; - std::vector shell_sections; -}; - -struct RawInstance { - std::string name; - std::string part_name; - SourceLocation location; -}; - -struct RawAssemblySet { - bool is_node_set; - std::string name; - std::string instance_name; - std::vector members; - SourceLocation location; -}; - -struct RawMaterial { - std::string name; - double youngs_modulus{0.0}; - double poisson_ratio{0.0}; - bool has_elastic{false}; - SourceLocation location; - SourceLocation elastic_location; -}; - -struct RawStep { - SourceLocation location; - bool has_static{false}; - std::array static_values{}; - std::vector boundaries; - std::vector loads; -}; - -struct MappingFailure { - FailureCategory category; - Diagnostic diagnostic; -}; - -/// @brief Builds one complete Domain candidate while retaining source identity. -/// @note The candidate is committed only after all blocks and cross-references -/// have been validated. -class MappingContext { - public: - explicit MappingContext(const ParsedInput& input) : input_{input} {} - - Result Run() { - ParseBlocks(); - if (!failure_) { - FinalizeModel(); - } - if (failure_) { - return Result::Failure(Status::Failure( - failure_->category, {std::move(failure_->diagnostic)})); - } - SortDiagnostics(definition_.warnings); - return Domain::Create(std::move(definition_)); - } - - private: - const KeywordParameter* Parameter(const KeywordBlock& block, - std::string_view name) const { - const auto found = - std::find_if(block.parameters.begin(), block.parameters.end(), - [name](const KeywordParameter& candidate) { - return candidate.name == name; - }); - return found == block.parameters.end() ? nullptr : &*found; - } - - bool Fail(FailureCategory category, std::string code, - const SourceLocation& location, std::string keyword, - std::string entity_identity, std::string message) { - if (!failure_) { - failure_ = MappingFailure{ - category, - {Severity::kError, std::move(code), location, std::move(keyword), - std::move(entity_identity), std::move(message)}}; - } - return false; - } - - bool InputFailure(std::string code, const SourceLocation& location, - std::string keyword, std::string entity_identity, - std::string message) { - return Fail(FailureCategory::kInput, std::move(code), location, - std::move(keyword), std::move(entity_identity), - std::move(message)); - } - - bool ModelFailure(std::string code, const SourceLocation& location, - std::string keyword, std::string entity_identity, - std::string message) { - return Fail(FailureCategory::kModel, std::move(code), location, - std::move(keyword), std::move(entity_identity), - std::move(message)); - } - - bool InvalidKeywordLocation(const KeywordBlock& block, std::string message) { - return InputFailure("invalid-keyword-location", block.location, - block.canonical_name, "", std::move(message)); - } - - bool ValidateParameters(const KeywordBlock& block, - const std::vector& allowed) { - std::set seen; - for (const auto& candidate : block.parameters) { - if (!seen.insert(candidate.name).second) { - return InputFailure("duplicate-entity", block.location, - block.canonical_name, candidate.name, - "A keyword parameter may be declared only once."); - } - if (std::find(allowed.begin(), allowed.end(), candidate.name) == - allowed.end()) { - return InputFailure( - "invalid-keyword-parameter", block.location, block.canonical_name, - candidate.name, - "The keyword parameter is outside the approved subset."); - } - } - return true; - } - - const std::string* RequiredParameterValue(const KeywordBlock& block, - std::string_view name) { - const auto* found = Parameter(block, name); - if (found == nullptr || !found->value || found->value->empty()) { - InputFailure("invalid-keyword-parameter", block.location, - block.canonical_name, std::string{name}, - "The keyword requires a non-empty parameter value."); - return nullptr; - } - return &*found->value; - } - - bool RequireNoData(const KeywordBlock& block) { - if (!block.data.empty()) { - return InputFailure("invalid-data-arity", block.data.front().location, - block.canonical_name, "", - "This keyword does not accept data rows."); - } - return true; - } - - bool ParseInteger(const std::string& text, std::int64_t& value, - const SourceLocation& location, const std::string& keyword, - bool positive) { - if (text.empty()) { - return InputFailure("invalid-numeric-value", location, keyword, text, - "A numeric field cannot be empty."); - } - char* end = nullptr; - errno = 0; - const long long parsed = std::strtoll(text.c_str(), &end, 10); - if (errno == ERANGE || end != text.c_str() + text.size() || - (positive && parsed <= 0)) { - return InputFailure( - "invalid-numeric-value", location, keyword, text, - "The field must be a valid positive base-10 integer."); - } - value = static_cast(parsed); - return true; - } - - bool ParseDouble(const std::string& text, double& value, - const SourceLocation& location, const std::string& keyword) { - if (text.empty()) { - return InputFailure("invalid-numeric-value", location, keyword, text, - "A numeric field cannot be empty."); - } - char* end = nullptr; - errno = 0; - const double parsed = std::strtod(text.c_str(), &end); - if (errno == ERANGE || end != text.c_str() + text.size() || - !std::isfinite(parsed)) { - return InputFailure("invalid-numeric-value", location, keyword, text, - "The field must be a finite floating-point value."); - } - value = parsed; - return true; - } - - bool ParseModelDouble(const std::string& text, double& value, - const SourceLocation& location, - const std::string& keyword, - const std::string& diagnostic_code, - const std::string& message) { - if (text.empty()) { - return InputFailure("invalid-numeric-value", location, keyword, text, - "A numeric field cannot be empty."); - } - char* end = nullptr; - errno = 0; - const double parsed = std::strtod(text.c_str(), &end); - if (end != text.c_str() + text.size()) { - return InputFailure("invalid-numeric-value", location, keyword, text, - "The field must use floating-point syntax."); - } - if (errno == ERANGE || !std::isfinite(parsed)) { - return ModelFailure(diagnostic_code, location, keyword, text, message); - } - value = parsed; - return true; - } - - bool ContainsName(const std::vector& values, - const std::string& name) const { - return std::any_of(values.begin(), values.end(), - [&name](const RawPart& value) { - return AsciiCaseInsensitiveEquals(value.name, name); - }); - } - - bool ContainsName(const std::vector& values, - const std::string& name) const { - return std::any_of(values.begin(), values.end(), - [&name](const RawInstance& value) { - return AsciiCaseInsensitiveEquals(value.name, name); - }); - } - - bool ContainsName(const std::vector& values, - const std::string& name) const { - return std::any_of(values.begin(), values.end(), - [&name](const RawMaterial& value) { - return AsciiCaseInsensitiveEquals(value.name, name); - }); - } - - bool ContainsSetName(const std::vector& sets, - const std::string& name) const { - const auto matches = [&name](const RawSet& set) { - return AsciiCaseInsensitiveEquals(set.name, name); - }; - return std::any_of(sets.begin(), sets.end(), matches); - } - - bool ContainsAssemblySetName(bool node_set, const std::string& name) const { - return std::any_of(assembly_sets_.begin(), assembly_sets_.end(), - [node_set, &name](const RawAssemblySet& set) { - return set.is_node_set == node_set && - AsciiCaseInsensitiveEquals(set.name, name); - }); - } - - void ParseBlocks() { - definition_.source_path = input_.source_path; - definition_.source_content_identity = input_.source_content_identity; - - for (std::size_t index = 0U; index < input_.blocks.size() && !failure_; - ++index) { - const auto& block = input_.blocks[index]; - if (in_instance_) { - ParseInstanceBlock(block); - } else if (current_part_) { - ParsePartBlock(block); - } else if (in_assembly_) { - ParseAssemblyBlock(block); - } else if (in_step_) { - ParseStepBlock(block); - } else { - ParseTopLevelBlock(block, index); - } - } - - if (!failure_ && - (current_part_ || in_assembly_ || in_instance_ || in_step_)) { - const auto location = input_.blocks.empty() - ? SourceLocation{input_.source_path, 0U} - : input_.blocks.back().location; - InputFailure("unclosed-keyword-block", location, "", "", - "A part, assembly, instance, or step block was not closed."); - } - } - - void ParseTopLevelBlock(const KeywordBlock& block, std::size_t index) { - if (step_seen_) { - if (block.canonical_name == "STEP") { - ParseStepStart(block); - } else { - InvalidKeywordLocation( - block, "The sole analysis step must be the final top-level block."); - } - return; - } - - if (block.canonical_name != "ELASTIC") { - material_eligible_.reset(); - } - pending_section_.reset(); - active_output_ = false; - - if (block.canonical_name == "HEADING") { - if (index != 0U || heading_seen_ || !ValidateParameters(block, {})) { - if (!failure_) { - InputFailure("invalid-keyword-location", block.location, - block.canonical_name, "", - "HEADING is optional only as the first keyword."); - } - return; - } - heading_seen_ = true; - for (std::size_t row = 0U; row < block.data.size(); ++row) { - if (row != 0U) { - definition_.heading.push_back('\n'); - } - for (std::size_t field = 0U; field < block.data[row].fields.size(); - ++field) { - if (field != 0U) { - definition_.heading.push_back(','); - } - definition_.heading += block.data[row].fields[field]; - } - } - return; - } - if (block.canonical_name == "PREPRINT") { - if (!RequireNoData(block)) { - return; - } - AddIgnoredWarning(block); - return; - } - if (block.canonical_name == "PART") { - ParsePartStart(block); - return; - } - if (block.canonical_name == "ASSEMBLY") { - ParseAssemblyStart(block); - return; - } - if (block.canonical_name == "MATERIAL") { - ParseMaterial(block); - return; - } - if (block.canonical_name == "ELASTIC") { - ParseElastic(block); - return; - } - if (block.canonical_name == "BOUNDARY") { - if (!assembly_seen_ || materials_.empty()) { - InvalidKeywordLocation( - block, "Model boundary data follows the assembly and materials."); - return; - } - model_boundary_seen_ = true; - ParseBoundary(block, model_boundaries_); - return; - } - if (block.canonical_name == "STEP") { - ParseStepStart(block); - return; - } - if (block.canonical_name == "END PART" || - block.canonical_name == "END ASSEMBLY" || - block.canonical_name == "END INSTANCE" || - block.canonical_name == "END STEP") { - InputFailure("invalid-keyword-location", block.location, - block.canonical_name, "", - "The closing keyword has no matching open block."); - return; - } - RejectUnknown(block); - } - - void ParsePartStart(const KeywordBlock& block) { - if (assembly_seen_ || !materials_.empty() || model_boundary_seen_) { - InvalidKeywordLocation( - block, "All part blocks must precede the sole assembly block."); - return; - } - if (!ValidateParameters(block, {"NAME"}) || !RequireNoData(block)) { - return; - } - const auto* name = RequiredParameterValue(block, "NAME"); - if (name == nullptr) { - return; - } - if (ContainsName(parts_, *name)) { - InputFailure("duplicate-entity", block.location, block.canonical_name, - *name, - "Part names are unique under case-insensitive lookup."); - return; - } - parts_.push_back({*name, block.location, {}, {}, {}, {}, {}, {}}); - current_part_ = parts_.size() - 1U; - part_elements_seen_ = false; - part_sets_seen_ = false; - part_sections_seen_ = false; - beam_section_context_active_ = false; - } - - void ParsePartBlock(const KeywordBlock& block) { - if (block.canonical_name == "END PART") { - if (!ValidateParameters(block, {}) || !RequireNoData(block)) { - return; - } - const RawPart& part = parts_[*current_part_]; - const bool shell_part = model_element_family_ == ElementFamily::kShell; - if (part.nodes.empty() || part.elements.empty() || - (!shell_part && part.sections.empty())) { - InvalidKeywordLocation(block, - "A part closes only after node, element, " - "and matching section blocks."); - return; - } - current_part_.reset(); - pending_section_.reset(); - beam_section_context_active_ = false; - return; - } - if (block.canonical_name == "PART") { - InputFailure("invalid-keyword-location", block.location, - block.canonical_name, "", - "Nested part blocks are not supported."); - return; - } - if (block.canonical_name == "ASSEMBLY") { - InputFailure("unsupported-nested-assembly", block.location, - block.canonical_name, "", - "An assembly cannot be nested in a part."); - return; - } - - RawPart& part = parts_[*current_part_]; - if (block.canonical_name == "NODE") { - beam_section_context_active_ = false; - if (part_elements_seen_ || part_sets_seen_ || part_sections_seen_) { - InvalidKeywordLocation( - block, - "NODE blocks must precede element, set, and section blocks."); - return; - } - pending_section_.reset(); - ParseNodes(block, part); - } else if (block.canonical_name == "ELEMENT") { - beam_section_context_active_ = false; - if (part.nodes.empty() || part_sets_seen_ || part_sections_seen_) { - InvalidKeywordLocation(block, - "ELEMENT blocks follow at least one NODE block " - "and precede sets and sections."); - return; - } - pending_section_.reset(); - ParseElements(block, part); - part_elements_seen_ = !failure_; - } else if (block.canonical_name == "NSET") { - beam_section_context_active_ = false; - if (!part_elements_seen_ || part_sections_seen_) { - InvalidKeywordLocation( - block, - "Part sets follow element blocks and precede beam sections."); - return; - } - pending_section_.reset(); - ParseSet(block, true, part); - part_sets_seen_ = !failure_; - } else if (block.canonical_name == "ELSET") { - beam_section_context_active_ = false; - if (!part_elements_seen_ || part_sections_seen_) { - InvalidKeywordLocation( - block, - "Part sets follow element blocks and precede beam sections."); - return; - } - pending_section_.reset(); - ParseSet(block, false, part); - part_sets_seen_ = !failure_; - } else if (block.canonical_name == "BEAM GENERAL SECTION") { - if (!part_elements_seen_) { - InvalidKeywordLocation( - block, - "BEAM GENERAL SECTION follows the part mesh and optional sets."); - return; - } - if (model_element_family_ == ElementFamily::kShell) { - InputFailure( - "unsupported-mixed-element-model", block.location, - block.canonical_name, "", - "Beam-section semantics cannot be mixed with shell elements."); - return; - } - ParseBeamSection(block, part); - part_sections_seen_ = !failure_; - beam_section_context_active_ = !failure_; - } else if (block.canonical_name == "SHELL SECTION") { - beam_section_context_active_ = false; - if (!part_elements_seen_) { - InvalidKeywordLocation( - block, "SHELL SECTION follows the part mesh and optional sets."); - return; - } - if (model_element_family_ == ElementFamily::kBeam) { - InputFailure( - "unsupported-mixed-element-model", block.location, - block.canonical_name, "", - "Shell-section semantics cannot be mixed with beam elements."); - return; - } - ParseShellSection(block, part); - part_sections_seen_ = !failure_; - } else if (block.canonical_name == "SECTION POINTS") { - ParseSectionPoints(block, part); - } else if (block.canonical_name == "TRANSVERSE SHEAR STIFFNESS") { - pending_section_.reset(); - if (!beam_section_context_active_) { - InputFailure( - "invalid-keyword-location", block.location, block.canonical_name, - "", - "The ignored shear keyword still requires beam-section context."); - return; - } - AddIgnoredWarning(block); - } else { - pending_section_.reset(); - beam_section_context_active_ = false; - RejectUnknown(block); - } - } - - void ParseNodes(const KeywordBlock& block, RawPart& part) { - if (!ValidateParameters(block, {}) || block.data.empty()) { - if (!failure_) { - InputFailure("invalid-data-arity", block.location, block.canonical_name, - "", "NODE requires at least one four-field data row."); - } - return; - } - for (const auto& row : block.data) { - if (row.fields.size() != 4U) { - InputFailure("invalid-data-arity", row.location, block.canonical_name, - "", "NODE rows require label, x, y, z."); - return; - } - RawNode node{}; - node.label_text = row.fields[0]; - node.location = row.location; - if (!ParseInteger(row.fields[0], node.label, row.location, - block.canonical_name, true)) { - return; - } - if (std::any_of(part.nodes.begin(), part.nodes.end(), - [&node](const RawNode& existing) { - return existing.label == node.label; - })) { - InputFailure("duplicate-entity", row.location, block.canonical_name, - node.label_text, "Node labels are unique within a part."); - return; - } - for (std::size_t coordinate = 0U; coordinate < 3U; ++coordinate) { - if (!ParseDouble(row.fields[coordinate + 1U], - node.coordinates[coordinate], row.location, - block.canonical_name)) { - return; - } - } - part.nodes.push_back(std::move(node)); - } - } - - void ParseElements(const KeywordBlock& block, RawPart& part) { - if (!ValidateParameters(block, {"TYPE"})) { - return; - } - const auto* type = RequiredParameterValue(block, "TYPE"); - if (type == nullptr) { - return; - } - RawElement::Type element_type{}; - ElementFamily element_family{}; - std::size_t expected_field_count = 0U; - if (AsciiCaseInsensitiveEquals(*type, "B33")) { - element_type = RawElement::Type::kB33; - element_family = ElementFamily::kBeam; - expected_field_count = 3U; - } else if (AsciiCaseInsensitiveEquals(*type, "S4")) { - element_type = RawElement::Type::kS4; - element_family = ElementFamily::kShell; - expected_field_count = 5U; - } else if (AsciiCaseInsensitiveEquals(*type, "S4R")) { - element_type = RawElement::Type::kS4r; - element_family = ElementFamily::kShell; - expected_field_count = 5U; - } else { - InputFailure( - "unsupported-element-formulation", block.location, - block.canonical_name, *type, - "Only TYPE=B33, TYPE=S4, and TYPE=S4R belong to the approved " - "element subsets."); - return; - } - if (model_element_family_ && *model_element_family_ != element_family) { - InputFailure("unsupported-mixed-element-model", block.location, - block.canonical_name, *type, - "Beam and shell elements cannot be mixed in one model."); - return; - } - model_element_family_ = element_family; - if (block.data.empty()) { - InputFailure(element_family == ElementFamily::kShell - ? "invalid-shell-connectivity" - : "invalid-data-arity", - block.location, block.canonical_name, "", - "ELEMENT requires at least one row with the approved " - "connectivity arity."); - return; - } - for (const auto& row : block.data) { - if (row.fields.size() != expected_field_count) { - InputFailure( - element_family == ElementFamily::kShell - ? "invalid-shell-connectivity" - : "invalid-data-arity", - row.location, block.canonical_name, "", - element_family == ElementFamily::kShell - ? "S4 and S4R rows require a label and exactly four nodes." - : "B33 rows require label, node 1, node 2."); - return; - } - RawElement element{}; - element.label_text = row.fields[0]; - element.type = element_type; - element.location = row.location; - if (!ParseInteger(row.fields[0], element.label, row.location, - block.canonical_name, true)) { - return; - } - element.node_labels.reserve(expected_field_count - 1U); - for (std::size_t field = 1U; field < expected_field_count; ++field) { - std::int64_t node_label = 0; - if (!ParseInteger(row.fields[field], node_label, row.location, - block.canonical_name, true)) { - return; - } - element.node_labels.push_back(node_label); - } - if (element_family == ElementFamily::kShell) { - const std::set distinct_nodes{element.node_labels.begin(), - element.node_labels.end()}; - if (distinct_nodes.size() != element.node_labels.size()) { - InputFailure( - "invalid-shell-connectivity", row.location, block.canonical_name, - element.label_text, - "Shell connectivity requires four distinct source nodes."); - return; - } - } - if (std::any_of(part.elements.begin(), part.elements.end(), - [&element](const RawElement& existing) { - return existing.label == element.label; - })) { - InputFailure("duplicate-entity", row.location, block.canonical_name, - element.label_text, - "Element labels are unique within a part."); - return; - } - part.elements.push_back(std::move(element)); - } - } - - void ParseShellSection(const KeywordBlock& block, RawPart& part) { - for (const auto& candidate : block.parameters) { - if (candidate.name != "ELSET" && candidate.name != "MATERIAL") { - InputFailure("unsupported-shell-section-option", block.location, - block.canonical_name, candidate.name, - "The shell-section option is outside the centered " - "single-layer subset."); - return; - } - } - if (!ValidateParameters(block, {"ELSET", "MATERIAL"})) { - return; - } - const auto* element_set = RequiredParameterValue(block, "ELSET"); - const auto* material = RequiredParameterValue(block, "MATERIAL"); - if (element_set == nullptr || material == nullptr) { - return; - } - if (block.data.size() != 1U || block.data[0].fields.empty() || - block.data[0].fields.size() > 2U) { - InputFailure("unsupported-shell-section-option", block.location, - block.canonical_name, *element_set, - "SHELL SECTION requires one thickness row with one optional " - "integration-point field."); - return; - } - double thickness = 0.0; - if (!ParseModelDouble(block.data[0].fields[0], thickness, - block.data[0].location, block.canonical_name, - "invalid-shell-thickness", - "Shell thickness must be finite and positive.")) { - return; - } - if (!(thickness > 0.0)) { - ModelFailure("invalid-shell-thickness", block.data[0].location, - block.canonical_name, *element_set, - "Shell thickness must be finite and positive."); - return; - } - if (block.data[0].fields.size() == 2U) { - if (!ParsePositiveSourceLabel(block.data[0].fields[1]).HasValue()) { - InputFailure("unsupported-shell-section-option", block.data[0].location, - block.canonical_name, block.data[0].fields[1], - "The optional shell integration-point field must be a " - "positive integer."); - return; - } - } - part.shell_sections.push_back( - {*element_set, *material, thickness, block.location}); - } - - bool ParseSetMembers(const KeywordBlock& block, bool generate, - std::vector& members) { - if (block.data.empty()) { - return InputFailure("invalid-data-arity", block.location, - block.canonical_name, "", - "A set requires at least one member row."); - } - for (const auto& data : block.data) { - auto fields = WithoutTrailingEmpty(data.fields); - if (generate) { - if (fields.size() != 3U) { - return InputFailure("invalid-data-arity", data.location, - block.canonical_name, "", - "GENERATE rows require first, last, increment."); - } - std::int64_t first = 0; - std::int64_t last = 0; - std::int64_t increment = 0; - if (!ParseInteger(fields[0], first, data.location, block.canonical_name, - true) || - !ParseInteger(fields[1], last, data.location, block.canonical_name, - true)) { - return false; - } - if (!ParseInteger(fields[2], increment, data.location, - block.canonical_name, false) || - increment <= 0 || first > last || (last - first) % increment != 0) { - return InputFailure("invalid-set-range", data.location, - block.canonical_name, "", - "GENERATE requires an inclusive range reached by " - "a positive increment."); - } - for (std::int64_t label = first; label <= last;) { - members.push_back(label); - if (label > last - increment) { - break; - } - label += increment; - } - } else { - if (fields.empty() || std::any_of(fields.begin(), fields.end(), - [](const std::string& field) { - return field.empty(); - })) { - return InputFailure( - "invalid-data-arity", data.location, block.canonical_name, "", - "Explicit set rows require non-empty member labels."); - } - for (const auto& field : fields) { - std::int64_t label = 0; - if (!ParseInteger(field, label, data.location, block.canonical_name, - true)) { - return false; - } - members.push_back(label); - } - } - } - std::set unique; - for (const auto member : members) { - if (!unique.insert(member).second) { - return InputFailure("duplicate-entity", block.location, - block.canonical_name, std::to_string(member), - "A set cannot repeat the same source member."); - } - } - return true; - } - - void ParseSet(const KeywordBlock& block, bool node_set, RawPart& part) { - const std::string_view name_parameter = node_set ? "NSET" : "ELSET"; - if (!ValidateParameters(block, {name_parameter, "GENERATE"})) { - return; - } - const auto* name = RequiredParameterValue(block, name_parameter); - if (name == nullptr) { - return; - } - const auto* generate_parameter = Parameter(block, "GENERATE"); - if (generate_parameter != nullptr && generate_parameter->value) { - InputFailure("invalid-keyword-parameter", block.location, - block.canonical_name, "GENERATE", - "GENERATE is a valueless flag."); - return; - } - const auto& sets = node_set ? part.node_sets : part.element_sets; - if (ContainsSetName(sets, *name)) { - InputFailure("duplicate-entity", block.location, block.canonical_name, - *name, - "Set names are unique within their node-set or element-set " - "namespace."); - return; - } - RawSet set{*name, {}, block.location}; - if (!ParseSetMembers(block, generate_parameter != nullptr, set.members)) { - return; - } - if (node_set) { - part.node_sets.push_back(std::move(set)); - } else { - part.element_sets.push_back(std::move(set)); - } - } - - void ParseBeamSection(const KeywordBlock& block, RawPart& part) { - pending_section_.reset(); - if (!ValidateParameters(block, {"ELSET", "MATERIAL", "SECTION"})) { - return; - } - const auto* element_set = RequiredParameterValue(block, "ELSET"); - const auto* material = RequiredParameterValue(block, "MATERIAL"); - const auto* section = RequiredParameterValue(block, "SECTION"); - if (element_set == nullptr || material == nullptr || section == nullptr) { - return; - } - if (!AsciiCaseInsensitiveEquals(*section, "GENERAL")) { - InputFailure("unsupported-section-formulation", block.location, - block.canonical_name, *section, - "Only SECTION=GENERAL belongs to the approved subset."); - return; - } - if (std::any_of(part.sections.begin(), part.sections.end(), - [element_set](const RawSection& existing) { - return AsciiCaseInsensitiveEquals( - existing.element_set_name, *element_set); - })) { - InputFailure("duplicate-entity", block.location, block.canonical_name, - *element_set, - "An element set can receive only one beam section."); - return; - } - if (block.data.size() != 2U || block.data[0].fields.size() != 5U || - block.data[1].fields.size() != 3U) { - InputFailure("invalid-data-arity", block.location, block.canonical_name, - *element_set, - "A general section requires one five-field property row and " - "one three-field axis row."); - return; - } - RawSection raw{*element_set, *material, {}, {}, {}, block.location}; - for (std::size_t property = 0U; property < 5U; ++property) { - if (!ParseModelDouble(block.data[0].fields[property], - raw.properties[property], block.data[0].location, - block.canonical_name, "invalid-beam-property", - "Beam section properties must be finite.")) { - return; - } - } - for (std::size_t coordinate = 0U; coordinate < 3U; ++coordinate) { - if (!ParseModelDouble(block.data[1].fields[coordinate], - raw.first_axis[coordinate], block.data[1].location, - block.canonical_name, "invalid-beam-guide-vector", - "The beam guide vector must be finite.")) { - return; - } - } - part.sections.push_back(std::move(raw)); - pending_section_ = part.sections.size() - 1U; - } - - void ParseSectionPoints(const KeywordBlock& block, RawPart& part) { - if (!pending_section_ || !ValidateParameters(block, {}) || - block.data.empty()) { - if (!failure_) { - InputFailure( - "invalid-keyword-location", block.location, block.canonical_name, - "", "SECTION POINTS must immediately follow a general section."); - } - return; - } - RawSection& section = part.sections[*pending_section_]; - for (const auto& row : block.data) { - if (row.fields.size() != 2U) { - InputFailure("invalid-data-arity", row.location, block.canonical_name, - section.element_set_name, - "Section-point rows require x1 and x2."); - return; - } - std::array point{}; - if (!ParseDouble(row.fields[0], point[0], row.location, - block.canonical_name) || - !ParseDouble(row.fields[1], point[1], row.location, - block.canonical_name)) { - return; - } - if (std::find(section.section_points.begin(), - section.section_points.end(), - point) != section.section_points.end()) { - InputFailure("duplicate-entity", row.location, block.canonical_name, - section.element_set_name, - "Section points must be unique within a section."); - return; - } - section.section_points.push_back(point); - } - pending_section_.reset(); - } - - void ParseAssemblyStart(const KeywordBlock& block) { - if (assembly_seen_) { - InputFailure("unsupported-nested-assembly", block.location, - block.canonical_name, "", - "V0 accepts exactly one non-nested assembly."); - return; - } - if (parts_.empty() || !materials_.empty() || model_boundary_seen_) { - InvalidKeywordLocation( - block, - "The sole assembly follows all part blocks and precedes model data."); - return; - } - if (!ValidateParameters(block, {"NAME"}) || !RequireNoData(block)) { - return; - } - if (RequiredParameterValue(block, "NAME") == nullptr) { - return; - } - assembly_seen_ = true; - in_assembly_ = true; - assembly_set_seen_ = false; - } - - void ParseAssemblyBlock(const KeywordBlock& block) { - if (block.canonical_name == "END ASSEMBLY") { - if (!ValidateParameters(block, {}) || !RequireNoData(block)) { - return; - } - if (instances_.empty()) { - InvalidKeywordLocation( - block, "The assembly requires at least one identity instance."); - return; - } - in_assembly_ = false; - return; - } - if (block.canonical_name == "ASSEMBLY") { - InputFailure("unsupported-nested-assembly", block.location, - block.canonical_name, "", - "Nested or duplicate assembly blocks are unsupported."); - } else if (block.canonical_name == "INSTANCE") { - if (assembly_set_seen_) { - InvalidKeywordLocation( - block, "All identity instances must precede assembly-level sets."); - return; - } - ParseInstanceStart(block); - } else if (block.canonical_name == "NSET") { - if (instances_.empty()) { - InvalidKeywordLocation( - block, "Assembly sets follow at least one identity instance."); - return; - } - assembly_set_seen_ = true; - ParseAssemblySet(block, true); - } else if (block.canonical_name == "ELSET") { - if (instances_.empty()) { - InvalidKeywordLocation( - block, "Assembly sets follow at least one identity instance."); - return; - } - assembly_set_seen_ = true; - ParseAssemblySet(block, false); - } else { - RejectUnknown(block); - } - } - - void ParseInstanceStart(const KeywordBlock& block) { - if (Parameter(block, "DEPENDENT") != nullptr || - Parameter(block, "INDEPENDENT") != nullptr) { - InputFailure( - "unsupported-instance-mesh-semantics", block.location, - block.canonical_name, "", - "Dependent and independent instance mesh semantics are unsupported."); - return; - } - if (!ValidateParameters(block, {"NAME", "PART"})) { - return; - } - const auto* name = RequiredParameterValue(block, "NAME"); - const auto* part = RequiredParameterValue(block, "PART"); - if (name == nullptr || part == nullptr) { - return; - } - if (!block.data.empty()) { - InputFailure("unsupported-instance-transform", - block.data.front().location, block.canonical_name, *name, - "Instance translation or rotation data is unsupported."); - return; - } - if (ContainsName(instances_, *name)) { - InputFailure("duplicate-entity", block.location, block.canonical_name, - *name, "Instance names are globally unique."); - return; - } - instances_.push_back({*name, *part, block.location}); - in_instance_ = true; - } - - void ParseInstanceBlock(const KeywordBlock& block) { - if (block.canonical_name == "END INSTANCE") { - if (!ValidateParameters(block, {}) || !RequireNoData(block)) { - return; - } - in_instance_ = false; - return; - } - if (block.canonical_name == "ASSEMBLY") { - InputFailure("unsupported-nested-assembly", block.location, - block.canonical_name, "", - "An assembly cannot be nested in an instance."); - return; - } - InputFailure("unsupported-instance-mesh-semantics", block.location, - block.canonical_name, instances_.back().name, - "Instance-local mesh definitions are unsupported."); - } - - void ParseAssemblySet(const KeywordBlock& block, bool node_set) { - const std::string_view name_parameter = node_set ? "NSET" : "ELSET"; - if (!ValidateParameters(block, {name_parameter, "INSTANCE", "GENERATE"})) { - return; - } - const auto* name = RequiredParameterValue(block, name_parameter); - const auto* instance = RequiredParameterValue(block, "INSTANCE"); - if (name == nullptr || instance == nullptr) { - return; - } - const auto* generate_parameter = Parameter(block, "GENERATE"); - if (generate_parameter != nullptr && generate_parameter->value) { - InputFailure("invalid-keyword-parameter", block.location, - block.canonical_name, "GENERATE", - "GENERATE is a valueless flag."); - return; - } - if (ContainsAssemblySetName(node_set, *name)) { - InputFailure("duplicate-entity", block.location, block.canonical_name, - *name, - "Assembly set names are unique within their node-set or " - "element-set namespace."); - return; - } - RawAssemblySet set{node_set, *name, *instance, {}, block.location}; - if (!ParseSetMembers(block, generate_parameter != nullptr, set.members)) { - return; - } - assembly_sets_.push_back(std::move(set)); - } - - void ParseMaterial(const KeywordBlock& block) { - if (!assembly_seen_ || model_boundary_seen_) { - InvalidKeywordLocation(block, - "Material definitions follow the assembly and " - "precede model boundaries."); - return; - } - if (!ValidateParameters(block, {"NAME"}) || !RequireNoData(block)) { - return; - } - const auto* name = RequiredParameterValue(block, "NAME"); - if (name == nullptr) { - return; - } - if (ContainsName(materials_, *name)) { - InputFailure("duplicate-entity", block.location, block.canonical_name, - *name, "Material names are globally unique."); - return; - } - materials_.push_back({*name, 0.0, 0.0, false, block.location, {}}); - material_eligible_ = materials_.size() - 1U; - } - - void ParseElastic(const KeywordBlock& block) { - if (!material_eligible_) { - InputFailure("invalid-keyword-location", block.location, - block.canonical_name, "", - "ELASTIC must immediately follow MATERIAL."); - return; - } - if (!ValidateParameters(block, {}) || block.data.size() != 1U || - block.data[0].fields.size() != 2U) { - if (!failure_) { - InputFailure("invalid-data-arity", block.location, block.canonical_name, - materials_[*material_eligible_].name, - "ELASTIC requires exactly one E, nu row."); - } - return; - } - RawMaterial& material = materials_[*material_eligible_]; - if (material.has_elastic) { - InputFailure("duplicate-entity", block.location, block.canonical_name, - material.name, "A material accepts one ELASTIC definition."); - return; - } - if (!ParseModelDouble(block.data[0].fields[0], material.youngs_modulus, - block.data[0].location, block.canonical_name, - model_element_family_ == ElementFamily::kShell - ? "invalid-shell-material" - : "invalid-beam-property", - "Elastic material values must be finite.") || - !ParseModelDouble(block.data[0].fields[1], material.poisson_ratio, - block.data[0].location, block.canonical_name, - model_element_family_ == ElementFamily::kShell - ? "invalid-shell-material" - : "invalid-beam-property", - "Elastic material values must be finite.")) { - return; - } - material.has_elastic = true; - material.elastic_location = block.data[0].location; - } - - void ParseBoundary( - const KeywordBlock& block, - std::vector& destination) { - if (!ValidateParameters(block, {}) || block.data.empty()) { - if (!failure_) { - InputFailure("invalid-data-arity", block.location, block.canonical_name, - "", "BOUNDARY requires one or more data rows."); - } - return; - } - for (const auto& row : block.data) { - if (row.fields.size() != 3U && row.fields.size() != 4U) { - InputFailure("invalid-data-arity", row.location, block.canonical_name, - "", - "BOUNDARY rows require target, first DOF, last DOF, and " - "optional value."); - return; - } - if (row.fields[0].empty()) { - InputFailure("unresolved-reference", row.location, block.canonical_name, - "", "A boundary target cannot be empty."); - return; - } - std::int64_t first = 0; - std::int64_t last = 0; - if (!ParseInteger(row.fields[1], first, row.location, - block.canonical_name, true) || - !ParseInteger(row.fields[2], last, row.location, block.canonical_name, - true)) { - return; - } - if (first < 1 || first > 6 || last < first || last > 6) { - InputFailure("invalid-dof", row.location, block.canonical_name, - row.fields[0], - "Boundary DOFs must be an ordered range in 1..6."); - return; - } - double value = 0.0; - if (row.fields.size() == 4U && - !ParseDouble(row.fields[3], value, row.location, - block.canonical_name)) { - return; - } - destination.push_back({row.fields[0], static_cast(first), - static_cast(last), value, row.location}); - } - } - - void ParseCload(const KeywordBlock& block, std::vector& loads) { - if (!ValidateParameters(block, {}) || block.data.empty()) { - if (!failure_) { - InputFailure("invalid-data-arity", block.location, block.canonical_name, - "", "CLOAD requires one or more rows."); - } - return; - } - for (const auto& row : block.data) { - if (row.fields.size() != 3U || row.fields[0].empty()) { - InputFailure("invalid-data-arity", row.location, block.canonical_name, - "", "CLOAD rows require target, DOF, magnitude."); - return; - } - std::int64_t dof = 0; - double magnitude = 0.0; - if (!ParseInteger(row.fields[1], dof, row.location, block.canonical_name, - true) || - !ParseDouble(row.fields[2], magnitude, row.location, - block.canonical_name)) { - return; - } - if (dof < 1 || dof > 6) { - InputFailure("invalid-dof", row.location, block.canonical_name, - row.fields[0], "CLOAD DOF must be in 1..6."); - return; - } - loads.push_back( - {row.fields[0], static_cast(dof), magnitude, row.location}); - } - } - - void ParseStepStart(const KeywordBlock& block) { - if (step_seen_) { - InputFailure("unsupported-multiple-step", block.location, - block.canonical_name, "", - "V0 accepts exactly one analysis step."); - return; - } - if (!assembly_seen_ || materials_.empty()) { - InvalidKeywordLocation(block, - "The sole step follows the complete assembly and " - "material definitions."); - return; - } - if (!ValidateParameters(block, {"NAME", "NLGEOM"}) || - !RequireNoData(block)) { - return; - } - const auto* nlgeom = Parameter(block, "NLGEOM"); - if (nlgeom != nullptr) { - if (!nlgeom->value || nlgeom->value->empty()) { - InputFailure("invalid-keyword-parameter", block.location, - block.canonical_name, "NLGEOM", - "NLGEOM requires NO in the approved subset."); - return; - } - if (!AsciiCaseInsensitiveEquals(*nlgeom->value, "NO")) { - if (model_element_family_ == ElementFamily::kShell) { - InputFailure("unsupported-nonlinear-geometry", block.location, - block.canonical_name, *nlgeom->value, - "Only absent NLGEOM or NLGEOM=NO is supported."); - } else { - ModelFailure("unsupported-nonlinear-geometry", block.location, - block.canonical_name, *nlgeom->value, - "Only absent NLGEOM or NLGEOM=NO is supported."); - } - return; - } - } - step_seen_ = true; - in_step_ = true; - step_ = RawStep{block.location, false, {}, {}, {}}; - step_load_seen_ = false; - step_no_op_seen_ = false; - } - - void ParseStepBlock(const KeywordBlock& block) { - if (block.canonical_name == "END STEP") { - active_output_ = false; - if (!ValidateParameters(block, {}) || !RequireNoData(block)) { - return; - } - if (!step_.has_static) { - InvalidKeywordLocation( - block, - "The sole step requires exactly one leading STATIC procedure."); - return; - } - in_step_ = false; - return; - } - if (block.canonical_name == "STEP") { - InputFailure("unsupported-multiple-step", block.location, - block.canonical_name, "", - "A second or nested step is unsupported."); - return; - } - if (block.canonical_name == "STATIC") { - active_output_ = false; - if (!step_.boundaries.empty() || !step_.loads.empty() || - step_no_op_seen_) { - InvalidKeywordLocation( - block, "STATIC must be the first keyword in the sole step."); - return; - } - ParseStatic(block); - } else if (block.canonical_name == "BOUNDARY") { - active_output_ = false; - if (!step_.has_static || step_load_seen_ || step_no_op_seen_) { - InvalidKeywordLocation( - block, - "Step boundaries follow STATIC and precede loads " - "and no-op requests."); - return; - } - ParseBoundary(block, step_.boundaries); - } else if (block.canonical_name == "CLOAD") { - active_output_ = false; - if (!step_.has_static || step_no_op_seen_) { - InvalidKeywordLocation( - block, - "CLOAD follows STATIC and boundaries and precedes no-op requests."); - return; - } - ParseCload(block, step_.loads); - step_load_seen_ = !failure_; - } else if (block.canonical_name == "RESTART") { - active_output_ = false; - if (!step_.has_static) { - InvalidKeywordLocation( - block, "Step no-op requests follow STATIC, boundaries, and loads."); - return; - } - step_no_op_seen_ = true; - if (RequireNoData(block)) { - AddIgnoredWarning(block); - } - } else if (block.canonical_name == "OUTPUT") { - if (!step_.has_static) { - InvalidKeywordLocation( - block, "Step no-op requests follow STATIC, boundaries, and loads."); - return; - } - step_no_op_seen_ = true; - ParseOutputRoot(block); - } else if (block.canonical_name == "NODE OUTPUT" || - block.canonical_name == "ELEMENT OUTPUT" || - block.canonical_name == "CONTACT OUTPUT") { - ParseOutputChild(block); - } else { - active_output_ = false; - RejectUnknown(block); - } - } - - void ParseStatic(const KeywordBlock& block) { - if (step_.has_static || !ValidateParameters(block, {}) || - block.data.size() != 1U || block.data[0].fields.size() != 4U) { - if (!failure_) { - InputFailure("invalid-static-data", block.location, - block.canonical_name, "", - "STATIC requires exactly one row of four values."); - } - return; - } - for (std::size_t field = 0U; field < 4U; ++field) { - if (!ParseDouble(block.data[0].fields[field], step_.static_values[field], - block.data[0].location, block.canonical_name)) { - return; - } - if (step_.static_values[field] <= 0.0) { - InputFailure("invalid-static-data", block.data[0].location, - block.canonical_name, "", - "All four STATIC fields must be positive."); - return; - } - } - if (step_.static_values[2] > step_.static_values[3]) { - InputFailure("invalid-static-data", block.data[0].location, - block.canonical_name, "", - "STATIC minimum increment cannot exceed maximum increment."); - return; - } - step_.has_static = true; - } - - void ParseOutputRoot(const KeywordBlock& block) { - const auto* field = Parameter(block, "FIELD"); - const auto* history = Parameter(block, "HISTORY"); - if ((field == nullptr) == (history == nullptr) || - (field != nullptr && field->value) || - (history != nullptr && history->value)) { - InputFailure("unsupported-keyword", block.location, block.canonical_name, - "", "OUTPUT must select exactly FIELD or HISTORY."); - return; - } - active_output_ = true; - AddIgnoredWarning(block); - } - - void ParseOutputChild(const KeywordBlock& block) { - if (!active_output_) { - InputFailure( - "invalid-keyword-location", block.location, block.canonical_name, "", - "Output variable keywords require an active OUTPUT request."); - return; - } - AddIgnoredWarning(block); - } - - void AddIgnoredWarning(const KeywordBlock& block) { - // One warning per allowlisted keyword keeps no-op provenance stable; - // subordinate variable rows remain attached to that keyword record. - definition_.warnings.push_back( - {Severity::kWarning, "ignored-input-keyword", block.location, - block.canonical_name, "", - "The allowlisted Abaqus keyword is ignored without semantic effect."}); - } - - void RejectUnknown(const KeywordBlock& block) { - if (block.canonical_name == "DLOAD" && - model_element_family_ == ElementFamily::kShell) { - InputFailure("unsupported-distributed-load", block.location, - block.canonical_name, "", - "Distributed, pressure, gravity, body, edge, and follower " - "loads are unsupported."); - return; - } - InputFailure("unsupported-keyword", block.location, block.canonical_name, - "", "The keyword is outside the approved Abaqus subset."); - } - - const RawPart* FindPart(const std::string& name) const { - const auto found = std::find_if( - parts_.begin(), parts_.end(), [&name](const RawPart& part) { - return AsciiCaseInsensitiveEquals(part.name, name); - }); - return found == parts_.end() ? nullptr : &*found; - } - - const RawInstance* FindInstance(const std::string& name) const { - const auto found = - std::find_if(instances_.begin(), instances_.end(), - [&name](const RawInstance& instance) { - return AsciiCaseInsensitiveEquals(instance.name, name); - }); - return found == instances_.end() ? nullptr : &*found; - } - - std::optional FindMaterialIndex(const std::string& name) const { - for (std::size_t index = 0U; index < materials_.size(); ++index) { - if (AsciiCaseInsensitiveEquals(materials_[index].name, name)) { - return static_cast(index); - } - } - return std::nullopt; - } - - const RawSet* FindSet(const std::vector& sets, - const std::string& name) const { - const auto found = - std::find_if(sets.begin(), sets.end(), [&name](const RawSet& set) { - return AsciiCaseInsensitiveEquals(set.name, name); - }); - return found == sets.end() ? nullptr : &*found; - } - - const RawNode* FindNode(const RawPart& part, std::int64_t label) const { - const auto found = std::find_if( - part.nodes.begin(), part.nodes.end(), - [label](const RawNode& node) { return node.label == label; }); - return found == part.nodes.end() ? nullptr : &*found; - } - - const RawElement* FindElement(const RawPart& part, std::int64_t label) const { - const auto found = std::find_if( - part.elements.begin(), part.elements.end(), - [label](const RawElement& element) { return element.label == label; }); - return found == part.elements.end() ? nullptr : &*found; - } - - void FinalizeModel() { - if (parts_.empty() || !assembly_seen_ || instances_.empty() || - materials_.empty() || !step_seen_ || !step_.has_static) { - const auto location = input_.blocks.empty() - ? SourceLocation{input_.source_path, 0U} - : input_.blocks.back().location; - InputFailure("invalid-model-cardinality", location, "", "", - "The model requires part, assembly, identity instance, " - "material, and one STATIC step."); - return; - } - - FinalizeMaterials(); - if (failure_) { - return; - } - FinalizePartsAndSections(); - if (failure_) { - return; - } - ExpandInstances(); - if (failure_) { - return; - } - if (!definition_.shell_elements.empty()) { - auto geometry = - PreprocessShellGeometry(definition_.nodes, definition_.shell_elements, - definition_.shell_sections); - if (!geometry.HasValue()) { - const auto& status = geometry.GetStatus(); - failure_ = - MappingFailure{status.Category().value_or(FailureCategory::kModel), - status.Diagnostics().front()}; - return; - } - definition_.shell_node_initial_frames = - std::move(geometry.Value().nodal_frames); - } - ExpandAssemblySets(); - if (failure_) { - return; - } - FinalizeStep(); - } - - void FinalizeMaterials() { - for (const auto& material : materials_) { - if (!material.has_elastic) { - InputFailure("unresolved-reference", material.location, "MATERIAL", - material.name, - "A material must own exactly one ELASTIC row."); - return; - } - if (model_element_family_ == ElementFamily::kShell) { - if (!(material.youngs_modulus > 0.0) || - !(material.poisson_ratio > -1.0) || - !(material.poisson_ratio < 0.5)) { - ModelFailure( - "invalid-shell-material", material.elastic_location, "ELASTIC", - material.name, - "Shell isotropic elasticity requires E>0 and -1 0.0) || !std::isfinite(shear_modulus) || - !(shear_modulus > 0.0)) { - ModelFailure("invalid-beam-property", material.elastic_location, - "ELASTIC", material.name, - "E and the derived G=E/(2*(1+nu)) must be positive."); - return; - } - definition_.materials.push_back({material.name, material.youngs_modulus, - material.poisson_ratio, - material.location}); - } - } - - void FinalizePartsAndSections() { - part_section_assignments_.resize(parts_.size()); - part_shell_section_assignments_.resize(parts_.size()); - const bool shell_model = model_element_family_ == ElementFamily::kShell; - for (std::size_t part_index = 0U; part_index < parts_.size(); - ++part_index) { - const RawPart& part = parts_[part_index]; - if (part.nodes.empty() || part.elements.empty() || - (!shell_model && part.sections.empty())) { - InputFailure("invalid-model-cardinality", part.location, "PART", - part.name, - "A part requires nodes, elements, and its approved " - "section form."); - return; - } - - PartDefinition part_definition{}; - part_definition.name = part.name; - part_definition.location = part.location; - for (const auto& node : part.nodes) { - part_definition.node_source_labels.push_back(node.label); - } - for (const auto& element : part.elements) { - part_definition.element_source_labels.push_back(element.label); - for (const auto node_label : element.node_labels) { - if (FindNode(part, node_label) == nullptr) { - InputFailure(shell_model ? "invalid-shell-connectivity" - : "unresolved-reference", - element.location, "ELEMENT", element.label_text, - "Element connectivity must resolve within its part."); - return; - } - } - } - for (const auto& set : part.node_sets) { - part_definition.node_set_names.push_back(set.name); - for (const auto label : set.members) { - if (FindNode(part, label) == nullptr) { - InputFailure("unresolved-reference", set.location, "NSET", set.name, - "Every node-set member must resolve within its part."); - return; - } - } - } - for (const auto& set : part.element_sets) { - part_definition.element_set_names.push_back(set.name); - for (const auto label : set.members) { - if (FindElement(part, label) == nullptr) { - InputFailure( - "unresolved-reference", set.location, "ELSET", set.name, - "Every element-set member must resolve within its part."); - return; - } - } - } - definition_.parts.push_back(std::move(part_definition)); - - if (shell_model) { - auto& assignments = part_shell_section_assignments_[part_index]; - for (const auto& section : part.shell_sections) { - const auto* element_set = - FindSet(part.element_sets, section.element_set_name); - const auto material_index = FindMaterialIndex(section.material_name); - if (element_set == nullptr || !material_index) { - InputFailure("unresolved-shell-section", section.location, - "SHELL SECTION", section.element_set_name, - "Shell section ELSET and MATERIAL references " - "must resolve."); - return; - } - const EntityIndex section_index = - static_cast(definition_.shell_sections.size()); - definition_.shell_sections.push_back( - {section.element_set_name, section.thickness, *material_index, - section.location}); - for (const auto element_label : element_set->members) { - if (!assignments - .emplace(element_label, - std::make_pair(section_index, *material_index)) - .second) { - InputFailure("invalid-shell-section-assignment", section.location, - "SHELL SECTION", std::to_string(element_label), - "A shell element cannot receive multiple " - "section assignments."); - return; - } - } - } - for (const auto& element : part.elements) { - if (assignments.find(element.label) == assignments.end()) { - InputFailure("invalid-shell-section-assignment", element.location, - "ELEMENT", element.label_text, - "Every shell element requires exactly one " - "resolved section assignment."); - return; - } - } - continue; - } - - auto& assignments = part_section_assignments_[part_index]; - for (const auto& section : part.sections) { - const auto* element_set = - FindSet(part.element_sets, section.element_set_name); - const auto material_index = FindMaterialIndex(section.material_name); - if (element_set == nullptr || !material_index) { - InputFailure("unresolved-reference", section.location, - "BEAM GENERAL SECTION", section.element_set_name, - "Section ELSET and MATERIAL references must resolve."); - return; - } - if (section.properties[2] != 0.0) { - ModelFailure("unsupported-coupled-section", section.location, - "BEAM GENERAL SECTION", section.element_set_name, - "V0 requires exact I12=0."); - return; - } - if (!(section.properties[0] > 0.0) || !(section.properties[1] > 0.0) || - !(section.properties[3] > 0.0) || !(section.properties[4] > 0.0)) { - ModelFailure("invalid-beam-property", section.location, - "BEAM GENERAL SECTION", section.element_set_name, - "A, I11, I22, and J must be positive."); - return; - } - const EntityIndex section_index = - static_cast(definition_.sections.size()); - definition_.sections.push_back( - {section.element_set_name, section.properties[0], - section.properties[1], section.properties[2], - section.properties[3], section.properties[4], section.first_axis, - section.section_points, section.location}); - for (const auto element_label : element_set->members) { - if (!assignments - .emplace(element_label, - std::make_pair(section_index, *material_index)) - .second) { - InputFailure("duplicate-entity", section.location, - "BEAM GENERAL SECTION", std::to_string(element_label), - "An element cannot receive multiple section " - "assignments."); - return; - } - } - } - for (const auto& element : part.elements) { - if (assignments.find(element.label) == assignments.end()) { - InputFailure( - "unresolved-reference", element.location, "ELEMENT", - element.label_text, - "Every B33 element requires a resolved section assignment."); - return; - } - } - } - } - - std::size_t PartIndex(const RawPart& part) const { - return static_cast(&part - parts_.data()); - } - - bool ValidateGeometry(const RawElement& raw, const Node& first, - const Node& second, const GeneralBeamSection& section) { - const auto maximum_absolute = [](const Vector3& vector) { - return std::max( - {std::abs(vector[0]), std::abs(vector[1]), std::abs(vector[2])}); - }; - const Vector3 first_position{first.coordinates}; - const Vector3 second_position{second.coordinates}; - - // Compare both approved inequalities after a common scaling. This - // preserves the exact ratios while avoiding overflow in x*x and in - // subtraction between large finite coordinates. - const double global_coordinate_scale = - std::max({1.0, maximum_absolute(first_position), - maximum_absolute(second_position)}); - const Vector3 first_scaled = first_position / global_coordinate_scale; - const Vector3 second_scaled = second_position / global_coordinate_scale; - const Vector3 delta_scaled = second_scaled - first_scaled; - const double length_ratio = delta_scaled.Norm(); - const double coordinate_norm_ratio = - std::max({1.0 / global_coordinate_scale, first_scaled.Norm(), - second_scaled.Norm()}); - if (!(length_ratio > 1.0e-12 * coordinate_norm_ratio)) { - return ModelFailure( - "invalid-beam-length", raw.location, "ELEMENT", raw.label_text, - "Beam length fails the approved scale-aware threshold."); - } - const Vector3 tangent = delta_scaled / length_ratio; - - const Vector3 guide{section.first_axis}; - const double global_guide_scale = std::max(1.0, maximum_absolute(guide)); - const Vector3 guide_scaled = guide / global_guide_scale; - const double projection = guide_scaled.Dot(tangent); - const Vector3 perpendicular = guide_scaled - projection * tangent; - const double guide_norm_ratio = - std::max(1.0 / global_guide_scale, guide_scaled.Norm()); - if (!(perpendicular.Norm() > 1.0e-12 * guide_norm_ratio)) { - return ModelFailure( - "invalid-beam-guide-vector", section.location, "BEAM GENERAL SECTION", - raw.label_text, - "The first section axis cannot be zero or tangent-parallel."); - } - return true; - } - - void ExpandInstances() { - for (const auto& raw_instance : instances_) { - const RawPart* part = FindPart(raw_instance.part_name); - if (part == nullptr) { - InputFailure("unresolved-reference", raw_instance.location, "INSTANCE", - raw_instance.name, - "INSTANCE PART must resolve case-insensitively."); - return; - } - InstanceDefinition instance{ - raw_instance.name, part->name, {}, {}, raw_instance.location}; - std::map node_indices; - std::map element_indices; - - // Instance order, followed by part-local declaration order, is the - // sole source of stable expanded internal IDs. - for (const auto& raw_node : part->nodes) { - if (definition_.nodes.size() > - std::numeric_limits::max()) { - InputFailure("entity-index-overflow", raw_node.location, "NODE", - raw_node.label_text, - "Expanded node count exceeds EntityIndex capacity."); - return; - } - const EntityIndex index = - static_cast(definition_.nodes.size()); - definition_.nodes.push_back( - {{raw_instance.name, raw_node.label, raw_node.label_text}, - raw_node.coordinates, - raw_node.location}); - node_indices.emplace(raw_node.label, index); - instance.node_mappings.push_back({raw_node.label, index}); - } - - for (const auto& raw_element : part->elements) { - EntityIndex index = 0U; - if (raw_element.type == RawElement::Type::kB33) { - const auto first = node_indices.find(raw_element.node_labels[0]); - const auto second = node_indices.find(raw_element.node_labels[1]); - const auto& assignments = part_section_assignments_[PartIndex(*part)]; - const auto assignment = assignments.find(raw_element.label); - if (first == node_indices.end() || second == node_indices.end() || - assignment == assignments.end()) { - InputFailure( - "unresolved-reference", raw_element.location, "ELEMENT", - raw_element.label_text, - "Expanded connectivity and section assignment must resolve."); - return; - } - if (definition_.elements.size() > - std::numeric_limits::max()) { - InputFailure( - "entity-index-overflow", raw_element.location, "ELEMENT", - raw_element.label_text, - "Expanded element count exceeds EntityIndex capacity."); - return; - } - index = static_cast(definition_.elements.size()); - const auto& section = definition_.sections[assignment->second.first]; - if (!ValidateGeometry(raw_element, definition_.nodes[first->second], - definition_.nodes[second->second], section)) { - return; - } - definition_.elements.push_back( - {{raw_instance.name, raw_element.label, raw_element.label_text}, - {first->second, second->second}, - assignment->second.second, - assignment->second.first, - raw_element.location}); - } else { - std::array connected_nodes{}; - for (std::size_t node = 0U; node < connected_nodes.size(); ++node) { - const auto found = node_indices.find(raw_element.node_labels[node]); - if (found == node_indices.end()) { - InputFailure("invalid-shell-connectivity", raw_element.location, - "ELEMENT", raw_element.label_text, - "Expanded shell connectivity must resolve."); - return; - } - connected_nodes[node] = found->second; - } - const auto& assignments = - part_shell_section_assignments_[PartIndex(*part)]; - const auto assignment = assignments.find(raw_element.label); - if (assignment == assignments.end()) { - InputFailure( - "invalid-shell-section-assignment", raw_element.location, - "ELEMENT", raw_element.label_text, - "Expanded shell section assignment must resolve exactly once."); - return; - } - if (definition_.shell_elements.size() > - std::numeric_limits::max()) { - InputFailure( - "entity-index-overflow", raw_element.location, "ELEMENT", - raw_element.label_text, - "Expanded shell element count exceeds EntityIndex capacity."); - return; - } - index = static_cast(definition_.shell_elements.size()); - definition_.shell_elements.push_back( - {{raw_instance.name, raw_element.label, raw_element.label_text}, - raw_element.type == RawElement::Type::kS4 - ? ShellSourceElementType::kS4 - : ShellSourceElementType::kS4r, - connected_nodes, - assignment->second.second, - assignment->second.first, - raw_element.location}); - } - element_indices.emplace(raw_element.label, index); - instance.element_mappings.push_back({raw_element.label, index}); - } - - for (const auto& raw_set : part->node_sets) { - NodeSet set{raw_set.name, raw_instance.name, {}, raw_set.location}; - for (const auto member : raw_set.members) { - const auto found = node_indices.find(member); - if (found == node_indices.end()) { - InputFailure("unresolved-reference", raw_set.location, "NSET", - raw_set.name, "Part node-set expansion failed."); - return; - } - set.node_indices.push_back(found->second); - } - definition_.node_sets.push_back(std::move(set)); - } - for (const auto& raw_set : part->element_sets) { - ElementSet set{raw_set.name, raw_instance.name, {}, raw_set.location}; - for (const auto member : raw_set.members) { - const auto found = element_indices.find(member); - if (found == element_indices.end()) { - InputFailure("unresolved-reference", raw_set.location, "ELSET", - raw_set.name, "Part element-set expansion failed."); - return; - } - set.element_indices.push_back(found->second); - } - definition_.element_sets.push_back(std::move(set)); - } - definition_.instances.push_back(std::move(instance)); - } - } - - void ExpandAssemblySets() { - for (const auto& raw_set : assembly_sets_) { - const RawInstance* raw_instance = FindInstance(raw_set.instance_name); - if (raw_instance == nullptr) { - InputFailure("unresolved-reference", raw_set.location, - raw_set.is_node_set ? "NSET" : "ELSET", raw_set.name, - "Assembly set INSTANCE must resolve."); - return; - } - const auto definition_instance = std::find_if( - definition_.instances.begin(), definition_.instances.end(), - [&raw_instance](const InstanceDefinition& instance) { - return AsciiCaseInsensitiveEquals(instance.name, - raw_instance->name); - }); - if (definition_instance == definition_.instances.end()) { - InputFailure("unresolved-reference", raw_set.location, - raw_set.is_node_set ? "NSET" : "ELSET", raw_set.name, - "Assembly set instance expansion is unavailable."); - return; - } - if (raw_set.is_node_set) { - definition_.node_sets.erase( - std::remove_if( - definition_.node_sets.begin(), definition_.node_sets.end(), - [&raw_set](const NodeSet& set) { - return AsciiCaseInsensitiveEquals(set.name, raw_set.name); - }), - definition_.node_sets.end()); - NodeSet set{ - raw_set.name, definition_instance->name, {}, raw_set.location}; - for (const auto member : raw_set.members) { - const auto mapping = - std::find_if(definition_instance->node_mappings.begin(), - definition_instance->node_mappings.end(), - [member](const SourceIndexMapping& value) { - return value.source_label == member; - }); - if (mapping == definition_instance->node_mappings.end()) { - InputFailure("unresolved-reference", raw_set.location, "NSET", - raw_set.name, - "Assembly node-set member must resolve in its " - "instance."); - return; - } - set.node_indices.push_back(mapping->internal_index); - } - definition_.node_sets.push_back(std::move(set)); - } else { - definition_.element_sets.erase( - std::remove_if(definition_.element_sets.begin(), - definition_.element_sets.end(), - [&raw_set](const ElementSet& set) { - return AsciiCaseInsensitiveEquals(set.name, - raw_set.name); - }), - definition_.element_sets.end()); - ElementSet set{ - raw_set.name, definition_instance->name, {}, raw_set.location}; - for (const auto member : raw_set.members) { - const auto mapping = - std::find_if(definition_instance->element_mappings.begin(), - definition_instance->element_mappings.end(), - [member](const SourceIndexMapping& value) { - return value.source_label == member; - }); - if (mapping == definition_instance->element_mappings.end()) { - InputFailure("unresolved-reference", raw_set.location, "ELSET", - raw_set.name, - "Assembly element-set member must resolve in " - "its instance."); - return; - } - set.element_indices.push_back(mapping->internal_index); - } - definition_.element_sets.push_back(std::move(set)); - } - } - } - - /// @brief Builds one stable node-target index from the completed candidate. - SourceTargetIndex BuildSourceTargetIndex() const { - std::vector entries; - std::size_t declaration_order = 0U; - for (std::size_t node = 0U; node < definition_.nodes.size(); ++node) { - const auto& source_id = definition_.nodes[node].source_id; - entries.push_back({SourceEntityKind::kNode, source_id.instance_name, "", - source_id, static_cast(node), - declaration_order++}); - } - for (const auto& set : definition_.node_sets) { - for (const EntityIndex node : set.node_indices) { - const auto& source_id = definition_.nodes[node].source_id; - entries.push_back({SourceEntityKind::kNode, - set.instance_name.value_or(source_id.instance_name), - set.name, source_id, node, declaration_order++}); - } - } - return SourceTargetIndex{std::move(entries)}; - } - - std::optional> ResolveNodeTarget( - const SourceTargetResolver& resolver, const std::string& target, - const SourceLocation& location, const std::string& keyword) { - auto resolved = resolver.Resolve({SourceEntityKind::kNode, "", target}); - if (!resolved.HasValue()) { - InputFailure( - "unresolved-reference", location, keyword, target, - "The boundary or load target must resolve unambiguously to one " - "node or one node set."); - return std::nullopt; - } - std::vector indices; - indices.reserve(resolved.Value().size()); - for (const auto& entry : resolved.Value()) { - indices.push_back(entry.entity_index); - } - return indices; - } - - void FinalizeStep() { - std::vector boundaries = - model_boundaries_; - boundaries.insert(boundaries.end(), step_.boundaries.begin(), - step_.boundaries.end()); - const SourceTargetIndex target_index = BuildSourceTargetIndex(); - const SourceTargetResolver target_resolver{target_index}; - - std::map, double> prescribed_values; - for (const auto& boundary : boundaries) { - auto target = ResolveNodeTarget(target_resolver, boundary.target, - boundary.location, "BOUNDARY"); - if (!target) { - return; - } - for (const auto node : *target) { - for (int dof = boundary.first_dof; dof <= boundary.last_dof; ++dof) { - const auto key = std::make_pair(node, dof); - const auto existing = prescribed_values.find(key); - if (existing != prescribed_values.end() && - existing->second != boundary.value) { - InputFailure("conflicting-boundary-condition", boundary.location, - "BOUNDARY", boundary.target, - "Expanded boundary rows prescribe different values to " - "one node/DOF."); - return; - } - prescribed_values[key] = boundary.value; - } - } - } - for (const auto& load : step_.loads) { - if (!ResolveNodeTarget(target_resolver, load.target, load.location, - "CLOAD")) { - return; - } - } - - // Static time-control values are provenance only: V0 creates exactly - // the canonical final frame 0 and performs no increment loop here. - definition_.steps.push_back({"Step-1", std::move(boundaries), step_.loads, - step_.static_values[0], step_.static_values[1], - step_.static_values[2], step_.static_values[3], - step_.location}); - } - - const ParsedInput& input_; - ModelDefinition definition_{}; - std::optional failure_; - - std::vector parts_; - std::vector instances_; - std::vector assembly_sets_; - std::vector materials_; - std::vector model_boundaries_; - RawStep step_{}; - std::vector>> - part_section_assignments_; - std::vector>> - part_shell_section_assignments_; - - std::optional current_part_; - std::optional pending_section_; - std::optional material_eligible_; - std::optional model_element_family_; - bool heading_seen_{false}; - bool part_elements_seen_{false}; - bool part_sets_seen_{false}; - bool part_sections_seen_{false}; - bool beam_section_context_active_{false}; - bool assembly_seen_{false}; - bool assembly_set_seen_{false}; - bool model_boundary_seen_{false}; - bool in_assembly_{false}; - bool in_instance_{false}; - bool step_seen_{false}; - bool in_step_{false}; - bool step_load_seen_{false}; - bool step_no_op_seen_{false}; - bool active_output_{false}; -}; - -} // namespace Result AbaqusDomainMapper::Map(const ParsedInput& input) const { - return MappingContext{input}.Run(); + abaqus_internal::DomainMappingContext context{input}; + const abaqus_internal::TopologyMapper topology_mapper; + + Status status = topology_mapper.Map(context); + if (!status.IsOk()) { + return Result::Failure(std::move(status)); + } + status = abaqus_internal::MaterialPropertyMapper{}.Map(context); + if (!status.IsOk()) { + return Result::Failure(std::move(status)); + } + status = topology_mapper.Finalize(context); + if (!status.IsOk()) { + return Result::Failure(std::move(status)); + } + status = abaqus_internal::StepDefinitionMapper{}.Map(context); + if (!status.IsOk()) { + return Result::Failure(std::move(status)); + } + return abaqus_internal::DomainBuilder{}.Build(context); } } // namespace fesa diff --git a/src/fesa/io/abaqus/domain_mapping_context.cpp b/src/fesa/io/abaqus/domain_mapping_context.cpp new file mode 100644 index 0000000..c60519e --- /dev/null +++ b/src/fesa/io/abaqus/domain_mapping_context.cpp @@ -0,0 +1,2247 @@ +#include "io/abaqus/domain_mapping_context.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/core/ascii.h" +#include "fesa/math/vector3.h" +#include "fesa/model/shell_geometry.h" +#include "fesa/model/source_target_resolver.h" + +namespace fesa::abaqus_internal { +namespace { + +std::vector WithoutTrailingEmpty(std::vector fields) { + while (!fields.empty() && fields.back().empty()) { + fields.pop_back(); + } + return fields; +} + +struct RawNode { + std::int64_t label; + std::string label_text; + std::array coordinates; + SourceLocation location; +}; + +struct RawElement { + enum class Type { kB33, kS4, kS4r }; + + std::int64_t label; + std::string label_text; + Type type; + std::vector node_labels; + SourceLocation location; +}; + +enum class ElementFamily { kBeam, kShell }; + +struct RawSet { + std::string name; + std::vector members; + SourceLocation location; +}; + +struct RawSection { + std::string element_set_name; + std::string material_name; + std::array properties; + std::array first_axis; + std::vector> section_points; + SourceLocation location; +}; + +struct RawShellSection { + std::string element_set_name; + std::string material_name; + double thickness; + SourceLocation location; +}; + +struct RawPart { + std::string name; + SourceLocation location; + std::vector nodes; + std::vector elements; + std::vector node_sets; + std::vector element_sets; + std::vector sections; + std::vector shell_sections; +}; + +struct RawInstance { + std::string name; + std::string part_name; + SourceLocation location; +}; + +struct RawAssemblySet { + bool is_node_set; + std::string name; + std::string instance_name; + std::vector members; + SourceLocation location; +}; + +struct RawMaterial { + std::string name; + double youngs_modulus{0.0}; + double poisson_ratio{0.0}; + bool has_elastic{false}; + SourceLocation location; + SourceLocation elastic_location; +}; + +struct RawStep { + SourceLocation location; + bool has_static{false}; + std::array static_values{}; + std::vector boundaries; + std::vector loads; +}; + +struct MappingFailure { + FailureCategory category; + Diagnostic diagnostic; +}; + +} // namespace + +/// @brief Builds one complete Domain candidate while retaining source identity. +/// @note The candidate is committed only after all blocks and cross-references +/// have been validated. +class DomainMappingContext::Impl { + public: + explicit Impl(const ParsedInput& input) : input_{input} {} + + Status ParseSyntax() { + ParseBlocks(); + return CurrentStatus(); + } + + Status FinalizeMaterialProperties() { + if (failure_) { + return CurrentStatus(); + } + ValidateCardinality(); + if (!failure_) { + FinalizeMaterials(); + } + if (!failure_) { + FinalizePartsAndSections(); + } + return CurrentStatus(); + } + + Status FinalizeTopology() { + if (failure_) { + return CurrentStatus(); + } + ExpandInstances(); + if (!failure_ && !definition_.shell_elements.empty()) { + auto geometry = + PreprocessShellGeometry(definition_.nodes, definition_.shell_elements, + definition_.shell_sections); + if (!geometry.HasValue()) { + const auto& status = geometry.GetStatus(); + failure_ = + MappingFailure{status.Category().value_or(FailureCategory::kModel), + status.Diagnostics().front()}; + } else { + definition_.shell_node_initial_frames = + std::move(geometry.Value().nodal_frames); + } + } + if (!failure_) { + ExpandAssemblySets(); + } + return CurrentStatus(); + } + + Status FinalizeStepCandidate() { + if (!failure_) { + FinalizeStep(); + } + return CurrentStatus(); + } + + Result Commit() { + if (failure_) { + return Result::Failure(Status::Failure( + failure_->category, {std::move(failure_->diagnostic)})); + } + SortDiagnostics(definition_.warnings); + return Domain::Create(std::move(definition_)); + } + + DomainMappingCandidateSummary CandidateSummary() const noexcept { + DomainMappingCandidateSummary summary{}; + summary.part_count = parts_.size(); + summary.instance_count = instances_.size(); + summary.material_count = materials_.size(); + summary.model_boundary_count = model_boundaries_.size(); + summary.step_boundary_count = step_.boundaries.size(); + summary.step_load_count = step_.loads.size(); + summary.has_static_step = step_.has_static; + for (const auto& part : parts_) { + summary.node_count += part.nodes.size(); + summary.element_count += part.elements.size(); + summary.node_set_count += part.node_sets.size(); + summary.element_set_count += part.element_sets.size(); + summary.beam_section_count += part.sections.size(); + summary.shell_section_count += part.shell_sections.size(); + } + for (const auto& set : assembly_sets_) { + if (set.is_node_set) { + ++summary.node_set_count; + } else { + ++summary.element_set_count; + } + } + return summary; + } + + private: + Status CurrentStatus() const { + if (!failure_) { + return Status::Ok(); + } + return Status::Failure(failure_->category, {failure_->diagnostic}); + } + + const KeywordParameter* Parameter(const KeywordBlock& block, + std::string_view name) const { + const auto found = + std::find_if(block.parameters.begin(), block.parameters.end(), + [name](const KeywordParameter& candidate) { + return candidate.name == name; + }); + return found == block.parameters.end() ? nullptr : &*found; + } + + bool Fail(FailureCategory category, std::string code, + const SourceLocation& location, std::string keyword, + std::string entity_identity, std::string message) { + if (!failure_) { + failure_ = MappingFailure{ + category, + {Severity::kError, std::move(code), location, std::move(keyword), + std::move(entity_identity), std::move(message)}}; + } + return false; + } + + bool InputFailure(std::string code, const SourceLocation& location, + std::string keyword, std::string entity_identity, + std::string message) { + return Fail(FailureCategory::kInput, std::move(code), location, + std::move(keyword), std::move(entity_identity), + std::move(message)); + } + + bool ModelFailure(std::string code, const SourceLocation& location, + std::string keyword, std::string entity_identity, + std::string message) { + return Fail(FailureCategory::kModel, std::move(code), location, + std::move(keyword), std::move(entity_identity), + std::move(message)); + } + + bool InvalidKeywordLocation(const KeywordBlock& block, std::string message) { + return InputFailure("invalid-keyword-location", block.location, + block.canonical_name, "", std::move(message)); + } + + bool ValidateParameters(const KeywordBlock& block, + const std::vector& allowed) { + std::set seen; + for (const auto& candidate : block.parameters) { + if (!seen.insert(candidate.name).second) { + return InputFailure("duplicate-entity", block.location, + block.canonical_name, candidate.name, + "A keyword parameter may be declared only once."); + } + if (std::find(allowed.begin(), allowed.end(), candidate.name) == + allowed.end()) { + return InputFailure( + "invalid-keyword-parameter", block.location, block.canonical_name, + candidate.name, + "The keyword parameter is outside the approved subset."); + } + } + return true; + } + + const std::string* RequiredParameterValue(const KeywordBlock& block, + std::string_view name) { + const auto* found = Parameter(block, name); + if (found == nullptr || !found->value || found->value->empty()) { + InputFailure("invalid-keyword-parameter", block.location, + block.canonical_name, std::string{name}, + "The keyword requires a non-empty parameter value."); + return nullptr; + } + return &*found->value; + } + + bool RequireNoData(const KeywordBlock& block) { + if (!block.data.empty()) { + return InputFailure("invalid-data-arity", block.data.front().location, + block.canonical_name, "", + "This keyword does not accept data rows."); + } + return true; + } + + bool ParseInteger(const std::string& text, std::int64_t& value, + const SourceLocation& location, const std::string& keyword, + bool positive) { + if (text.empty()) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "A numeric field cannot be empty."); + } + char* end = nullptr; + errno = 0; + const long long parsed = std::strtoll(text.c_str(), &end, 10); + if (errno == ERANGE || end != text.c_str() + text.size() || + (positive && parsed <= 0)) { + return InputFailure( + "invalid-numeric-value", location, keyword, text, + "The field must be a valid positive base-10 integer."); + } + value = static_cast(parsed); + return true; + } + + bool ParseDouble(const std::string& text, double& value, + const SourceLocation& location, const std::string& keyword) { + if (text.empty()) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "A numeric field cannot be empty."); + } + char* end = nullptr; + errno = 0; + const double parsed = std::strtod(text.c_str(), &end); + if (errno == ERANGE || end != text.c_str() + text.size() || + !std::isfinite(parsed)) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "The field must be a finite floating-point value."); + } + value = parsed; + return true; + } + + bool ParseModelDouble(const std::string& text, double& value, + const SourceLocation& location, + const std::string& keyword, + const std::string& diagnostic_code, + const std::string& message) { + if (text.empty()) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "A numeric field cannot be empty."); + } + char* end = nullptr; + errno = 0; + const double parsed = std::strtod(text.c_str(), &end); + if (end != text.c_str() + text.size()) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "The field must use floating-point syntax."); + } + if (errno == ERANGE || !std::isfinite(parsed)) { + return ModelFailure(diagnostic_code, location, keyword, text, message); + } + value = parsed; + return true; + } + + bool ContainsName(const std::vector& values, + const std::string& name) const { + return std::any_of(values.begin(), values.end(), + [&name](const RawPart& value) { + return AsciiCaseInsensitiveEquals(value.name, name); + }); + } + + bool ContainsName(const std::vector& values, + const std::string& name) const { + return std::any_of(values.begin(), values.end(), + [&name](const RawInstance& value) { + return AsciiCaseInsensitiveEquals(value.name, name); + }); + } + + bool ContainsName(const std::vector& values, + const std::string& name) const { + return std::any_of(values.begin(), values.end(), + [&name](const RawMaterial& value) { + return AsciiCaseInsensitiveEquals(value.name, name); + }); + } + + bool ContainsSetName(const std::vector& sets, + const std::string& name) const { + const auto matches = [&name](const RawSet& set) { + return AsciiCaseInsensitiveEquals(set.name, name); + }; + return std::any_of(sets.begin(), sets.end(), matches); + } + + bool ContainsAssemblySetName(bool node_set, const std::string& name) const { + return std::any_of(assembly_sets_.begin(), assembly_sets_.end(), + [node_set, &name](const RawAssemblySet& set) { + return set.is_node_set == node_set && + AsciiCaseInsensitiveEquals(set.name, name); + }); + } + + void ParseBlocks() { + definition_.source_path = input_.source_path; + definition_.source_content_identity = input_.source_content_identity; + + for (std::size_t index = 0U; index < input_.blocks.size() && !failure_; + ++index) { + const auto& block = input_.blocks[index]; + if (in_instance_) { + ParseInstanceBlock(block); + } else if (current_part_) { + ParsePartBlock(block); + } else if (in_assembly_) { + ParseAssemblyBlock(block); + } else if (in_step_) { + ParseStepBlock(block); + } else { + ParseTopLevelBlock(block, index); + } + } + + if (!failure_ && + (current_part_ || in_assembly_ || in_instance_ || in_step_)) { + const auto location = input_.blocks.empty() + ? SourceLocation{input_.source_path, 0U} + : input_.blocks.back().location; + InputFailure("unclosed-keyword-block", location, "", "", + "A part, assembly, instance, or step block was not closed."); + } + } + + void ParseTopLevelBlock(const KeywordBlock& block, std::size_t index) { + if (step_seen_) { + if (block.canonical_name == "STEP") { + ParseStepStart(block); + } else { + InvalidKeywordLocation( + block, "The sole analysis step must be the final top-level block."); + } + return; + } + + if (block.canonical_name != "ELASTIC") { + material_eligible_.reset(); + } + pending_section_.reset(); + active_output_ = false; + + if (block.canonical_name == "HEADING") { + if (index != 0U || heading_seen_ || !ValidateParameters(block, {})) { + if (!failure_) { + InputFailure("invalid-keyword-location", block.location, + block.canonical_name, "", + "HEADING is optional only as the first keyword."); + } + return; + } + heading_seen_ = true; + for (std::size_t row = 0U; row < block.data.size(); ++row) { + if (row != 0U) { + definition_.heading.push_back('\n'); + } + for (std::size_t field = 0U; field < block.data[row].fields.size(); + ++field) { + if (field != 0U) { + definition_.heading.push_back(','); + } + definition_.heading += block.data[row].fields[field]; + } + } + return; + } + if (block.canonical_name == "PREPRINT") { + if (!RequireNoData(block)) { + return; + } + AddIgnoredWarning(block); + return; + } + if (block.canonical_name == "PART") { + ParsePartStart(block); + return; + } + if (block.canonical_name == "ASSEMBLY") { + ParseAssemblyStart(block); + return; + } + if (block.canonical_name == "MATERIAL") { + ParseMaterial(block); + return; + } + if (block.canonical_name == "ELASTIC") { + ParseElastic(block); + return; + } + if (block.canonical_name == "BOUNDARY") { + if (!assembly_seen_ || materials_.empty()) { + InvalidKeywordLocation( + block, "Model boundary data follows the assembly and materials."); + return; + } + model_boundary_seen_ = true; + ParseBoundary(block, model_boundaries_); + return; + } + if (block.canonical_name == "STEP") { + ParseStepStart(block); + return; + } + if (block.canonical_name == "END PART" || + block.canonical_name == "END ASSEMBLY" || + block.canonical_name == "END INSTANCE" || + block.canonical_name == "END STEP") { + InputFailure("invalid-keyword-location", block.location, + block.canonical_name, "", + "The closing keyword has no matching open block."); + return; + } + RejectUnknown(block); + } + + void ParsePartStart(const KeywordBlock& block) { + if (assembly_seen_ || !materials_.empty() || model_boundary_seen_) { + InvalidKeywordLocation( + block, "All part blocks must precede the sole assembly block."); + return; + } + if (!ValidateParameters(block, {"NAME"}) || !RequireNoData(block)) { + return; + } + const auto* name = RequiredParameterValue(block, "NAME"); + if (name == nullptr) { + return; + } + if (ContainsName(parts_, *name)) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + *name, + "Part names are unique under case-insensitive lookup."); + return; + } + parts_.push_back({*name, block.location, {}, {}, {}, {}, {}, {}}); + current_part_ = parts_.size() - 1U; + part_elements_seen_ = false; + part_sets_seen_ = false; + part_sections_seen_ = false; + beam_section_context_active_ = false; + } + + void ParsePartBlock(const KeywordBlock& block) { + if (block.canonical_name == "END PART") { + if (!ValidateParameters(block, {}) || !RequireNoData(block)) { + return; + } + const RawPart& part = parts_[*current_part_]; + const bool shell_part = model_element_family_ == ElementFamily::kShell; + if (part.nodes.empty() || part.elements.empty() || + (!shell_part && part.sections.empty())) { + InvalidKeywordLocation(block, + "A part closes only after node, element, " + "and matching section blocks."); + return; + } + current_part_.reset(); + pending_section_.reset(); + beam_section_context_active_ = false; + return; + } + if (block.canonical_name == "PART") { + InputFailure("invalid-keyword-location", block.location, + block.canonical_name, "", + "Nested part blocks are not supported."); + return; + } + if (block.canonical_name == "ASSEMBLY") { + InputFailure("unsupported-nested-assembly", block.location, + block.canonical_name, "", + "An assembly cannot be nested in a part."); + return; + } + + RawPart& part = parts_[*current_part_]; + if (block.canonical_name == "NODE") { + beam_section_context_active_ = false; + if (part_elements_seen_ || part_sets_seen_ || part_sections_seen_) { + InvalidKeywordLocation( + block, + "NODE blocks must precede element, set, and section blocks."); + return; + } + pending_section_.reset(); + ParseNodes(block, part); + } else if (block.canonical_name == "ELEMENT") { + beam_section_context_active_ = false; + if (part.nodes.empty() || part_sets_seen_ || part_sections_seen_) { + InvalidKeywordLocation(block, + "ELEMENT blocks follow at least one NODE block " + "and precede sets and sections."); + return; + } + pending_section_.reset(); + ParseElements(block, part); + part_elements_seen_ = !failure_; + } else if (block.canonical_name == "NSET") { + beam_section_context_active_ = false; + if (!part_elements_seen_ || part_sections_seen_) { + InvalidKeywordLocation( + block, + "Part sets follow element blocks and precede beam sections."); + return; + } + pending_section_.reset(); + ParseSet(block, true, part); + part_sets_seen_ = !failure_; + } else if (block.canonical_name == "ELSET") { + beam_section_context_active_ = false; + if (!part_elements_seen_ || part_sections_seen_) { + InvalidKeywordLocation( + block, + "Part sets follow element blocks and precede beam sections."); + return; + } + pending_section_.reset(); + ParseSet(block, false, part); + part_sets_seen_ = !failure_; + } else if (block.canonical_name == "BEAM GENERAL SECTION") { + if (!part_elements_seen_) { + InvalidKeywordLocation( + block, + "BEAM GENERAL SECTION follows the part mesh and optional sets."); + return; + } + if (model_element_family_ == ElementFamily::kShell) { + InputFailure( + "unsupported-mixed-element-model", block.location, + block.canonical_name, "", + "Beam-section semantics cannot be mixed with shell elements."); + return; + } + ParseBeamSection(block, part); + part_sections_seen_ = !failure_; + beam_section_context_active_ = !failure_; + } else if (block.canonical_name == "SHELL SECTION") { + beam_section_context_active_ = false; + if (!part_elements_seen_) { + InvalidKeywordLocation( + block, "SHELL SECTION follows the part mesh and optional sets."); + return; + } + if (model_element_family_ == ElementFamily::kBeam) { + InputFailure( + "unsupported-mixed-element-model", block.location, + block.canonical_name, "", + "Shell-section semantics cannot be mixed with beam elements."); + return; + } + ParseShellSection(block, part); + part_sections_seen_ = !failure_; + } else if (block.canonical_name == "SECTION POINTS") { + ParseSectionPoints(block, part); + } else if (block.canonical_name == "TRANSVERSE SHEAR STIFFNESS") { + pending_section_.reset(); + if (!beam_section_context_active_) { + InputFailure( + "invalid-keyword-location", block.location, block.canonical_name, + "", + "The ignored shear keyword still requires beam-section context."); + return; + } + AddIgnoredWarning(block); + } else { + pending_section_.reset(); + beam_section_context_active_ = false; + RejectUnknown(block); + } + } + + void ParseNodes(const KeywordBlock& block, RawPart& part) { + if (!ValidateParameters(block, {}) || block.data.empty()) { + if (!failure_) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + "", "NODE requires at least one four-field data row."); + } + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 4U) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + "", "NODE rows require label, x, y, z."); + return; + } + RawNode node{}; + node.label_text = row.fields[0]; + node.location = row.location; + if (!ParseInteger(row.fields[0], node.label, row.location, + block.canonical_name, true)) { + return; + } + if (std::any_of(part.nodes.begin(), part.nodes.end(), + [&node](const RawNode& existing) { + return existing.label == node.label; + })) { + InputFailure("duplicate-entity", row.location, block.canonical_name, + node.label_text, "Node labels are unique within a part."); + return; + } + for (std::size_t coordinate = 0U; coordinate < 3U; ++coordinate) { + if (!ParseDouble(row.fields[coordinate + 1U], + node.coordinates[coordinate], row.location, + block.canonical_name)) { + return; + } + } + part.nodes.push_back(std::move(node)); + } + } + + void ParseElements(const KeywordBlock& block, RawPart& part) { + if (!ValidateParameters(block, {"TYPE"})) { + return; + } + const auto* type = RequiredParameterValue(block, "TYPE"); + if (type == nullptr) { + return; + } + RawElement::Type element_type{}; + ElementFamily element_family{}; + std::size_t expected_field_count = 0U; + if (AsciiCaseInsensitiveEquals(*type, "B33")) { + element_type = RawElement::Type::kB33; + element_family = ElementFamily::kBeam; + expected_field_count = 3U; + } else if (AsciiCaseInsensitiveEquals(*type, "S4")) { + element_type = RawElement::Type::kS4; + element_family = ElementFamily::kShell; + expected_field_count = 5U; + } else if (AsciiCaseInsensitiveEquals(*type, "S4R")) { + element_type = RawElement::Type::kS4r; + element_family = ElementFamily::kShell; + expected_field_count = 5U; + } else { + InputFailure( + "unsupported-element-formulation", block.location, + block.canonical_name, *type, + "Only TYPE=B33, TYPE=S4, and TYPE=S4R belong to the approved " + "element subsets."); + return; + } + if (model_element_family_ && *model_element_family_ != element_family) { + InputFailure("unsupported-mixed-element-model", block.location, + block.canonical_name, *type, + "Beam and shell elements cannot be mixed in one model."); + return; + } + model_element_family_ = element_family; + if (block.data.empty()) { + InputFailure(element_family == ElementFamily::kShell + ? "invalid-shell-connectivity" + : "invalid-data-arity", + block.location, block.canonical_name, "", + "ELEMENT requires at least one row with the approved " + "connectivity arity."); + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != expected_field_count) { + InputFailure( + element_family == ElementFamily::kShell + ? "invalid-shell-connectivity" + : "invalid-data-arity", + row.location, block.canonical_name, "", + element_family == ElementFamily::kShell + ? "S4 and S4R rows require a label and exactly four nodes." + : "B33 rows require label, node 1, node 2."); + return; + } + RawElement element{}; + element.label_text = row.fields[0]; + element.type = element_type; + element.location = row.location; + if (!ParseInteger(row.fields[0], element.label, row.location, + block.canonical_name, true)) { + return; + } + element.node_labels.reserve(expected_field_count - 1U); + for (std::size_t field = 1U; field < expected_field_count; ++field) { + std::int64_t node_label = 0; + if (!ParseInteger(row.fields[field], node_label, row.location, + block.canonical_name, true)) { + return; + } + element.node_labels.push_back(node_label); + } + if (element_family == ElementFamily::kShell) { + const std::set distinct_nodes{element.node_labels.begin(), + element.node_labels.end()}; + if (distinct_nodes.size() != element.node_labels.size()) { + InputFailure( + "invalid-shell-connectivity", row.location, block.canonical_name, + element.label_text, + "Shell connectivity requires four distinct source nodes."); + return; + } + } + if (std::any_of(part.elements.begin(), part.elements.end(), + [&element](const RawElement& existing) { + return existing.label == element.label; + })) { + InputFailure("duplicate-entity", row.location, block.canonical_name, + element.label_text, + "Element labels are unique within a part."); + return; + } + part.elements.push_back(std::move(element)); + } + } + + void ParseShellSection(const KeywordBlock& block, RawPart& part) { + for (const auto& candidate : block.parameters) { + if (candidate.name != "ELSET" && candidate.name != "MATERIAL") { + InputFailure("unsupported-shell-section-option", block.location, + block.canonical_name, candidate.name, + "The shell-section option is outside the centered " + "single-layer subset."); + return; + } + } + if (!ValidateParameters(block, {"ELSET", "MATERIAL"})) { + return; + } + const auto* element_set = RequiredParameterValue(block, "ELSET"); + const auto* material = RequiredParameterValue(block, "MATERIAL"); + if (element_set == nullptr || material == nullptr) { + return; + } + if (block.data.size() != 1U || block.data[0].fields.empty() || + block.data[0].fields.size() > 2U) { + InputFailure("unsupported-shell-section-option", block.location, + block.canonical_name, *element_set, + "SHELL SECTION requires one thickness row with one optional " + "integration-point field."); + return; + } + double thickness = 0.0; + if (!ParseModelDouble(block.data[0].fields[0], thickness, + block.data[0].location, block.canonical_name, + "invalid-shell-thickness", + "Shell thickness must be finite and positive.")) { + return; + } + if (!(thickness > 0.0)) { + ModelFailure("invalid-shell-thickness", block.data[0].location, + block.canonical_name, *element_set, + "Shell thickness must be finite and positive."); + return; + } + if (block.data[0].fields.size() == 2U) { + if (!ParsePositiveSourceLabel(block.data[0].fields[1]).HasValue()) { + InputFailure("unsupported-shell-section-option", block.data[0].location, + block.canonical_name, block.data[0].fields[1], + "The optional shell integration-point field must be a " + "positive integer."); + return; + } + } + part.shell_sections.push_back( + {*element_set, *material, thickness, block.location}); + } + + bool ParseSetMembers(const KeywordBlock& block, bool generate, + std::vector& members) { + if (block.data.empty()) { + return InputFailure("invalid-data-arity", block.location, + block.canonical_name, "", + "A set requires at least one member row."); + } + for (const auto& data : block.data) { + auto fields = WithoutTrailingEmpty(data.fields); + if (generate) { + if (fields.size() != 3U) { + return InputFailure("invalid-data-arity", data.location, + block.canonical_name, "", + "GENERATE rows require first, last, increment."); + } + std::int64_t first = 0; + std::int64_t last = 0; + std::int64_t increment = 0; + if (!ParseInteger(fields[0], first, data.location, block.canonical_name, + true) || + !ParseInteger(fields[1], last, data.location, block.canonical_name, + true)) { + return false; + } + if (!ParseInteger(fields[2], increment, data.location, + block.canonical_name, false) || + increment <= 0 || first > last || (last - first) % increment != 0) { + return InputFailure("invalid-set-range", data.location, + block.canonical_name, "", + "GENERATE requires an inclusive range reached by " + "a positive increment."); + } + for (std::int64_t label = first; label <= last;) { + members.push_back(label); + if (label > last - increment) { + break; + } + label += increment; + } + } else { + if (fields.empty() || std::any_of(fields.begin(), fields.end(), + [](const std::string& field) { + return field.empty(); + })) { + return InputFailure( + "invalid-data-arity", data.location, block.canonical_name, "", + "Explicit set rows require non-empty member labels."); + } + for (const auto& field : fields) { + std::int64_t label = 0; + if (!ParseInteger(field, label, data.location, block.canonical_name, + true)) { + return false; + } + members.push_back(label); + } + } + } + std::set unique; + for (const auto member : members) { + if (!unique.insert(member).second) { + return InputFailure("duplicate-entity", block.location, + block.canonical_name, std::to_string(member), + "A set cannot repeat the same source member."); + } + } + return true; + } + + void ParseSet(const KeywordBlock& block, bool node_set, RawPart& part) { + const std::string_view name_parameter = node_set ? "NSET" : "ELSET"; + if (!ValidateParameters(block, {name_parameter, "GENERATE"})) { + return; + } + const auto* name = RequiredParameterValue(block, name_parameter); + if (name == nullptr) { + return; + } + const auto* generate_parameter = Parameter(block, "GENERATE"); + if (generate_parameter != nullptr && generate_parameter->value) { + InputFailure("invalid-keyword-parameter", block.location, + block.canonical_name, "GENERATE", + "GENERATE is a valueless flag."); + return; + } + const auto& sets = node_set ? part.node_sets : part.element_sets; + if (ContainsSetName(sets, *name)) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + *name, + "Set names are unique within their node-set or element-set " + "namespace."); + return; + } + RawSet set{*name, {}, block.location}; + if (!ParseSetMembers(block, generate_parameter != nullptr, set.members)) { + return; + } + if (node_set) { + part.node_sets.push_back(std::move(set)); + } else { + part.element_sets.push_back(std::move(set)); + } + } + + void ParseBeamSection(const KeywordBlock& block, RawPart& part) { + pending_section_.reset(); + if (!ValidateParameters(block, {"ELSET", "MATERIAL", "SECTION"})) { + return; + } + const auto* element_set = RequiredParameterValue(block, "ELSET"); + const auto* material = RequiredParameterValue(block, "MATERIAL"); + const auto* section = RequiredParameterValue(block, "SECTION"); + if (element_set == nullptr || material == nullptr || section == nullptr) { + return; + } + if (!AsciiCaseInsensitiveEquals(*section, "GENERAL")) { + InputFailure("unsupported-section-formulation", block.location, + block.canonical_name, *section, + "Only SECTION=GENERAL belongs to the approved subset."); + return; + } + if (std::any_of(part.sections.begin(), part.sections.end(), + [element_set](const RawSection& existing) { + return AsciiCaseInsensitiveEquals( + existing.element_set_name, *element_set); + })) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + *element_set, + "An element set can receive only one beam section."); + return; + } + if (block.data.size() != 2U || block.data[0].fields.size() != 5U || + block.data[1].fields.size() != 3U) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + *element_set, + "A general section requires one five-field property row and " + "one three-field axis row."); + return; + } + RawSection raw{*element_set, *material, {}, {}, {}, block.location}; + for (std::size_t property = 0U; property < 5U; ++property) { + if (!ParseModelDouble(block.data[0].fields[property], + raw.properties[property], block.data[0].location, + block.canonical_name, "invalid-beam-property", + "Beam section properties must be finite.")) { + return; + } + } + for (std::size_t coordinate = 0U; coordinate < 3U; ++coordinate) { + if (!ParseModelDouble(block.data[1].fields[coordinate], + raw.first_axis[coordinate], block.data[1].location, + block.canonical_name, "invalid-beam-guide-vector", + "The beam guide vector must be finite.")) { + return; + } + } + part.sections.push_back(std::move(raw)); + pending_section_ = part.sections.size() - 1U; + } + + void ParseSectionPoints(const KeywordBlock& block, RawPart& part) { + if (!pending_section_ || !ValidateParameters(block, {}) || + block.data.empty()) { + if (!failure_) { + InputFailure( + "invalid-keyword-location", block.location, block.canonical_name, + "", "SECTION POINTS must immediately follow a general section."); + } + return; + } + RawSection& section = part.sections[*pending_section_]; + for (const auto& row : block.data) { + if (row.fields.size() != 2U) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + section.element_set_name, + "Section-point rows require x1 and x2."); + return; + } + std::array point{}; + if (!ParseDouble(row.fields[0], point[0], row.location, + block.canonical_name) || + !ParseDouble(row.fields[1], point[1], row.location, + block.canonical_name)) { + return; + } + if (std::find(section.section_points.begin(), + section.section_points.end(), + point) != section.section_points.end()) { + InputFailure("duplicate-entity", row.location, block.canonical_name, + section.element_set_name, + "Section points must be unique within a section."); + return; + } + section.section_points.push_back(point); + } + pending_section_.reset(); + } + + void ParseAssemblyStart(const KeywordBlock& block) { + if (assembly_seen_) { + InputFailure("unsupported-nested-assembly", block.location, + block.canonical_name, "", + "V0 accepts exactly one non-nested assembly."); + return; + } + if (parts_.empty() || !materials_.empty() || model_boundary_seen_) { + InvalidKeywordLocation( + block, + "The sole assembly follows all part blocks and precedes model data."); + return; + } + if (!ValidateParameters(block, {"NAME"}) || !RequireNoData(block)) { + return; + } + if (RequiredParameterValue(block, "NAME") == nullptr) { + return; + } + assembly_seen_ = true; + in_assembly_ = true; + assembly_set_seen_ = false; + } + + void ParseAssemblyBlock(const KeywordBlock& block) { + if (block.canonical_name == "END ASSEMBLY") { + if (!ValidateParameters(block, {}) || !RequireNoData(block)) { + return; + } + if (instances_.empty()) { + InvalidKeywordLocation( + block, "The assembly requires at least one identity instance."); + return; + } + in_assembly_ = false; + return; + } + if (block.canonical_name == "ASSEMBLY") { + InputFailure("unsupported-nested-assembly", block.location, + block.canonical_name, "", + "Nested or duplicate assembly blocks are unsupported."); + } else if (block.canonical_name == "INSTANCE") { + if (assembly_set_seen_) { + InvalidKeywordLocation( + block, "All identity instances must precede assembly-level sets."); + return; + } + ParseInstanceStart(block); + } else if (block.canonical_name == "NSET") { + if (instances_.empty()) { + InvalidKeywordLocation( + block, "Assembly sets follow at least one identity instance."); + return; + } + assembly_set_seen_ = true; + ParseAssemblySet(block, true); + } else if (block.canonical_name == "ELSET") { + if (instances_.empty()) { + InvalidKeywordLocation( + block, "Assembly sets follow at least one identity instance."); + return; + } + assembly_set_seen_ = true; + ParseAssemblySet(block, false); + } else { + RejectUnknown(block); + } + } + + void ParseInstanceStart(const KeywordBlock& block) { + if (Parameter(block, "DEPENDENT") != nullptr || + Parameter(block, "INDEPENDENT") != nullptr) { + InputFailure( + "unsupported-instance-mesh-semantics", block.location, + block.canonical_name, "", + "Dependent and independent instance mesh semantics are unsupported."); + return; + } + if (!ValidateParameters(block, {"NAME", "PART"})) { + return; + } + const auto* name = RequiredParameterValue(block, "NAME"); + const auto* part = RequiredParameterValue(block, "PART"); + if (name == nullptr || part == nullptr) { + return; + } + if (!block.data.empty()) { + InputFailure("unsupported-instance-transform", + block.data.front().location, block.canonical_name, *name, + "Instance translation or rotation data is unsupported."); + return; + } + if (ContainsName(instances_, *name)) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + *name, "Instance names are globally unique."); + return; + } + instances_.push_back({*name, *part, block.location}); + in_instance_ = true; + } + + void ParseInstanceBlock(const KeywordBlock& block) { + if (block.canonical_name == "END INSTANCE") { + if (!ValidateParameters(block, {}) || !RequireNoData(block)) { + return; + } + in_instance_ = false; + return; + } + if (block.canonical_name == "ASSEMBLY") { + InputFailure("unsupported-nested-assembly", block.location, + block.canonical_name, "", + "An assembly cannot be nested in an instance."); + return; + } + InputFailure("unsupported-instance-mesh-semantics", block.location, + block.canonical_name, instances_.back().name, + "Instance-local mesh definitions are unsupported."); + } + + void ParseAssemblySet(const KeywordBlock& block, bool node_set) { + const std::string_view name_parameter = node_set ? "NSET" : "ELSET"; + if (!ValidateParameters(block, {name_parameter, "INSTANCE", "GENERATE"})) { + return; + } + const auto* name = RequiredParameterValue(block, name_parameter); + const auto* instance = RequiredParameterValue(block, "INSTANCE"); + if (name == nullptr || instance == nullptr) { + return; + } + const auto* generate_parameter = Parameter(block, "GENERATE"); + if (generate_parameter != nullptr && generate_parameter->value) { + InputFailure("invalid-keyword-parameter", block.location, + block.canonical_name, "GENERATE", + "GENERATE is a valueless flag."); + return; + } + if (ContainsAssemblySetName(node_set, *name)) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + *name, + "Assembly set names are unique within their node-set or " + "element-set namespace."); + return; + } + RawAssemblySet set{node_set, *name, *instance, {}, block.location}; + if (!ParseSetMembers(block, generate_parameter != nullptr, set.members)) { + return; + } + assembly_sets_.push_back(std::move(set)); + } + + void ParseMaterial(const KeywordBlock& block) { + if (!assembly_seen_ || model_boundary_seen_) { + InvalidKeywordLocation(block, + "Material definitions follow the assembly and " + "precede model boundaries."); + return; + } + if (!ValidateParameters(block, {"NAME"}) || !RequireNoData(block)) { + return; + } + const auto* name = RequiredParameterValue(block, "NAME"); + if (name == nullptr) { + return; + } + if (ContainsName(materials_, *name)) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + *name, "Material names are globally unique."); + return; + } + materials_.push_back({*name, 0.0, 0.0, false, block.location, {}}); + material_eligible_ = materials_.size() - 1U; + } + + void ParseElastic(const KeywordBlock& block) { + if (!material_eligible_) { + InputFailure("invalid-keyword-location", block.location, + block.canonical_name, "", + "ELASTIC must immediately follow MATERIAL."); + return; + } + if (!ValidateParameters(block, {}) || block.data.size() != 1U || + block.data[0].fields.size() != 2U) { + if (!failure_) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + materials_[*material_eligible_].name, + "ELASTIC requires exactly one E, nu row."); + } + return; + } + RawMaterial& material = materials_[*material_eligible_]; + if (material.has_elastic) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + material.name, "A material accepts one ELASTIC definition."); + return; + } + if (!ParseModelDouble(block.data[0].fields[0], material.youngs_modulus, + block.data[0].location, block.canonical_name, + model_element_family_ == ElementFamily::kShell + ? "invalid-shell-material" + : "invalid-beam-property", + "Elastic material values must be finite.") || + !ParseModelDouble(block.data[0].fields[1], material.poisson_ratio, + block.data[0].location, block.canonical_name, + model_element_family_ == ElementFamily::kShell + ? "invalid-shell-material" + : "invalid-beam-property", + "Elastic material values must be finite.")) { + return; + } + material.has_elastic = true; + material.elastic_location = block.data[0].location; + } + + void ParseBoundary( + const KeywordBlock& block, + std::vector& destination) { + if (!ValidateParameters(block, {}) || block.data.empty()) { + if (!failure_) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + "", "BOUNDARY requires one or more data rows."); + } + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 3U && row.fields.size() != 4U) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + "", + "BOUNDARY rows require target, first DOF, last DOF, and " + "optional value."); + return; + } + if (row.fields[0].empty()) { + InputFailure("unresolved-reference", row.location, block.canonical_name, + "", "A boundary target cannot be empty."); + return; + } + std::int64_t first = 0; + std::int64_t last = 0; + if (!ParseInteger(row.fields[1], first, row.location, + block.canonical_name, true) || + !ParseInteger(row.fields[2], last, row.location, block.canonical_name, + true)) { + return; + } + if (first < 1 || first > 6 || last < first || last > 6) { + InputFailure("invalid-dof", row.location, block.canonical_name, + row.fields[0], + "Boundary DOFs must be an ordered range in 1..6."); + return; + } + double value = 0.0; + if (row.fields.size() == 4U && + !ParseDouble(row.fields[3], value, row.location, + block.canonical_name)) { + return; + } + destination.push_back({row.fields[0], static_cast(first), + static_cast(last), value, row.location}); + } + } + + void ParseCload(const KeywordBlock& block, std::vector& loads) { + if (!ValidateParameters(block, {}) || block.data.empty()) { + if (!failure_) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + "", "CLOAD requires one or more rows."); + } + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 3U || row.fields[0].empty()) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + "", "CLOAD rows require target, DOF, magnitude."); + return; + } + std::int64_t dof = 0; + double magnitude = 0.0; + if (!ParseInteger(row.fields[1], dof, row.location, block.canonical_name, + true) || + !ParseDouble(row.fields[2], magnitude, row.location, + block.canonical_name)) { + return; + } + if (dof < 1 || dof > 6) { + InputFailure("invalid-dof", row.location, block.canonical_name, + row.fields[0], "CLOAD DOF must be in 1..6."); + return; + } + loads.push_back( + {row.fields[0], static_cast(dof), magnitude, row.location}); + } + } + + void ParseStepStart(const KeywordBlock& block) { + if (step_seen_) { + InputFailure("unsupported-multiple-step", block.location, + block.canonical_name, "", + "V0 accepts exactly one analysis step."); + return; + } + if (!assembly_seen_ || materials_.empty()) { + InvalidKeywordLocation(block, + "The sole step follows the complete assembly and " + "material definitions."); + return; + } + if (!ValidateParameters(block, {"NAME", "NLGEOM"}) || + !RequireNoData(block)) { + return; + } + const auto* nlgeom = Parameter(block, "NLGEOM"); + if (nlgeom != nullptr) { + if (!nlgeom->value || nlgeom->value->empty()) { + InputFailure("invalid-keyword-parameter", block.location, + block.canonical_name, "NLGEOM", + "NLGEOM requires NO in the approved subset."); + return; + } + if (!AsciiCaseInsensitiveEquals(*nlgeom->value, "NO")) { + if (model_element_family_ == ElementFamily::kShell) { + InputFailure("unsupported-nonlinear-geometry", block.location, + block.canonical_name, *nlgeom->value, + "Only absent NLGEOM or NLGEOM=NO is supported."); + } else { + ModelFailure("unsupported-nonlinear-geometry", block.location, + block.canonical_name, *nlgeom->value, + "Only absent NLGEOM or NLGEOM=NO is supported."); + } + return; + } + } + step_seen_ = true; + in_step_ = true; + step_ = RawStep{block.location, false, {}, {}, {}}; + step_load_seen_ = false; + step_no_op_seen_ = false; + } + + void ParseStepBlock(const KeywordBlock& block) { + if (block.canonical_name == "END STEP") { + active_output_ = false; + if (!ValidateParameters(block, {}) || !RequireNoData(block)) { + return; + } + if (!step_.has_static) { + InvalidKeywordLocation( + block, + "The sole step requires exactly one leading STATIC procedure."); + return; + } + in_step_ = false; + return; + } + if (block.canonical_name == "STEP") { + InputFailure("unsupported-multiple-step", block.location, + block.canonical_name, "", + "A second or nested step is unsupported."); + return; + } + if (block.canonical_name == "STATIC") { + active_output_ = false; + if (!step_.boundaries.empty() || !step_.loads.empty() || + step_no_op_seen_) { + InvalidKeywordLocation( + block, "STATIC must be the first keyword in the sole step."); + return; + } + ParseStatic(block); + } else if (block.canonical_name == "BOUNDARY") { + active_output_ = false; + if (!step_.has_static || step_load_seen_ || step_no_op_seen_) { + InvalidKeywordLocation( + block, + "Step boundaries follow STATIC and precede loads " + "and no-op requests."); + return; + } + ParseBoundary(block, step_.boundaries); + } else if (block.canonical_name == "CLOAD") { + active_output_ = false; + if (!step_.has_static || step_no_op_seen_) { + InvalidKeywordLocation( + block, + "CLOAD follows STATIC and boundaries and precedes no-op requests."); + return; + } + ParseCload(block, step_.loads); + step_load_seen_ = !failure_; + } else if (block.canonical_name == "RESTART") { + active_output_ = false; + if (!step_.has_static) { + InvalidKeywordLocation( + block, "Step no-op requests follow STATIC, boundaries, and loads."); + return; + } + step_no_op_seen_ = true; + if (RequireNoData(block)) { + AddIgnoredWarning(block); + } + } else if (block.canonical_name == "OUTPUT") { + if (!step_.has_static) { + InvalidKeywordLocation( + block, "Step no-op requests follow STATIC, boundaries, and loads."); + return; + } + step_no_op_seen_ = true; + ParseOutputRoot(block); + } else if (block.canonical_name == "NODE OUTPUT" || + block.canonical_name == "ELEMENT OUTPUT" || + block.canonical_name == "CONTACT OUTPUT") { + ParseOutputChild(block); + } else { + active_output_ = false; + RejectUnknown(block); + } + } + + void ParseStatic(const KeywordBlock& block) { + if (step_.has_static || !ValidateParameters(block, {}) || + block.data.size() != 1U || block.data[0].fields.size() != 4U) { + if (!failure_) { + InputFailure("invalid-static-data", block.location, + block.canonical_name, "", + "STATIC requires exactly one row of four values."); + } + return; + } + for (std::size_t field = 0U; field < 4U; ++field) { + if (!ParseDouble(block.data[0].fields[field], step_.static_values[field], + block.data[0].location, block.canonical_name)) { + return; + } + if (step_.static_values[field] <= 0.0) { + InputFailure("invalid-static-data", block.data[0].location, + block.canonical_name, "", + "All four STATIC fields must be positive."); + return; + } + } + if (step_.static_values[2] > step_.static_values[3]) { + InputFailure("invalid-static-data", block.data[0].location, + block.canonical_name, "", + "STATIC minimum increment cannot exceed maximum increment."); + return; + } + step_.has_static = true; + } + + void ParseOutputRoot(const KeywordBlock& block) { + const auto* field = Parameter(block, "FIELD"); + const auto* history = Parameter(block, "HISTORY"); + if ((field == nullptr) == (history == nullptr) || + (field != nullptr && field->value) || + (history != nullptr && history->value)) { + InputFailure("unsupported-keyword", block.location, block.canonical_name, + "", "OUTPUT must select exactly FIELD or HISTORY."); + return; + } + active_output_ = true; + AddIgnoredWarning(block); + } + + void ParseOutputChild(const KeywordBlock& block) { + if (!active_output_) { + InputFailure( + "invalid-keyword-location", block.location, block.canonical_name, "", + "Output variable keywords require an active OUTPUT request."); + return; + } + AddIgnoredWarning(block); + } + + void AddIgnoredWarning(const KeywordBlock& block) { + // One warning per allowlisted keyword keeps no-op provenance stable; + // subordinate variable rows remain attached to that keyword record. + definition_.warnings.push_back( + {Severity::kWarning, "ignored-input-keyword", block.location, + block.canonical_name, "", + "The allowlisted Abaqus keyword is ignored without semantic effect."}); + } + + void RejectUnknown(const KeywordBlock& block) { + if (block.canonical_name == "DLOAD" && + model_element_family_ == ElementFamily::kShell) { + InputFailure("unsupported-distributed-load", block.location, + block.canonical_name, "", + "Distributed, pressure, gravity, body, edge, and follower " + "loads are unsupported."); + return; + } + InputFailure("unsupported-keyword", block.location, block.canonical_name, + "", "The keyword is outside the approved Abaqus subset."); + } + + const RawPart* FindPart(const std::string& name) const { + const auto found = std::find_if( + parts_.begin(), parts_.end(), [&name](const RawPart& part) { + return AsciiCaseInsensitiveEquals(part.name, name); + }); + return found == parts_.end() ? nullptr : &*found; + } + + const RawInstance* FindInstance(const std::string& name) const { + const auto found = + std::find_if(instances_.begin(), instances_.end(), + [&name](const RawInstance& instance) { + return AsciiCaseInsensitiveEquals(instance.name, name); + }); + return found == instances_.end() ? nullptr : &*found; + } + + std::optional FindMaterialIndex(const std::string& name) const { + for (std::size_t index = 0U; index < materials_.size(); ++index) { + if (AsciiCaseInsensitiveEquals(materials_[index].name, name)) { + return static_cast(index); + } + } + return std::nullopt; + } + + const RawSet* FindSet(const std::vector& sets, + const std::string& name) const { + const auto found = + std::find_if(sets.begin(), sets.end(), [&name](const RawSet& set) { + return AsciiCaseInsensitiveEquals(set.name, name); + }); + return found == sets.end() ? nullptr : &*found; + } + + const RawNode* FindNode(const RawPart& part, std::int64_t label) const { + const auto found = std::find_if( + part.nodes.begin(), part.nodes.end(), + [label](const RawNode& node) { return node.label == label; }); + return found == part.nodes.end() ? nullptr : &*found; + } + + const RawElement* FindElement(const RawPart& part, std::int64_t label) const { + const auto found = std::find_if( + part.elements.begin(), part.elements.end(), + [label](const RawElement& element) { return element.label == label; }); + return found == part.elements.end() ? nullptr : &*found; + } + + void ValidateCardinality() { + if (parts_.empty() || !assembly_seen_ || instances_.empty() || + materials_.empty() || !step_seen_ || !step_.has_static) { + const auto location = input_.blocks.empty() + ? SourceLocation{input_.source_path, 0U} + : input_.blocks.back().location; + InputFailure("invalid-model-cardinality", location, "", "", + "The model requires part, assembly, identity instance, " + "material, and one STATIC step."); + } + } + + void FinalizeMaterials() { + for (const auto& material : materials_) { + if (!material.has_elastic) { + InputFailure("unresolved-reference", material.location, "MATERIAL", + material.name, + "A material must own exactly one ELASTIC row."); + return; + } + if (model_element_family_ == ElementFamily::kShell) { + if (!(material.youngs_modulus > 0.0) || + !(material.poisson_ratio > -1.0) || + !(material.poisson_ratio < 0.5)) { + ModelFailure( + "invalid-shell-material", material.elastic_location, "ELASTIC", + material.name, + "Shell isotropic elasticity requires E>0 and -1 0.0) || !std::isfinite(shear_modulus) || + !(shear_modulus > 0.0)) { + ModelFailure("invalid-beam-property", material.elastic_location, + "ELASTIC", material.name, + "E and the derived G=E/(2*(1+nu)) must be positive."); + return; + } + definition_.materials.push_back({material.name, material.youngs_modulus, + material.poisson_ratio, + material.location}); + } + } + + void FinalizePartsAndSections() { + part_section_assignments_.resize(parts_.size()); + part_shell_section_assignments_.resize(parts_.size()); + const bool shell_model = model_element_family_ == ElementFamily::kShell; + for (std::size_t part_index = 0U; part_index < parts_.size(); + ++part_index) { + const RawPart& part = parts_[part_index]; + if (part.nodes.empty() || part.elements.empty() || + (!shell_model && part.sections.empty())) { + InputFailure("invalid-model-cardinality", part.location, "PART", + part.name, + "A part requires nodes, elements, and its approved " + "section form."); + return; + } + + PartDefinition part_definition{}; + part_definition.name = part.name; + part_definition.location = part.location; + for (const auto& node : part.nodes) { + part_definition.node_source_labels.push_back(node.label); + } + for (const auto& element : part.elements) { + part_definition.element_source_labels.push_back(element.label); + for (const auto node_label : element.node_labels) { + if (FindNode(part, node_label) == nullptr) { + InputFailure(shell_model ? "invalid-shell-connectivity" + : "unresolved-reference", + element.location, "ELEMENT", element.label_text, + "Element connectivity must resolve within its part."); + return; + } + } + } + for (const auto& set : part.node_sets) { + part_definition.node_set_names.push_back(set.name); + for (const auto label : set.members) { + if (FindNode(part, label) == nullptr) { + InputFailure("unresolved-reference", set.location, "NSET", set.name, + "Every node-set member must resolve within its part."); + return; + } + } + } + for (const auto& set : part.element_sets) { + part_definition.element_set_names.push_back(set.name); + for (const auto label : set.members) { + if (FindElement(part, label) == nullptr) { + InputFailure( + "unresolved-reference", set.location, "ELSET", set.name, + "Every element-set member must resolve within its part."); + return; + } + } + } + definition_.parts.push_back(std::move(part_definition)); + + if (shell_model) { + auto& assignments = part_shell_section_assignments_[part_index]; + for (const auto& section : part.shell_sections) { + const auto* element_set = + FindSet(part.element_sets, section.element_set_name); + const auto material_index = FindMaterialIndex(section.material_name); + if (element_set == nullptr || !material_index) { + InputFailure("unresolved-shell-section", section.location, + "SHELL SECTION", section.element_set_name, + "Shell section ELSET and MATERIAL references " + "must resolve."); + return; + } + const EntityIndex section_index = + static_cast(definition_.shell_sections.size()); + definition_.shell_sections.push_back( + {section.element_set_name, section.thickness, *material_index, + section.location}); + for (const auto element_label : element_set->members) { + if (!assignments + .emplace(element_label, + std::make_pair(section_index, *material_index)) + .second) { + InputFailure("invalid-shell-section-assignment", section.location, + "SHELL SECTION", std::to_string(element_label), + "A shell element cannot receive multiple " + "section assignments."); + return; + } + } + } + for (const auto& element : part.elements) { + if (assignments.find(element.label) == assignments.end()) { + InputFailure("invalid-shell-section-assignment", element.location, + "ELEMENT", element.label_text, + "Every shell element requires exactly one " + "resolved section assignment."); + return; + } + } + continue; + } + + auto& assignments = part_section_assignments_[part_index]; + for (const auto& section : part.sections) { + const auto* element_set = + FindSet(part.element_sets, section.element_set_name); + const auto material_index = FindMaterialIndex(section.material_name); + if (element_set == nullptr || !material_index) { + InputFailure("unresolved-reference", section.location, + "BEAM GENERAL SECTION", section.element_set_name, + "Section ELSET and MATERIAL references must resolve."); + return; + } + if (section.properties[2] != 0.0) { + ModelFailure("unsupported-coupled-section", section.location, + "BEAM GENERAL SECTION", section.element_set_name, + "V0 requires exact I12=0."); + return; + } + if (!(section.properties[0] > 0.0) || !(section.properties[1] > 0.0) || + !(section.properties[3] > 0.0) || !(section.properties[4] > 0.0)) { + ModelFailure("invalid-beam-property", section.location, + "BEAM GENERAL SECTION", section.element_set_name, + "A, I11, I22, and J must be positive."); + return; + } + const EntityIndex section_index = + static_cast(definition_.sections.size()); + definition_.sections.push_back( + {section.element_set_name, section.properties[0], + section.properties[1], section.properties[2], + section.properties[3], section.properties[4], section.first_axis, + section.section_points, section.location}); + for (const auto element_label : element_set->members) { + if (!assignments + .emplace(element_label, + std::make_pair(section_index, *material_index)) + .second) { + InputFailure("duplicate-entity", section.location, + "BEAM GENERAL SECTION", std::to_string(element_label), + "An element cannot receive multiple section " + "assignments."); + return; + } + } + } + for (const auto& element : part.elements) { + if (assignments.find(element.label) == assignments.end()) { + InputFailure( + "unresolved-reference", element.location, "ELEMENT", + element.label_text, + "Every B33 element requires a resolved section assignment."); + return; + } + } + } + } + + std::size_t PartIndex(const RawPart& part) const { + return static_cast(&part - parts_.data()); + } + + bool ValidateGeometry(const RawElement& raw, const Node& first, + const Node& second, const GeneralBeamSection& section) { + const auto maximum_absolute = [](const Vector3& vector) { + return std::max( + {std::abs(vector[0]), std::abs(vector[1]), std::abs(vector[2])}); + }; + const Vector3 first_position{first.coordinates}; + const Vector3 second_position{second.coordinates}; + + // Compare both approved inequalities after a common scaling. This + // preserves the exact ratios while avoiding overflow in x*x and in + // subtraction between large finite coordinates. + const double global_coordinate_scale = + std::max({1.0, maximum_absolute(first_position), + maximum_absolute(second_position)}); + const Vector3 first_scaled = first_position / global_coordinate_scale; + const Vector3 second_scaled = second_position / global_coordinate_scale; + const Vector3 delta_scaled = second_scaled - first_scaled; + const double length_ratio = delta_scaled.Norm(); + const double coordinate_norm_ratio = + std::max({1.0 / global_coordinate_scale, first_scaled.Norm(), + second_scaled.Norm()}); + if (!(length_ratio > 1.0e-12 * coordinate_norm_ratio)) { + return ModelFailure( + "invalid-beam-length", raw.location, "ELEMENT", raw.label_text, + "Beam length fails the approved scale-aware threshold."); + } + const Vector3 tangent = delta_scaled / length_ratio; + + const Vector3 guide{section.first_axis}; + const double global_guide_scale = std::max(1.0, maximum_absolute(guide)); + const Vector3 guide_scaled = guide / global_guide_scale; + const double projection = guide_scaled.Dot(tangent); + const Vector3 perpendicular = guide_scaled - projection * tangent; + const double guide_norm_ratio = + std::max(1.0 / global_guide_scale, guide_scaled.Norm()); + if (!(perpendicular.Norm() > 1.0e-12 * guide_norm_ratio)) { + return ModelFailure( + "invalid-beam-guide-vector", section.location, "BEAM GENERAL SECTION", + raw.label_text, + "The first section axis cannot be zero or tangent-parallel."); + } + return true; + } + + void ExpandInstances() { + for (const auto& raw_instance : instances_) { + const RawPart* part = FindPart(raw_instance.part_name); + if (part == nullptr) { + InputFailure("unresolved-reference", raw_instance.location, "INSTANCE", + raw_instance.name, + "INSTANCE PART must resolve case-insensitively."); + return; + } + InstanceDefinition instance{ + raw_instance.name, part->name, {}, {}, raw_instance.location}; + std::map node_indices; + std::map element_indices; + + // Instance order, followed by part-local declaration order, is the + // sole source of stable expanded internal IDs. + for (const auto& raw_node : part->nodes) { + if (definition_.nodes.size() > + std::numeric_limits::max()) { + InputFailure("entity-index-overflow", raw_node.location, "NODE", + raw_node.label_text, + "Expanded node count exceeds EntityIndex capacity."); + return; + } + const EntityIndex index = + static_cast(definition_.nodes.size()); + definition_.nodes.push_back( + {{raw_instance.name, raw_node.label, raw_node.label_text}, + raw_node.coordinates, + raw_node.location}); + node_indices.emplace(raw_node.label, index); + instance.node_mappings.push_back({raw_node.label, index}); + } + + for (const auto& raw_element : part->elements) { + EntityIndex index = 0U; + if (raw_element.type == RawElement::Type::kB33) { + const auto first = node_indices.find(raw_element.node_labels[0]); + const auto second = node_indices.find(raw_element.node_labels[1]); + const auto& assignments = part_section_assignments_[PartIndex(*part)]; + const auto assignment = assignments.find(raw_element.label); + if (first == node_indices.end() || second == node_indices.end() || + assignment == assignments.end()) { + InputFailure( + "unresolved-reference", raw_element.location, "ELEMENT", + raw_element.label_text, + "Expanded connectivity and section assignment must resolve."); + return; + } + if (definition_.elements.size() > + std::numeric_limits::max()) { + InputFailure( + "entity-index-overflow", raw_element.location, "ELEMENT", + raw_element.label_text, + "Expanded element count exceeds EntityIndex capacity."); + return; + } + index = static_cast(definition_.elements.size()); + const auto& section = definition_.sections[assignment->second.first]; + if (!ValidateGeometry(raw_element, definition_.nodes[first->second], + definition_.nodes[second->second], section)) { + return; + } + definition_.elements.push_back( + {{raw_instance.name, raw_element.label, raw_element.label_text}, + {first->second, second->second}, + assignment->second.second, + assignment->second.first, + raw_element.location}); + } else { + std::array connected_nodes{}; + for (std::size_t node = 0U; node < connected_nodes.size(); ++node) { + const auto found = node_indices.find(raw_element.node_labels[node]); + if (found == node_indices.end()) { + InputFailure("invalid-shell-connectivity", raw_element.location, + "ELEMENT", raw_element.label_text, + "Expanded shell connectivity must resolve."); + return; + } + connected_nodes[node] = found->second; + } + const auto& assignments = + part_shell_section_assignments_[PartIndex(*part)]; + const auto assignment = assignments.find(raw_element.label); + if (assignment == assignments.end()) { + InputFailure( + "invalid-shell-section-assignment", raw_element.location, + "ELEMENT", raw_element.label_text, + "Expanded shell section assignment must resolve exactly once."); + return; + } + if (definition_.shell_elements.size() > + std::numeric_limits::max()) { + InputFailure( + "entity-index-overflow", raw_element.location, "ELEMENT", + raw_element.label_text, + "Expanded shell element count exceeds EntityIndex capacity."); + return; + } + index = static_cast(definition_.shell_elements.size()); + definition_.shell_elements.push_back( + {{raw_instance.name, raw_element.label, raw_element.label_text}, + raw_element.type == RawElement::Type::kS4 + ? ShellSourceElementType::kS4 + : ShellSourceElementType::kS4r, + connected_nodes, + assignment->second.second, + assignment->second.first, + raw_element.location}); + } + element_indices.emplace(raw_element.label, index); + instance.element_mappings.push_back({raw_element.label, index}); + } + + for (const auto& raw_set : part->node_sets) { + NodeSet set{raw_set.name, raw_instance.name, {}, raw_set.location}; + for (const auto member : raw_set.members) { + const auto found = node_indices.find(member); + if (found == node_indices.end()) { + InputFailure("unresolved-reference", raw_set.location, "NSET", + raw_set.name, "Part node-set expansion failed."); + return; + } + set.node_indices.push_back(found->second); + } + definition_.node_sets.push_back(std::move(set)); + } + for (const auto& raw_set : part->element_sets) { + ElementSet set{raw_set.name, raw_instance.name, {}, raw_set.location}; + for (const auto member : raw_set.members) { + const auto found = element_indices.find(member); + if (found == element_indices.end()) { + InputFailure("unresolved-reference", raw_set.location, "ELSET", + raw_set.name, "Part element-set expansion failed."); + return; + } + set.element_indices.push_back(found->second); + } + definition_.element_sets.push_back(std::move(set)); + } + definition_.instances.push_back(std::move(instance)); + } + } + + void ExpandAssemblySets() { + for (const auto& raw_set : assembly_sets_) { + const RawInstance* raw_instance = FindInstance(raw_set.instance_name); + if (raw_instance == nullptr) { + InputFailure("unresolved-reference", raw_set.location, + raw_set.is_node_set ? "NSET" : "ELSET", raw_set.name, + "Assembly set INSTANCE must resolve."); + return; + } + const auto definition_instance = std::find_if( + definition_.instances.begin(), definition_.instances.end(), + [&raw_instance](const InstanceDefinition& instance) { + return AsciiCaseInsensitiveEquals(instance.name, + raw_instance->name); + }); + if (definition_instance == definition_.instances.end()) { + InputFailure("unresolved-reference", raw_set.location, + raw_set.is_node_set ? "NSET" : "ELSET", raw_set.name, + "Assembly set instance expansion is unavailable."); + return; + } + if (raw_set.is_node_set) { + definition_.node_sets.erase( + std::remove_if( + definition_.node_sets.begin(), definition_.node_sets.end(), + [&raw_set](const NodeSet& set) { + return AsciiCaseInsensitiveEquals(set.name, raw_set.name); + }), + definition_.node_sets.end()); + NodeSet set{ + raw_set.name, definition_instance->name, {}, raw_set.location}; + for (const auto member : raw_set.members) { + const auto mapping = + std::find_if(definition_instance->node_mappings.begin(), + definition_instance->node_mappings.end(), + [member](const SourceIndexMapping& value) { + return value.source_label == member; + }); + if (mapping == definition_instance->node_mappings.end()) { + InputFailure("unresolved-reference", raw_set.location, "NSET", + raw_set.name, + "Assembly node-set member must resolve in its " + "instance."); + return; + } + set.node_indices.push_back(mapping->internal_index); + } + definition_.node_sets.push_back(std::move(set)); + } else { + definition_.element_sets.erase( + std::remove_if(definition_.element_sets.begin(), + definition_.element_sets.end(), + [&raw_set](const ElementSet& set) { + return AsciiCaseInsensitiveEquals(set.name, + raw_set.name); + }), + definition_.element_sets.end()); + ElementSet set{ + raw_set.name, definition_instance->name, {}, raw_set.location}; + for (const auto member : raw_set.members) { + const auto mapping = + std::find_if(definition_instance->element_mappings.begin(), + definition_instance->element_mappings.end(), + [member](const SourceIndexMapping& value) { + return value.source_label == member; + }); + if (mapping == definition_instance->element_mappings.end()) { + InputFailure("unresolved-reference", raw_set.location, "ELSET", + raw_set.name, + "Assembly element-set member must resolve in " + "its instance."); + return; + } + set.element_indices.push_back(mapping->internal_index); + } + definition_.element_sets.push_back(std::move(set)); + } + } + } + + /// @brief Builds one stable node-target index from the completed candidate. + SourceTargetIndex BuildSourceTargetIndex() const { + std::vector entries; + std::size_t declaration_order = 0U; + for (std::size_t node = 0U; node < definition_.nodes.size(); ++node) { + const auto& source_id = definition_.nodes[node].source_id; + entries.push_back({SourceEntityKind::kNode, source_id.instance_name, "", + source_id, static_cast(node), + declaration_order++}); + } + for (const auto& set : definition_.node_sets) { + for (const EntityIndex node : set.node_indices) { + const auto& source_id = definition_.nodes[node].source_id; + entries.push_back({SourceEntityKind::kNode, + set.instance_name.value_or(source_id.instance_name), + set.name, source_id, node, declaration_order++}); + } + } + return SourceTargetIndex{std::move(entries)}; + } + + std::optional> ResolveNodeTarget( + const SourceTargetResolver& resolver, const std::string& target, + const SourceLocation& location, const std::string& keyword) { + auto resolved = resolver.Resolve({SourceEntityKind::kNode, "", target}); + if (!resolved.HasValue()) { + InputFailure( + "unresolved-reference", location, keyword, target, + "The boundary or load target must resolve unambiguously to one " + "node or one node set."); + return std::nullopt; + } + std::vector indices; + indices.reserve(resolved.Value().size()); + for (const auto& entry : resolved.Value()) { + indices.push_back(entry.entity_index); + } + return indices; + } + + void FinalizeStep() { + std::vector boundaries = + model_boundaries_; + boundaries.insert(boundaries.end(), step_.boundaries.begin(), + step_.boundaries.end()); + const SourceTargetIndex target_index = BuildSourceTargetIndex(); + const SourceTargetResolver target_resolver{target_index}; + + std::map, double> prescribed_values; + for (const auto& boundary : boundaries) { + auto target = ResolveNodeTarget(target_resolver, boundary.target, + boundary.location, "BOUNDARY"); + if (!target) { + return; + } + for (const auto node : *target) { + for (int dof = boundary.first_dof; dof <= boundary.last_dof; ++dof) { + const auto key = std::make_pair(node, dof); + const auto existing = prescribed_values.find(key); + if (existing != prescribed_values.end() && + existing->second != boundary.value) { + InputFailure("conflicting-boundary-condition", boundary.location, + "BOUNDARY", boundary.target, + "Expanded boundary rows prescribe different values to " + "one node/DOF."); + return; + } + prescribed_values[key] = boundary.value; + } + } + } + for (const auto& load : step_.loads) { + if (!ResolveNodeTarget(target_resolver, load.target, load.location, + "CLOAD")) { + return; + } + } + + // Static time-control values are provenance only: V0 creates exactly + // the canonical final frame 0 and performs no increment loop here. + definition_.steps.push_back({"Step-1", std::move(boundaries), step_.loads, + step_.static_values[0], step_.static_values[1], + step_.static_values[2], step_.static_values[3], + step_.location}); + } + + const ParsedInput& input_; + ModelDefinition definition_{}; + std::optional failure_; + + std::vector parts_; + std::vector instances_; + std::vector assembly_sets_; + std::vector materials_; + std::vector model_boundaries_; + RawStep step_{}; + std::vector>> + part_section_assignments_; + std::vector>> + part_shell_section_assignments_; + + std::optional current_part_; + std::optional pending_section_; + std::optional material_eligible_; + std::optional model_element_family_; + bool heading_seen_{false}; + bool part_elements_seen_{false}; + bool part_sets_seen_{false}; + bool part_sections_seen_{false}; + bool beam_section_context_active_{false}; + bool assembly_seen_{false}; + bool assembly_set_seen_{false}; + bool model_boundary_seen_{false}; + bool in_assembly_{false}; + bool in_instance_{false}; + bool step_seen_{false}; + bool in_step_{false}; + bool step_load_seen_{false}; + bool step_no_op_seen_{false}; + bool active_output_{false}; +}; + +DomainMappingContext::DomainMappingContext(const ParsedInput& input) + : impl_{std::make_unique(input)} {} + +DomainMappingContext::~DomainMappingContext() = default; + +DomainMappingCandidateSummary DomainMappingContext::CandidateSummary() + const noexcept { + return impl_->CandidateSummary(); +} + +Status DomainMappingContext::ParseSyntax() { return impl_->ParseSyntax(); } + +Status DomainMappingContext::FinalizeMaterialProperties() { + return impl_->FinalizeMaterialProperties(); +} + +Status DomainMappingContext::FinalizeTopology() { + return impl_->FinalizeTopology(); +} + +Status DomainMappingContext::FinalizeStep() { + return impl_->FinalizeStepCandidate(); +} + +Result DomainMappingContext::Commit() { return impl_->Commit(); } + +} // namespace fesa::abaqus_internal diff --git a/src/fesa/io/abaqus/domain_mapping_context.h b/src/fesa/io/abaqus/domain_mapping_context.h new file mode 100644 index 0000000..2821baf --- /dev/null +++ b/src/fesa/io/abaqus/domain_mapping_context.h @@ -0,0 +1,79 @@ +#ifndef FESA_IO_ABAQUS_DOMAIN_MAPPING_CONTEXT_H_ +#define FESA_IO_ABAQUS_DOMAIN_MAPPING_CONTEXT_H_ + +#include +#include + +#include "fesa/core/status.h" +#include "fesa/io/abaqus/input_syntax.h" +#include "fesa/model/domain.h" + +namespace fesa::abaqus_internal { + +class DomainBuilder; +class MaterialPropertyMapper; +class StepDefinitionMapper; +class TopologyMapper; + +/// @brief Summarizes syntax-derived candidates before Domain ownership begins. +struct DomainMappingCandidateSummary { + std::size_t part_count; + std::size_t node_count; + std::size_t element_count; + std::size_t instance_count; + std::size_t node_set_count; + std::size_t element_set_count; + std::size_t material_count; + std::size_t beam_section_count; + std::size_t shell_section_count; + std::size_t model_boundary_count; + std::size_t step_boundary_count; + std::size_t step_load_count; + bool has_static_step; +}; + +/// @brief Owns uncommitted Abaqus mapping candidates shared by private stages. +/// @note The ParsedInput reference must outlive this context and all stages. +class DomainMappingContext { + public: + /// @brief Starts an empty mapping candidate for one parsed input. + explicit DomainMappingContext(const ParsedInput& input); + ~DomainMappingContext(); + + DomainMappingContext(const DomainMappingContext&) = delete; + DomainMappingContext& operator=(const DomainMappingContext&) = delete; + DomainMappingContext(DomainMappingContext&&) = delete; + DomainMappingContext& operator=(DomainMappingContext&&) = delete; + + /// @brief Reports stable raw candidate counts for component verification. + DomainMappingCandidateSummary CandidateSummary() const noexcept; + + private: + class Impl; + + friend class DomainBuilder; + friend class MaterialPropertyMapper; + friend class StepDefinitionMapper; + friend class TopologyMapper; + + /// @brief Parses syntax into uncommitted topology and semantic candidates. + Status ParseSyntax(); + + /// @brief Validates and projects material and property candidates. + Status FinalizeMaterialProperties(); + + /// @brief Expands identity instances and stable topology candidates. + Status FinalizeTopology(); + + /// @brief Resolves the sole static step against finalized source targets. + Status FinalizeStep(); + + /// @brief Atomically transfers a complete candidate into Domain ownership. + Result Commit(); + + std::unique_ptr impl_; +}; + +} // namespace fesa::abaqus_internal + +#endif // FESA_IO_ABAQUS_DOMAIN_MAPPING_CONTEXT_H_ diff --git a/src/fesa/io/abaqus/material_property_mapper.cpp b/src/fesa/io/abaqus/material_property_mapper.cpp new file mode 100644 index 0000000..84cdc2b --- /dev/null +++ b/src/fesa/io/abaqus/material_property_mapper.cpp @@ -0,0 +1,11 @@ +#include "io/abaqus/material_property_mapper.h" + +#include "io/abaqus/domain_mapping_context.h" + +namespace fesa::abaqus_internal { + +Status MaterialPropertyMapper::Map(DomainMappingContext& context) const { + return context.FinalizeMaterialProperties(); +} + +} // namespace fesa::abaqus_internal diff --git a/src/fesa/io/abaqus/material_property_mapper.h b/src/fesa/io/abaqus/material_property_mapper.h new file mode 100644 index 0000000..065c6b3 --- /dev/null +++ b/src/fesa/io/abaqus/material_property_mapper.h @@ -0,0 +1,19 @@ +#ifndef FESA_IO_ABAQUS_MATERIAL_PROPERTY_MAPPER_H_ +#define FESA_IO_ABAQUS_MATERIAL_PROPERTY_MAPPER_H_ + +#include "fesa/core/status.h" + +namespace fesa::abaqus_internal { + +class DomainMappingContext; + +/// @brief Validates current isotropic material and section candidates. +class MaterialPropertyMapper { + public: + /// @brief Projects validated material and property records in source order. + Status Map(DomainMappingContext& context) const; +}; + +} // namespace fesa::abaqus_internal + +#endif // FESA_IO_ABAQUS_MATERIAL_PROPERTY_MAPPER_H_ diff --git a/src/fesa/io/abaqus/step_definition_mapper.cpp b/src/fesa/io/abaqus/step_definition_mapper.cpp new file mode 100644 index 0000000..2ecd82d --- /dev/null +++ b/src/fesa/io/abaqus/step_definition_mapper.cpp @@ -0,0 +1,11 @@ +#include "io/abaqus/step_definition_mapper.h" + +#include "io/abaqus/domain_mapping_context.h" + +namespace fesa::abaqus_internal { + +Status StepDefinitionMapper::Map(DomainMappingContext& context) const { + return context.FinalizeStep(); +} + +} // namespace fesa::abaqus_internal diff --git a/src/fesa/io/abaqus/step_definition_mapper.h b/src/fesa/io/abaqus/step_definition_mapper.h new file mode 100644 index 0000000..8fe1138 --- /dev/null +++ b/src/fesa/io/abaqus/step_definition_mapper.h @@ -0,0 +1,19 @@ +#ifndef FESA_IO_ABAQUS_STEP_DEFINITION_MAPPER_H_ +#define FESA_IO_ABAQUS_STEP_DEFINITION_MAPPER_H_ + +#include "fesa/core/status.h" + +namespace fesa::abaqus_internal { + +class DomainMappingContext; + +/// @brief Resolves the approved static step, loads, and boundaries. +class StepDefinitionMapper { + public: + /// @brief Resolves source targets and finalizes the static-step candidate. + Status Map(DomainMappingContext& context) const; +}; + +} // namespace fesa::abaqus_internal + +#endif // FESA_IO_ABAQUS_STEP_DEFINITION_MAPPER_H_ diff --git a/src/fesa/io/abaqus/topology_mapper.cpp b/src/fesa/io/abaqus/topology_mapper.cpp new file mode 100644 index 0000000..5d78175 --- /dev/null +++ b/src/fesa/io/abaqus/topology_mapper.cpp @@ -0,0 +1,15 @@ +#include "io/abaqus/topology_mapper.h" + +#include "io/abaqus/domain_mapping_context.h" + +namespace fesa::abaqus_internal { + +Status TopologyMapper::Map(DomainMappingContext& context) const { + return context.ParseSyntax(); +} + +Status TopologyMapper::Finalize(DomainMappingContext& context) const { + return context.FinalizeTopology(); +} + +} // namespace fesa::abaqus_internal diff --git a/src/fesa/io/abaqus/topology_mapper.h b/src/fesa/io/abaqus/topology_mapper.h new file mode 100644 index 0000000..943628d --- /dev/null +++ b/src/fesa/io/abaqus/topology_mapper.h @@ -0,0 +1,22 @@ +#ifndef FESA_IO_ABAQUS_TOPOLOGY_MAPPER_H_ +#define FESA_IO_ABAQUS_TOPOLOGY_MAPPER_H_ + +#include "fesa/core/status.h" + +namespace fesa::abaqus_internal { + +class DomainMappingContext; + +/// @brief Maps and finalizes topology without constructing a public Domain. +class TopologyMapper { + public: + /// @brief Parses ordered syntax into shared uncommitted candidates. + Status Map(DomainMappingContext& context) const; + + /// @brief Expands identity instances and stable set mappings. + Status Finalize(DomainMappingContext& context) const; +}; + +} // namespace fesa::abaqus_internal + +#endif // FESA_IO_ABAQUS_TOPOLOGY_MAPPER_H_ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4a56e13..3ce9a0e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ add_executable( unit/math/sparse_matrix_test.cpp unit/math/vector3_test.cpp unit/math/vector_test.cpp + unit/io/abaqus/domain_mapping_components_test.cpp unit/io/abaqus/domain_mapper_test.cpp unit/io/abaqus/input_reader_test.cpp unit/io/abaqus/input_syntax_test.cpp diff --git a/tests/unit/io/abaqus/domain_mapping_components_test.cpp b/tests/unit/io/abaqus/domain_mapping_components_test.cpp new file mode 100644 index 0000000..b9ffbde --- /dev/null +++ b/tests/unit/io/abaqus/domain_mapping_components_test.cpp @@ -0,0 +1,236 @@ +#include + +#include +#include +#include +#include +#include + +#include "fesa/io/abaqus/domain_mapper.h" +#include "fesa/io/abaqus/input_reader.h" +#include "io/abaqus/domain_builder.h" +#include "io/abaqus/domain_mapping_context.h" +#include "io/abaqus/material_property_mapper.h" +#include "io/abaqus/step_definition_mapper.h" +#include "io/abaqus/topology_mapper.h" + +namespace fesa::abaqus_internal { +namespace { + +class TemporaryComponentInput { + public: + TemporaryComponentInput(const std::string& stem, const std::string& content) + : path_{std::filesystem::temp_directory_path() / + ("fesa-domain-components-" + stem + ".inp")} { + std::ofstream stream{path_, std::ios::binary | std::ios::trunc}; + stream.write(content.data(), static_cast(content.size())); + if (!stream) { + throw std::runtime_error{"Unable to create mapper component fixture."}; + } + } + + ~TemporaryComponentInput() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path& Path() const noexcept { return path_; } + + private: + std::filesystem::path path_; +}; + +ParsedInput ParseComponentInput(const std::string& stem, + const std::string& content) { + const TemporaryComponentInput input{stem, content}; + auto parsed = AbaqusInputReader{}.Read(input.Path()); + if (!parsed.HasValue()) { + throw std::runtime_error{"Unable to parse mapper component fixture."}; + } + return std::move(parsed.Value()); +} + +std::string ValidComponentDeck() { + return R"inp(*Preprint +*Part, name=BeamPart +*Node +1, 0., 0., 0. +2, 1., 0., 0. +*Element, type=B33 +1, 1, 2 +*Nset, nset=LocalRoot +1 +*Elset, elset=BeamSet +1 +*Beam General Section, elset=BeamSet, material=Steel, section=GENERAL +1., 1., 0., 1., 1. +0., 1., 0. +*End Part +*Assembly, name=Assembly +*Instance, name=Beam-1, part=BeamPart +*End Instance +*Nset, nset=Root, instance=Beam-1 +1 +*Nset, nset=Tip, instance=Beam-1 +2 +*End Assembly +*Material, name=Steel +*Elastic +100., 0.25 +*Boundary +Root, 1, 6 +*Step, name=Load, nlgeom=NO +*Static +0.1, 1., 0.01, 1. +*Boundary +Tip, 1, 1, 0.125 +*Cload +Tip, 2, -1. +*Output, field +*Node Output +U +*End Step +)inp"; +} + +std::string ReplaceOnce(std::string text, const std::string& from, + const std::string& to) { + const std::size_t position = text.find(from); + if (position == std::string::npos) { + throw std::logic_error{"Mapper component mutation source was not found."}; + } + text.replace(position, from.size(), to); + return text; +} + +void ExpectSameDiagnostic(const Diagnostic& actual, + const Diagnostic& expected) { + EXPECT_EQ(actual.severity, expected.severity); + EXPECT_EQ(actual.code, expected.code); + EXPECT_EQ(actual.location.file, expected.location.file); + EXPECT_EQ(actual.location.line, expected.location.line); + EXPECT_EQ(actual.keyword, expected.keyword); + EXPECT_EQ(actual.entity_identity, expected.entity_identity); + EXPECT_EQ(actual.message, expected.message); +} + +TEST(DomainMappingComponents, + CModule001BuildsStableCandidatesBeforeAtomicDomainCommit) { + ParsedInput input = ParseComponentInput("valid", ValidComponentDeck()); + DomainMappingContext context{input}; + TopologyMapper topology_mapper; + MaterialPropertyMapper material_property_mapper; + StepDefinitionMapper step_definition_mapper; + + ASSERT_TRUE(topology_mapper.Map(context).IsOk()); + DomainMappingCandidateSummary summary = context.CandidateSummary(); + EXPECT_EQ(summary.part_count, 1U); + EXPECT_EQ(summary.node_count, 2U); + EXPECT_EQ(summary.element_count, 1U); + EXPECT_EQ(summary.instance_count, 1U); + EXPECT_EQ(summary.node_set_count, 3U); + EXPECT_EQ(summary.element_set_count, 1U); + EXPECT_EQ(summary.material_count, 1U); + EXPECT_EQ(summary.beam_section_count, 1U); + EXPECT_EQ(summary.shell_section_count, 0U); + EXPECT_EQ(summary.model_boundary_count, 1U); + EXPECT_EQ(summary.step_boundary_count, 1U); + EXPECT_EQ(summary.step_load_count, 1U); + EXPECT_TRUE(summary.has_static_step); + + ASSERT_TRUE(material_property_mapper.Map(context).IsOk()); + ASSERT_TRUE(topology_mapper.Finalize(context).IsOk()); + ASSERT_TRUE(step_definition_mapper.Map(context).IsOk()); + auto result = DomainBuilder{}.Build(context); + ASSERT_TRUE(result.HasValue()); + + const Domain& domain = result.Value(); + EXPECT_EQ(domain.Nodes().size(), 2U); + EXPECT_EQ(domain.Elements().Size(), 1U); + EXPECT_EQ(domain.Materials().Size(), 1U); + EXPECT_EQ(domain.Properties().Size(), 1U); + ASSERT_EQ(domain.Steps().Size(), 1U); + EXPECT_EQ(domain.Steps()[0].BoundaryConditions().size(), 7U); + EXPECT_EQ(domain.Steps()[0].Loads().size(), 1U); + ASSERT_EQ(domain.Warnings().size(), 3U); + EXPECT_EQ(domain.Warnings()[0].keyword, "PREPRINT"); + EXPECT_EQ(domain.Warnings()[1].keyword, "OUTPUT"); + EXPECT_EQ(domain.Warnings()[2].keyword, "NODE OUTPUT"); +} + +TEST(DomainMappingComponents, + CModule001PreservesExactFirstFailureThroughFacade) { + ParsedInput input = ParseComponentInput( + "invalid", "*Node\n1, 0., 0., 0.\n*Material, name=Steel\n"); + DomainMappingContext context{input}; + const Status component_status = TopologyMapper{}.Map(context); + ASSERT_FALSE(component_status.IsOk()); + ASSERT_EQ(component_status.Diagnostics().size(), 1U); + + const Diagnostic& component_diagnostic = + component_status.Diagnostics().front(); + EXPECT_EQ(component_status.Category(), FailureCategory::kInput); + EXPECT_EQ(component_diagnostic.severity, Severity::kError); + EXPECT_EQ(component_diagnostic.code, "unsupported-keyword"); + EXPECT_EQ(component_diagnostic.location.line, 1U); + EXPECT_EQ(component_diagnostic.keyword, "NODE"); + EXPECT_TRUE(component_diagnostic.entity_identity.empty()); + EXPECT_EQ(component_diagnostic.message, + "The keyword is outside the approved Abaqus subset."); + + auto facade_result = AbaqusDomainMapper{}.Map(input); + ASSERT_FALSE(facade_result.HasValue()); + ASSERT_EQ(facade_result.GetStatus().Diagnostics().size(), 1U); + EXPECT_EQ(facade_result.GetStatus().Category(), component_status.Category()); + ExpectSameDiagnostic(facade_result.GetStatus().Diagnostics().front(), + component_diagnostic); +} + +TEST(DomainMappingComponents, + CModule001RejectsInvalidMaterialAtOwningComponent) { + ParsedInput input = ParseComponentInput( + "invalid-material", + ReplaceOnce(ValidComponentDeck(), "100., 0.25", "-100., 0.25")); + DomainMappingContext context{input}; + ASSERT_TRUE(TopologyMapper{}.Map(context).IsOk()); + + const Status status = MaterialPropertyMapper{}.Map(context); + ASSERT_FALSE(status.IsOk()); + ASSERT_EQ(status.Diagnostics().size(), 1U); + EXPECT_EQ(status.Category(), FailureCategory::kModel); + const Diagnostic& diagnostic = status.Diagnostics().front(); + EXPECT_EQ(diagnostic.code, "invalid-beam-property"); + EXPECT_EQ(diagnostic.location.line, 26U); + EXPECT_EQ(diagnostic.keyword, "ELASTIC"); + EXPECT_EQ(diagnostic.entity_identity, "Steel"); + EXPECT_EQ(diagnostic.message, + "E and the derived G=E/(2*(1+nu)) must be positive."); +} + +TEST(DomainMappingComponents, + CModule001RejectsUnresolvedLoadAtOwningComponent) { + ParsedInput input = ParseComponentInput( + "invalid-step", + ReplaceOnce(ValidComponentDeck(), "Tip, 2, -1.", "Missing, 2, -1.")); + DomainMappingContext context{input}; + TopologyMapper topology_mapper; + ASSERT_TRUE(topology_mapper.Map(context).IsOk()); + ASSERT_TRUE(MaterialPropertyMapper{}.Map(context).IsOk()); + ASSERT_TRUE(topology_mapper.Finalize(context).IsOk()); + + const Status status = StepDefinitionMapper{}.Map(context); + ASSERT_FALSE(status.IsOk()); + ASSERT_EQ(status.Diagnostics().size(), 1U); + EXPECT_EQ(status.Category(), FailureCategory::kInput); + const Diagnostic& diagnostic = status.Diagnostics().front(); + EXPECT_EQ(diagnostic.code, "unresolved-reference"); + EXPECT_EQ(diagnostic.location.line, 35U); + EXPECT_EQ(diagnostic.keyword, "CLOAD"); + EXPECT_EQ(diagnostic.entity_identity, "Missing"); + EXPECT_EQ(diagnostic.message, + "The boundary or load target must resolve unambiguously to one " + "node or one node set."); +} + +} // namespace +} // namespace fesa::abaqus_internal