From a1047513fdbe3d6d6681e132047cbd4fb565ac33 Mon Sep 17 00:00:00 2001 From: dbv Date: Mon, 20 Jun 2016 15:08:14 +0300 Subject: [PATCH] Issue #1366: Added Union feature --- src/FeaturesPlugin/CMakeLists.txt | 3 + src/FeaturesPlugin/FeaturesPlugin_Boolean.cpp | 4 +- .../FeaturesPlugin_Partition.cpp | 2 +- src/FeaturesPlugin/FeaturesPlugin_Plugin.cpp | 7 + src/FeaturesPlugin/FeaturesPlugin_Union.cpp | 160 ++++++++++++++++++ src/FeaturesPlugin/FeaturesPlugin_Union.h | 55 ++++++ .../FeaturesPlugin_Validators.cpp | 76 +++++++++ .../FeaturesPlugin_Validators.h | 34 ++++ src/FeaturesPlugin/Test/TestUnion.py | 75 ++++++++ src/FeaturesPlugin/icons/union.png | Bin 0 -> 798 bytes src/FeaturesPlugin/plugin-Features.xml | 9 +- src/FeaturesPlugin/union_widget.xml | 13 ++ 12 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 src/FeaturesPlugin/FeaturesPlugin_Union.cpp create mode 100644 src/FeaturesPlugin/FeaturesPlugin_Union.h create mode 100644 src/FeaturesPlugin/Test/TestUnion.py create mode 100644 src/FeaturesPlugin/icons/union.png create mode 100644 src/FeaturesPlugin/union_widget.xml diff --git a/src/FeaturesPlugin/CMakeLists.txt b/src/FeaturesPlugin/CMakeLists.txt index edd823596..2ffaf860d 100644 --- a/src/FeaturesPlugin/CMakeLists.txt +++ b/src/FeaturesPlugin/CMakeLists.txt @@ -24,6 +24,7 @@ SET(PROJECT_HEADERS FeaturesPlugin_RevolutionBoolean.h FeaturesPlugin_RevolutionCut.h FeaturesPlugin_RevolutionFuse.h + FeaturesPlugin_Union.h FeaturesPlugin_ValidatorTransform.h FeaturesPlugin_Validators.h FeaturesPlugin_RemoveSubShapes.h @@ -49,6 +50,7 @@ SET(PROJECT_SOURCES FeaturesPlugin_RevolutionBoolean.cpp FeaturesPlugin_RevolutionCut.cpp FeaturesPlugin_RevolutionFuse.cpp + FeaturesPlugin_Union.cpp FeaturesPlugin_ValidatorTransform.cpp FeaturesPlugin_Validators.cpp FeaturesPlugin_RemoveSubShapes.cpp @@ -71,6 +73,7 @@ SET(XML_RESOURCES intersection_widget.xml pipe_widget.xml remove_subshapes_widget.xml + union_widget.xml ) SET(TEXT_RESOURCES diff --git a/src/FeaturesPlugin/FeaturesPlugin_Boolean.cpp b/src/FeaturesPlugin/FeaturesPlugin_Boolean.cpp index 065e065fc..69915b1b3 100644 --- a/src/FeaturesPlugin/FeaturesPlugin_Boolean.cpp +++ b/src/FeaturesPlugin/FeaturesPlugin_Boolean.cpp @@ -143,7 +143,7 @@ void FeaturesPlugin_Boolean::execute() std::shared_ptr anObject = *anObjectsIt; ListOfShape aListWithObject; aListWithObject.push_back(anObject); - GeomAlgoAPI_MakeShape aBoolAlgo; (aListWithObject, aTools, (GeomAlgoAPI_Boolean::OperationType)aType); + GeomAlgoAPI_MakeShape aBoolAlgo; switch(aType) { case BOOL_CUT: aBoolAlgo = GeomAlgoAPI_Boolean(aListWithObject, aTools, GeomAlgoAPI_Boolean::BOOL_CUT); break; @@ -346,7 +346,7 @@ void FeaturesPlugin_Boolean::execute() } else if((anObjects.size() + aTools.size()) > 1){ std::shared_ptr aFuseAlgo(new GeomAlgoAPI_Boolean(anObjects, aTools, - (GeomAlgoAPI_Boolean::OperationType)aType)); + GeomAlgoAPI_Boolean::BOOL_FUSE)); // Checking that the algorithm worked properly. if(!aFuseAlgo->isDone()) { diff --git a/src/FeaturesPlugin/FeaturesPlugin_Partition.cpp b/src/FeaturesPlugin/FeaturesPlugin_Partition.cpp index a105bd1db..df2ba89ea 100755 --- a/src/FeaturesPlugin/FeaturesPlugin_Partition.cpp +++ b/src/FeaturesPlugin/FeaturesPlugin_Partition.cpp @@ -143,7 +143,7 @@ void FeaturesPlugin_Partition::storeResult(const ListOfShape& theObjects, ResultBodyPtr aResultBody = document()->createBody(data(), theIndex); // Store modified shape. - if(aBaseShape->isEqual(theResultShape)) { + if(!aBaseShape.get() || aBaseShape->isEqual(theResultShape)) { aResultBody->store(theResultShape); setResult(aResultBody, theIndex); return; diff --git a/src/FeaturesPlugin/FeaturesPlugin_Plugin.cpp b/src/FeaturesPlugin/FeaturesPlugin_Plugin.cpp index d42ede130..de245dfd6 100644 --- a/src/FeaturesPlugin/FeaturesPlugin_Plugin.cpp +++ b/src/FeaturesPlugin/FeaturesPlugin_Plugin.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -55,6 +56,10 @@ FeaturesPlugin_Plugin::FeaturesPlugin_Plugin() new FeaturesPlugin_ValidatorRemoveSubShapesResult); aFactory->registerValidator("FeaturesPlugin_ValidatorPipePath", new FeaturesPlugin_ValidatorPipePath); + aFactory->registerValidator("FeaturesPlugin_ValidatorUnionSelection", + new FeaturesPlugin_ValidatorUnionSelection); + aFactory->registerValidator("FeaturesPlugin_ValidatorUnionArguments", + new FeaturesPlugin_ValidatorUnionArguments); // register this plugin ModelAPI_Session::get()->registerPlugin(this); @@ -92,6 +97,8 @@ FeaturePtr FeaturesPlugin_Plugin::createFeature(string theFeatureID) return FeaturePtr(new FeaturesPlugin_RevolutionFuse); } else if (theFeatureID == FeaturesPlugin_RemoveSubShapes::ID()) { return FeaturePtr(new FeaturesPlugin_RemoveSubShapes); + } else if (theFeatureID == FeaturesPlugin_Union::ID()) { + return FeaturePtr(new FeaturesPlugin_Union); } // feature of such kind is not found diff --git a/src/FeaturesPlugin/FeaturesPlugin_Union.cpp b/src/FeaturesPlugin/FeaturesPlugin_Union.cpp new file mode 100644 index 000000000..1d97c92aa --- /dev/null +++ b/src/FeaturesPlugin/FeaturesPlugin_Union.cpp @@ -0,0 +1,160 @@ +// Copyright (C) 2014-20xx CEA/DEN, EDF R&D --> + +// File: FeaturesPlugin_Union.cpp +// Created: 17 June 2016 +// Author: Dmitry Bobylev + +#include "FeaturesPlugin_Union.h" + +#include +#include +#include + +#include + +#include +#include +#include + +//================================================================================================= +FeaturesPlugin_Union::FeaturesPlugin_Union() +{ +} + +//================================================================================================= +void FeaturesPlugin_Union::initAttributes() +{ + data()->addAttribute(BASE_OBJECTS_ID(), ModelAPI_AttributeSelectionList::typeId()); +} + +//================================================================================================= +void FeaturesPlugin_Union::execute() +{ + ListOfShape anObjects; + std::map, ListOfShape> aCompSolidsObjects; + + // Getting objects. + AttributeSelectionListPtr anObjectsSelList = selectionList(FeaturesPlugin_Union::BASE_OBJECTS_ID()); + for(int anObjectsIndex = 0; anObjectsIndex < anObjectsSelList->size(); anObjectsIndex++) { + AttributeSelectionPtr anObjectAttr = anObjectsSelList->value(anObjectsIndex); + std::shared_ptr anObject = anObjectAttr->value(); + if(!anObject.get()) { + return; + } + ResultPtr aContext = anObjectAttr->context(); + ResultCompSolidPtr aResCompSolidPtr = ModelAPI_Tools::compSolidOwner(aContext); + if(aResCompSolidPtr.get()) { + std::shared_ptr aContextShape = aResCompSolidPtr->shape(); + std::map, ListOfShape>::iterator anIt = aCompSolidsObjects.begin(); + for(; anIt != aCompSolidsObjects.end(); anIt++) { + if(anIt->first->isEqual(aContextShape)) { + aCompSolidsObjects[anIt->first].push_back(anObject); + break; + } + } + if(anIt == aCompSolidsObjects.end()) { + aCompSolidsObjects[aContextShape].push_back(anObject); + } + } else { + anObjects.push_back(anObject); + } + } + + // Collecting solids from compsolids which will not be modified in boolean operation and will be added to result. + ListOfShape aShapesToAdd; + for(std::map, ListOfShape>::iterator anIt = aCompSolidsObjects.begin(); + anIt != aCompSolidsObjects.end(); anIt++) { + std::shared_ptr aCompSolid = anIt->first; + ListOfShape& aUsedInOperationSolids = anIt->second; + anObjects.insert(anObjects.end(), aUsedInOperationSolids.begin(), aUsedInOperationSolids.end()); + + // Collect solids from compsolid which will not be modified in boolean operation. + for(GeomAPI_ShapeExplorer anExp(aCompSolid, GeomAPI_Shape::SOLID); anExp.more(); anExp.next()) { + std::shared_ptr aSolidInCompSolid = anExp.current(); + ListOfShape::iterator anIt = aUsedInOperationSolids.begin(); + for(; anIt != aUsedInOperationSolids.end(); anIt++) { + if(aSolidInCompSolid->isEqual(*anIt)) { + break; + } + } + if(anIt == aUsedInOperationSolids.end()) { + aShapesToAdd.push_back(aSolidInCompSolid); + } + } + } + + if(anObjects.size() < 2) { + setError("Error: Not enough objects for operation. Should be at least 2."); + return; + } + + // Fuse objects. + ListOfShape aTools; + aTools.splice(aTools.begin(), anObjects, anObjects.begin()); + std::shared_ptr aFuseAlgo(new GeomAlgoAPI_Boolean(anObjects, + aTools, + GeomAlgoAPI_Boolean::BOOL_FUSE)); + + // Checking that the algorithm worked properly. + GeomAlgoAPI_MakeShapeList aMakeShapeList; + GeomAPI_DataMapOfShapeShape aMapOfShapes; + if(!aFuseAlgo->isDone()) { + setError("Error: Boolean algorithm failed."); + return; + } + if(aFuseAlgo->shape()->isNull()) { + setError("Error: Resulting shape is Null."); + return; + } + if(!aFuseAlgo->isValid()) { + setError("Error: Resulting shape is not valid."); + return; + } + + GeomShapePtr aShape = aFuseAlgo->shape(); + aMakeShapeList.appendAlgo(aFuseAlgo); + aMapOfShapes.merge(aFuseAlgo->mapOfSubShapes()); + + // Store original shapes for naming. + anObjects.splice(anObjects.begin(), aTools); + anObjects.insert(anObjects.end(), aShapesToAdd.begin(), aShapesToAdd.end()); + + // Combine result with not used solids from compsolid. + if(aShapesToAdd.size() > 0) { + aShapesToAdd.push_back(aShape); + std::shared_ptr aFillerAlgo(new GeomAlgoAPI_PaveFiller(aShapesToAdd, true)); + if(!aFillerAlgo->isDone()) { + setError("Error: PaveFiller algorithm failed."); + return; + } + if(aFillerAlgo->shape()->isNull()) { + setError("Error: Resulting shape is Null."); + return; + } + if(!aFillerAlgo->isValid()) { + setError("Error: Resulting shape is not valid."); + return; + } + + aShape = aFillerAlgo->shape(); + aMakeShapeList.appendAlgo(aFillerAlgo); + aMapOfShapes.merge(aFillerAlgo->mapOfSubShapes()); + } + + // Store result and naming. + const int aModifyTag = 1; + const int aDeletedTag = 2; + const int aSubsolidsTag = 3; /// sub solids will be placed at labels 3, 4, etc. if result is compound of solids + const std::string aModName = "Modified"; + + std::shared_ptr aResultBody = document()->createBody(data()); + aResultBody->storeModified(anObjects.front(), aShape, aSubsolidsTag); + + for(ListOfShape::const_iterator anIter = anObjects.begin(); anIter != anObjects.end(); ++anIter) { + aResultBody->loadAndOrientModifiedShapes(&aMakeShapeList, *anIter, GeomAPI_Shape::FACE, + aModifyTag, aModName, aMapOfShapes); + aResultBody->loadDeletedShapes(&aMakeShapeList, *anIter, GeomAPI_Shape::FACE, aDeletedTag); + } + + setResult(aResultBody); +} diff --git a/src/FeaturesPlugin/FeaturesPlugin_Union.h b/src/FeaturesPlugin/FeaturesPlugin_Union.h new file mode 100644 index 000000000..4d993f0b7 --- /dev/null +++ b/src/FeaturesPlugin/FeaturesPlugin_Union.h @@ -0,0 +1,55 @@ +// Copyright (C) 2014-20xx CEA/DEN, EDF R&D --> + +// File: FeaturesPlugin_Union.h +// Created: 17 June 2016 +// Author: Dmitry Bobylev + +#ifndef FeaturesPlugin_Union_H_ +#define FeaturesPlugin_Union_H_ + +#include "FeaturesPlugin.h" +#include + +#include + +class GeomAlgoAPI_MakeShape; + +/// \class FeaturesPlugin_Union +/// \ingroup Plugins +/// \brief Feature for applying of Union operations on Shapes. Union removes shared shapes from +/// several shapes and combines them into one. +class FeaturesPlugin_Union : public ModelAPI_Feature +{ +public: + /// Feature kind. + inline static const std::string& ID() + { + static const std::string MY_ID("Union"); + return MY_ID; + } + + /// Attribute name of base objects. + inline static const std::string& BASE_OBJECTS_ID() + { + static const std::string MY_BASE_OBJECTS_ID("base_objects"); + return MY_BASE_OBJECTS_ID; + } + + /// \return the kind of a feature. + FEATURESPLUGIN_EXPORT virtual const std::string& getKind() + { + static std::string MY_KIND = FeaturesPlugin_Union::ID(); + return MY_KIND; + } + + /// Creates a new part document if needed + FEATURESPLUGIN_EXPORT virtual void execute(); + + /// Request for initialization of data model of the feature: adding all attributes + FEATURESPLUGIN_EXPORT virtual void initAttributes(); + + /// Use plugin manager for features creation + FeaturesPlugin_Union(); +}; + +#endif diff --git a/src/FeaturesPlugin/FeaturesPlugin_Validators.cpp b/src/FeaturesPlugin/FeaturesPlugin_Validators.cpp index eb2017787..96997a0e0 100644 --- a/src/FeaturesPlugin/FeaturesPlugin_Validators.cpp +++ b/src/FeaturesPlugin/FeaturesPlugin_Validators.cpp @@ -6,6 +6,8 @@ #include "FeaturesPlugin_Validators.h" +#include "FeaturesPlugin_Union.h" + #include #include #include @@ -24,6 +26,7 @@ #include #include +#include #include #include #include @@ -642,3 +645,76 @@ bool FeaturesPlugin_ValidatorRemoveSubShapesResult::isNotObligatory(std::string { return false; } + +//================================================================================================== +bool FeaturesPlugin_ValidatorUnionSelection::isValid(const AttributePtr& theAttribute, + const std::list& theArguments, + std::string& theError) const +{ + AttributeSelectionListPtr aBaseObjectsAttrList = std::dynamic_pointer_cast(theAttribute); + if(!aBaseObjectsAttrList.get()) { + theError = "Error: This validator can only work with selection list in \"" + FeaturesPlugin_Union::ID() + "\" feature."; + return false; + } + + for(int anIndex = 0; anIndex < aBaseObjectsAttrList->size(); ++anIndex) { + bool isSameFound = false; + AttributeSelectionPtr anAttrSelectionInList = aBaseObjectsAttrList->value(anIndex); + ResultCompSolidPtr aResult = std::dynamic_pointer_cast(anAttrSelectionInList->context()); + if(!aResult.get()) { + continue; + } + if(aResult->numberOfSubs() > 0) { + theError = "Error: Whole compsolids not allowed for selection."; + return false; + } + } + + return true; +} + +//================================================================================================== +bool FeaturesPlugin_ValidatorUnionArguments::isValid(const std::shared_ptr& theFeature, + const std::list& theArguments, + std::string& theError) const +{ + // Check feature kind. + if(theFeature->getKind() != FeaturesPlugin_Union::ID()) { + theError = "Error: This validator supports only \"" + FeaturesPlugin_Union::ID() + "\" feature."; + return false; + } + + // Get base objects attribute list. + AttributeSelectionListPtr aBaseObejctsAttrList = theFeature->selectionList(FeaturesPlugin_Union::BASE_OBJECTS_ID()); + if(!aBaseObejctsAttrList.get()) { + theError = "Error: Could not get \"" + FeaturesPlugin_Union::BASE_OBJECTS_ID() + "\" attribute."; + return false; + } + + // Get all shapes. + ListOfShape aBaseShapesList; + for(int anIndex = 0; anIndex < aBaseObejctsAttrList->size(); ++anIndex) { + AttributeSelectionPtr anAttrSelectionInList = aBaseObejctsAttrList->value(anIndex); + GeomShapePtr aShape = anAttrSelectionInList->value(); + aBaseShapesList.push_back(aShape); + } + + // Make componud and find connected. + GeomShapePtr aCompound = GeomAlgoAPI_CompoundBuilder::compound(aBaseShapesList); + ListOfShape aCombined, aFree; + GeomAlgoAPI_ShapeTools::combineShapes(aCompound, GeomAPI_Shape::COMPSOLID, aCombined, aFree); + + if(aFree.size() > 0 || aCombined.size() > 1) { + theError = "Error: Not all shapes have shared topology."; + return false; + } + + return true; +} + +//================================================================================================== +bool FeaturesPlugin_ValidatorUnionArguments::isNotObligatory(std::string theFeature, + std::string theAttribute) +{ + return false; +} diff --git a/src/FeaturesPlugin/FeaturesPlugin_Validators.h b/src/FeaturesPlugin/FeaturesPlugin_Validators.h index da6180e58..1ca14c03a 100644 --- a/src/FeaturesPlugin/FeaturesPlugin_Validators.h +++ b/src/FeaturesPlugin/FeaturesPlugin_Validators.h @@ -168,4 +168,38 @@ class FeaturesPlugin_ValidatorRemoveSubShapesResult: public ModelAPI_FeatureVali virtual bool isNotObligatory(std::string theFeature, std::string theAttribute); }; +/// \class FeaturesPlugin_ValidatorUnionSelection +/// \ingroup Validators +/// \brief Validates selection for "Union" feature. +class FeaturesPlugin_ValidatorUnionSelection: public ModelAPI_AttributeValidator +{ +public: + /// \return True if the attribute is valid. It checks whether the selection + /// is acceptable for operation. + /// \param[in] theAttribute an attribute to check. + /// \param[in] theArguments a filter parameters. + /// \param[out] theError error message. + virtual bool isValid(const AttributePtr& theAttribute, + const std::list& theArguments, + std::string& theError) const; +}; + +/// \class FeaturesPlugin_ValidatorUnionArguments +/// \ingroup Validators +/// \brief Validator for the "Union" feature. +class FeaturesPlugin_ValidatorUnionArguments: public ModelAPI_FeatureValidator +{ + public: + //! \return true if result is valid shape. + //! \param theFeature the checked feature + //! \param theArguments arguments of the feature (not used) + //! \param theError error message + virtual bool isValid(const std::shared_ptr& theFeature, + const std::list& theArguments, + std::string& theError) const; + + /// \return true if the attribute in feature is not obligatory for the feature execution + virtual bool isNotObligatory(std::string theFeature, std::string theAttribute); +}; + #endif diff --git a/src/FeaturesPlugin/Test/TestUnion.py b/src/FeaturesPlugin/Test/TestUnion.py new file mode 100644 index 000000000..562645c42 --- /dev/null +++ b/src/FeaturesPlugin/Test/TestUnion.py @@ -0,0 +1,75 @@ +#========================================================================= +# Initialization of the test +#========================================================================= +from ModelAPI import * +from GeomDataAPI import * +from GeomAlgoAPI import * +from GeomAPI import * +import math + +aSession = ModelAPI_Session.get() +aDocument = aSession.moduleDocument() + +# Create a part for extrusion +aSession.startOperation() +aPartFeature = aDocument.addFeature("Part") +aSession.finishOperation() +assert (len(aPartFeature.results()) == 1) + +aPartResult = modelAPI_ResultPart(aPartFeature.firstResult()) +aPart = aPartResult.partDoc() + +#========================================================================= +# Create a sketch to extrude +#========================================================================= +aSession.startOperation() +aSketchFeature = featureToCompositeFeature(aPart.addFeature("Sketch")) +origin = geomDataAPI_Point(aSketchFeature.attribute("Origin")) +origin.setValue(0, 0, 0) +dirx = geomDataAPI_Dir(aSketchFeature.attribute("DirX")) +dirx.setValue(1, 0, 0) +norm = geomDataAPI_Dir(aSketchFeature.attribute("Norm")) +norm.setValue(0, 0, 1) + +# Create circles +aSketchCircle = aSketchFeature.addFeature("SketchCircle") +anCircleCentr = geomDataAPI_Point2D(aSketchCircle.attribute("CircleCenter")) +aCircleRadius = aSketchCircle.real("CircleRadius") +anCircleCentr.setValue(-25, 0) +aCircleRadius.setValue(50) +aSketchCircle = aSketchFeature.addFeature("SketchCircle") +anCircleCentr = geomDataAPI_Point2D(aSketchCircle.attribute("CircleCenter")) +aCircleRadius = aSketchCircle.real("CircleRadius") +anCircleCentr.setValue(25, 0) +aCircleRadius.setValue(50) +aSession.finishOperation() +aSketchResult = aSketchFeature.firstResult() + +#========================================================================= +# Make extrusion on sketch +#========================================================================= +# Create extrusion +aSession.startOperation() +anExtrusionFeature = aPart.addFeature("Extrusion") +anExtrusionFeature.selectionList("base").append(aSketchResult, None) +anExtrusionFeature.string("CreationMethod").setValue("BySizes") +anExtrusionFeature.real("to_size").setValue(50) +anExtrusionFeature.real("from_size").setValue(0) +anExtrusionFeature.real("to_offset").setValue(0) #TODO: remove +anExtrusionFeature.real("from_offset").setValue(0) #TODO: remove +anExtrusionFeature.execute() +aSession.finishOperation() +anExtrusionResult = modelAPI_ResultCompSolid(modelAPI_ResultBody(anExtrusionFeature.firstResult())) + +#========================================================================= +# Make union on extrusion +#========================================================================= +aSession.startOperation() +aUnionFeature = aPart.addFeature("Union") +aUnionFeature.selectionList("base_objects").append(anExtrusionResult.subResult(0), None); +aUnionFeature.selectionList("base_objects").append(anExtrusionResult.subResult(1), None); +aUnionFeature.selectionList("base_objects").append(anExtrusionResult.subResult(2), None); +aSession.finishOperation() +assert (len(aUnionFeature.results()) > 0) +anUnionResult = modelAPI_ResultCompSolid(modelAPI_ResultBody(aUnionFeature.firstResult())) +assert (anUnionResult.numberOfSubs() == 0) diff --git a/src/FeaturesPlugin/icons/union.png b/src/FeaturesPlugin/icons/union.png new file mode 100644 index 0000000000000000000000000000000000000000..268512c9ba97953bd642e01edaccf3bcf81b633f GIT binary patch literal 798 zcmV+(1L6FMP)qU+=$Iv@9=O)+tui9s z#QyIyyJw|xkKD?wYxIn;iQ~F~SUc%OzNkGX8(w{ix&jeehL(Nu{P^u^JKT78zFjbI zFapht9!Kfgrk34RD{w*^r8K9@%c$OP5mReK(XnBfIlV`CmSr(5bw`frcE=k0wIh=! zCIE0qU^&ICA6deK*fHEN%IWel0A!P-!r5~p%ZrvovPYK~$18fOb;I`()gPLpsvz#GbArUk3 zOF4g|%h* zc)BeEQbh=S^19~Ah#k$`e06C2($hTiXyMJAxb2z%S{PJDcOs?6j(`aeYQbH#m9ea1 zptFdTi}sru(`>R-b$=#@XI~NTbUnUkkHTVcNdXn>F1LiJFsV#Cgw!s5ofQaFb0(!# z + + + + + + - - - + + + + + + + + -- 2.39.2