From 3de35c98186ca68b2079fb9ba0016a4d534ddbd3 Mon Sep 17 00:00:00 2001 From: David Carter <38090157+davesrocketshop@users.noreply.github.com> Date: Mon, 6 May 2024 16:34:51 +0000 Subject: [PATCH] Material: Expanded Python API (#13829) Expands the Python API to allow for material creation. With test cases. --- src/Mod/Material/App/MaterialManagerPy.xml | 5 + .../Material/App/MaterialManagerPyImpl.cpp | 67 +++++++- src/Mod/Material/App/MaterialPy.xml | 44 +++++- src/Mod/Material/App/MaterialPyImpl.cpp | 145 +++++++++++++----- src/Mod/Material/App/ModelLibrary.cpp | 4 + src/Mod/Material/CMakeLists.txt | 1 + src/Mod/Material/TestMaterialsApp.py | 1 + .../materialtests/TestMaterialCreation.py | 137 +++++++++++++++++ 8 files changed, 358 insertions(+), 46 deletions(-) create mode 100644 src/Mod/Material/materialtests/TestMaterialCreation.py diff --git a/src/Mod/Material/App/MaterialManagerPy.xml b/src/Mod/Material/App/MaterialManagerPy.xml index af9579ee75..4ad5991e22 100644 --- a/src/Mod/Material/App/MaterialManagerPy.xml +++ b/src/Mod/Material/App/MaterialManagerPy.xml @@ -47,5 +47,10 @@ Get a list of materials implementing the specified model, with values for all properties + + + Save the material in the specified library + + diff --git a/src/Mod/Material/App/MaterialManagerPyImpl.cpp b/src/Mod/Material/App/MaterialManagerPyImpl.cpp index e5af5059e4..bdc9113fc5 100644 --- a/src/Mod/Material/App/MaterialManagerPyImpl.cpp +++ b/src/Mod/Material/App/MaterialManagerPyImpl.cpp @@ -84,7 +84,8 @@ PyObject* MaterialManagerPy::getMaterialByPath(PyObject* args) if (!libPath.isEmpty()) { try { auto material = - getMaterialManagerPtr()->getMaterialByPath(QString::fromUtf8(utf8Path.c_str()), libPath); + getMaterialManagerPtr()->getMaterialByPath(QString::fromUtf8(utf8Path.c_str()), + libPath); return new MaterialPy(new Material(*material)); } catch (const MaterialNotFound&) { @@ -98,7 +99,8 @@ PyObject* MaterialManagerPy::getMaterialByPath(PyObject* args) } try { - auto material = getMaterialManagerPtr()->getMaterialByPath(QString::fromUtf8(utf8Path.c_str())); + auto material = + getMaterialManagerPtr()->getMaterialByPath(QString::fromUtf8(utf8Path.c_str())); return new MaterialPy(new Material(*material)); } catch (const MaterialNotFound&) { @@ -195,3 +197,64 @@ PyObject* MaterialManagerPy::materialsWithModelComplete(PyObject* args) return dict; } + +PyObject* MaterialManagerPy::save(PyObject* args, PyObject* kwds) +{ + char* libraryName {}; + PyObject* obj {}; + char* path {}; + PyObject* overwrite = Py_False; + PyObject* saveAsCopy = Py_False; + PyObject* saveInherited = Py_False; + static char* kwds_save[] = + {"library", "material", "path", "overwrite", "saveAsCopy", "saveInherited", nullptr}; + if (!PyArg_ParseTupleAndKeywords(args, + kwds, + "etOet|O!O!O!", + kwds_save, + "utf-8", &libraryName, + &obj, + "utf-8", &path, + &PyBool_Type, &overwrite, + &PyBool_Type, &saveAsCopy, + &PyBool_Type, &saveInherited)) { + return nullptr; + } + + Base::Console().Log("library name %s\n", libraryName); + Base::Console().Log("path %s\n", path); + + MaterialPy* material; + if (QLatin1String(obj->ob_type->tp_name) == QLatin1String("Materials.Material")) { + material = static_cast(obj); + } + else { + PyErr_Format(PyExc_TypeError, "Material expected not '%s'", obj->ob_type->tp_name); + return nullptr; + } + if (!material) { + PyErr_SetString(PyExc_TypeError, "Invalid material object"); + return nullptr; + } + auto sharedMaterial = std::make_shared(*(material->getMaterialPtr())); + + std::shared_ptr library; + try { + library = getMaterialManagerPtr()->getLibrary(QString::fromUtf8(libraryName)); + } + catch (const LibraryNotFound&) { + PyErr_SetString(PyExc_LookupError, "Unknown library"); + return nullptr; + } + + + getMaterialManagerPtr()->saveMaterial(library, + sharedMaterial, + QString::fromUtf8(path), + PyObject_IsTrue(overwrite), + PyObject_IsTrue(saveAsCopy), + PyObject_IsTrue(saveInherited)); + material->getMaterialPtr()->setUUID(sharedMaterial->getUUID()); // Make sure they match + + return Py_None; +} diff --git a/src/Mod/Material/App/MaterialPy.xml b/src/Mod/Material/App/MaterialPy.xml index 8d3d7f14d5..edd21c5890 100644 --- a/src/Mod/Material/App/MaterialPy.xml +++ b/src/Mod/Material/App/MaterialPy.xml @@ -33,7 +33,7 @@ - + Model name. @@ -47,23 +47,23 @@ - Unique model identifier. + Unique model identifier. This is only valid after the material is saved. - + Description of the material. - + URL to a material reference. - + Reference for material data. @@ -81,13 +81,13 @@ - + Author information. - + License information. @@ -111,11 +111,31 @@ + + + Add the physical model with the given UUID + + + + + Remove the physical model with the given UUID + + Check if the material implements the physical model with the given UUID + + + Add the appearance model with the given UUID + + + + + Remove the appearance model with the given UUID + + Check if the material implements the appearance model with the given UUID @@ -175,10 +195,20 @@ Get the value associated with the property + + + Set the value associated with the property + + Get the value associated with the property + + + Set the value associated with the property + + diff --git a/src/Mod/Material/App/MaterialPyImpl.cpp b/src/Mod/Material/App/MaterialPyImpl.cpp index bf959bb72d..3e4c72c079 100644 --- a/src/Mod/Material/App/MaterialPyImpl.cpp +++ b/src/Mod/Material/App/MaterialPyImpl.cpp @@ -48,43 +48,8 @@ static Py::List getList(const QVariant& value); // returns a string which represents the object e.g. when printed in python std::string MaterialPy::representation() const { - MaterialPy::PointerType ptr = getMaterialPtr(); - std::stringstream str; - str << "Property [Name=("; - str << ptr->getName().toStdString(); - str << "), UUID=("; - str << ptr->getUUID().toStdString(); - auto library = ptr->getLibrary(); - if (library) { - str << "), Library Name=("; - str << ptr->getLibrary()->getName().toStdString(); - str << "), Library Root=("; - str << ptr->getLibrary()->getDirectoryPath().toStdString(); - str << "), Library Icon=("; - str << ptr->getLibrary()->getIconPath().toStdString(); - } - str << "), Directory=("; - str << ptr->getDirectory().toStdString(); - // str << "), URL=("; - // str << ptr->getURL(); - // str << "), DOI=("; - // str << ptr->getDOI(); - // str << "), Description=("; - // str << ptr->getDescription(); - // str << "), Inherits=["; - // const std::vector &inherited = getMaterialPtr()->getInheritance(); - // for (auto it = inherited.begin(); it != inherited.end(); it++) - // { - // std::string uuid = *it; - // if (it != inherited.begin()) - // str << "), UUID=("; - // else - // str << "UUID=("; - // str << uuid << ")"; - // } - // str << "]]"; - str << ")]"; - + std::ostringstream str; + str << ""; return str.str(); } @@ -123,6 +88,11 @@ Py::String MaterialPy::getName() const return {getMaterialPtr()->getName().toStdString()}; } +void MaterialPy::setName(Py::String arg) +{ + getMaterialPtr()->setName(QString::fromStdString(arg)); +} + Py::String MaterialPy::getDirectory() const { return {getMaterialPtr()->getDirectory().toStdString()}; @@ -138,16 +108,31 @@ Py::String MaterialPy::getDescription() const return {getMaterialPtr()->getDescription().toStdString()}; } +void MaterialPy::setDescription(Py::String arg) +{ + getMaterialPtr()->setDescription(QString::fromStdString(arg)); +} + Py::String MaterialPy::getURL() const { return {getMaterialPtr()->getURL().toStdString()}; } +void MaterialPy::setURL(Py::String arg) +{ + getMaterialPtr()->setURL(QString::fromStdString(arg)); +} + Py::String MaterialPy::getReference() const { return {getMaterialPtr()->getReference().toStdString()}; } +void MaterialPy::setReference(Py::String arg) +{ + getMaterialPtr()->setReference(QString::fromStdString(arg)); +} + Py::String MaterialPy::getParent() const { return {getMaterialPtr()->getParentUUID().toStdString()}; @@ -163,11 +148,21 @@ Py::String MaterialPy::getAuthor() const return {getMaterialPtr()->getAuthor().toStdString()}; } +void MaterialPy::setAuthor(Py::String arg) +{ + getMaterialPtr()->setAuthor(QString::fromStdString(arg)); +} + Py::String MaterialPy::getLicense() const { return {getMaterialPtr()->getLicense().toStdString()}; } +void MaterialPy::setLicense(Py::String arg) +{ + getMaterialPtr()->setLicense(QString::fromStdString(arg)); +} + Py::List MaterialPy::getPhysicalModels() const { auto models = getMaterialPtr()->getPhysicalModels(); @@ -214,6 +209,30 @@ int MaterialPy::setCustomAttributes(const char* /*attr*/, PyObject* /*obj*/) return 0; } +PyObject* MaterialPy::addPhysicalModel(PyObject* args) +{ + char* uuid; + if (!PyArg_ParseTuple(args, "s", &uuid)) { + return nullptr; + } + + getMaterialPtr()->addPhysical(QString::fromStdString(uuid)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject* MaterialPy::removePhysicalModel(PyObject* args) +{ + char* uuid; + if (!PyArg_ParseTuple(args, "s", &uuid)) { + return nullptr; + } + + getMaterialPtr()->removePhysical(QString::fromStdString(uuid)); + Py_INCREF(Py_None); + return Py_None; +} + PyObject* MaterialPy::hasPhysicalModel(PyObject* args) { char* uuid; @@ -225,6 +244,30 @@ PyObject* MaterialPy::hasPhysicalModel(PyObject* args) return PyBool_FromLong(hasProperty ? 1 : 0); } +PyObject* MaterialPy::addAppearanceModel(PyObject* args) +{ + char* uuid; + if (!PyArg_ParseTuple(args, "s", &uuid)) { + return nullptr; + } + + getMaterialPtr()->addAppearance(QString::fromStdString(uuid)); + Py_INCREF(Py_None); + return Py_None; +} + +PyObject* MaterialPy::removeAppearanceModel(PyObject* args) +{ + char* uuid; + if (!PyArg_ParseTuple(args, "s", &uuid)) { + return nullptr; + } + + getMaterialPtr()->removeAppearance(QString::fromStdString(uuid)); + Py_INCREF(Py_None); + return Py_None; +} + PyObject* MaterialPy::hasAppearanceModel(PyObject* args) { char* uuid; @@ -472,6 +515,20 @@ PyObject* MaterialPy::getPhysicalValue(PyObject* args) return _pyObjectFromVariant(value); } +PyObject* MaterialPy::setPhysicalValue(PyObject* args) +{ + char* name; + char* value; + if (!PyArg_ParseTuple(args, "ss", &name, &value)) { + return nullptr; + } + + getMaterialPtr()->setPhysicalValue(QString::fromStdString(name), + QString::fromStdString(value)); + Py_INCREF(Py_None); + return Py_None; +} + PyObject* MaterialPy::getAppearanceValue(PyObject* args) { char* name; @@ -482,3 +539,17 @@ PyObject* MaterialPy::getAppearanceValue(PyObject* args) QVariant value = getMaterialPtr()->getAppearanceValue(QString::fromStdString(name)); return _pyObjectFromVariant(value); } + +PyObject* MaterialPy::setAppearanceValue(PyObject* args) +{ + char* name; + char* value; + if (!PyArg_ParseTuple(args, "ss", &name, &value)) { + return nullptr; + } + + getMaterialPtr()->setAppearanceValue(QString::fromStdString(name), + QString::fromStdString(value)); + Py_INCREF(Py_None); + return Py_None; +} diff --git a/src/Mod/Material/App/ModelLibrary.cpp b/src/Mod/Material/App/ModelLibrary.cpp index 40df9b3366..115a04846b 100644 --- a/src/Mod/Material/App/ModelLibrary.cpp +++ b/src/Mod/Material/App/ModelLibrary.cpp @@ -50,6 +50,10 @@ bool LibraryBase::operator==(const LibraryBase& library) const QString LibraryBase::getLocalPath(const QString& path) const { QString filePath = getDirectoryPath(); + if (!(filePath.endsWith(QLatin1String("/")) || filePath.endsWith(QLatin1String("\\")))) { + filePath += QLatin1String("/"); + } + QString cleanPath = QDir::cleanPath(path); QString prefix = QString::fromStdString("/") + getName(); if (cleanPath.startsWith(prefix)) { diff --git a/src/Mod/Material/CMakeLists.txt b/src/Mod/Material/CMakeLists.txt index a9ee1296d4..fc6113bd21 100644 --- a/src/Mod/Material/CMakeLists.txt +++ b/src/Mod/Material/CMakeLists.txt @@ -285,6 +285,7 @@ set(MaterialTest_Files materialtests/__init__.py materialtests/TestModels.py materialtests/TestMaterials.py + materialtests/TestMaterialCreation.py ) ADD_CUSTOM_TARGET(MaterialTest ALL diff --git a/src/Mod/Material/TestMaterialsApp.py b/src/Mod/Material/TestMaterialsApp.py index c53634d312..b368b8c4a7 100644 --- a/src/Mod/Material/TestMaterialsApp.py +++ b/src/Mod/Material/TestMaterialsApp.py @@ -28,3 +28,4 @@ import Materials from materialtests.TestModels import ModelTestCases from materialtests.TestMaterials import MaterialTestCases +from materialtests.TestMaterialCreation import MaterialCreationTestCases diff --git a/src/Mod/Material/materialtests/TestMaterialCreation.py b/src/Mod/Material/materialtests/TestMaterialCreation.py new file mode 100644 index 0000000000..faf1a48f38 --- /dev/null +++ b/src/Mod/Material/materialtests/TestMaterialCreation.py @@ -0,0 +1,137 @@ +#************************************************************************** +# Copyright (c) 2023 David Carter * +# * +# This file is part of the FreeCAD CAx development system. * +# * +# This program is free software; you can redistribute it and/or modify * +# it under the terms of the GNU Lesser General Public License (LGPL) * +# as published by the Free Software Foundation; either version 2 of * +# the License, or (at your option) any later version. * +# for detail see the LICENCE text file. * +# * +# FreeCAD is distributed in the hope that it will be useful, * +# but WITHOUT ANY WARRANTY; without even the implied warranty of * +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# GNU Library General Public License for more details. * +# * +# You should have received a copy of the GNU Library General Public * +# License along with FreeCAD; if not, write to the Free Software * +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# USA * +#************************************************************************** + +""" +Test module for FreeCAD material cards and APIs +""" + +import unittest +import FreeCAD +import Materials + +import os + +parseQuantity = FreeCAD.Units.parseQuantity + +class MaterialCreationTestCases(unittest.TestCase): + """ + Test class for FreeCAD material creation and APIs + """ + + def setUp(self): + """ Setup function to initialize test data """ + self.ModelManager = Materials.ModelManager() + self.MaterialManager = Materials.MaterialManager() + self.uuids = Materials.UUIDs() + + def tearDown(self): + try: + material = self.MaterialManager.getMaterialByPath("Example/Frakenstein.FCMat", "User") + os.remove(material.LibraryRoot + "/Example/Frakenstein.FCMat") + try: + os.rmdir(material.LibraryRoot + "/Example") + except OSError: + # Can't remove directories that aren't empty + pass + except LookupError: + pass + + def checkNewMaterial(self, material): + """ Check the state of a newly created material """ + self.assertEqual(len(material.UUID), 0) + self.assertEqual(len(material.Name), 0) + self.assertEqual(len(material.Author), 0) + self.assertEqual(len(material.License), 0) + self.assertEqual(len(material.Description), 0) + self.assertEqual(len(material.URL), 0) + self.assertEqual(len(material.Reference), 0) + self.assertEqual(len(material.Parent), 0) + self.assertEqual(len(material.Tags), 0) + + def testCreateMaterial(self): + """ Create a material with properties """ + material = Materials.Material() + self.checkNewMaterial(material) + + material.Name = "Frankenstein" + material.Author = "Mary Shelley" + material.License = "CC-BY-3.0" + material.Description = "The sad story of a boy afraid of fire" + material.URL = "https://www.example.com" + material.Reference = "ISBN 978-1673287882" + + # UUID isn't valid until the file is saved + self.assertEqual(material.UUID, '') + + self.assertEqual(material.Name, "Frankenstein") + self.assertEqual(material.Author, "Mary Shelley") + self.assertEqual(material.License, "CC-BY-3.0") + self.assertEqual(material.Description, "The sad story of a boy afraid of fire") + self.assertEqual(material.URL, "https://www.example.com") + self.assertEqual(material.Reference, "ISBN 978-1673287882") + + self.assertEqual(len(material.PhysicalModels), 0) + self.assertEqual(len(material.AppearanceModels), 0) + + material.addAppearanceModel(self.uuids.TextureRendering) + self.assertEqual(len(material.AppearanceModels), 1) + # TextureRendering inherits BasicRendering + self.assertTrue(material.hasAppearanceModel(self.uuids.BasicRendering)) + self.assertTrue(material.hasAppearanceModel(self.uuids.TextureRendering)) + + # Colors are tuples of 3 (rgb) or 4 (rgba) values between 0 and 1 + # All values are set with strings + material.setAppearanceValue("DiffuseColor", "(1.0, 1.0, 1.0)") + material.setAppearanceValue("SpecularColor", "(0, 0, 0, 1.0)") + + self.assertEqual(material.AppearanceProperties["DiffuseColor"], "(1.0, 1.0, 1.0)") + self.assertEqual(material.getAppearanceValue("DiffuseColor"), "(1.0, 1.0, 1.0)") + + self.assertEqual(material.AppearanceProperties["SpecularColor"], "(0, 0, 0, 1.0)") + self.assertEqual(material.getAppearanceValue("SpecularColor"), "(0, 0, 0, 1.0)") + + # Properties without a value will return None + self.assertIsNone(material.getAppearanceValue("AmbientColor")) + self.assertIsNone(material.getAppearanceValue("EmissiveColor")) + self.assertIsNone(material.getAppearanceValue("Shininess")) + self.assertIsNone(material.getAppearanceValue("Transparency")) + self.assertIsNone(material.getAppearanceValue("TexturePath")) + self.assertIsNone(material.getAppearanceValue("TextureImage")) + self.assertIsNone(material.getAppearanceValue("TextureScaling")) + + material.addPhysicalModel(self.uuids.Density) + self.assertEqual(len(material.PhysicalModels), 1) + self.assertTrue(material.hasPhysicalModel(self.uuids.Density)) + + # Quantity properties require units + material.setPhysicalValue("Density", "99.9 kg/m^3") + self.assertEqual(material.getPhysicalValue("Density").UserString, parseQuantity("99.90 kg/m^3").UserString) + + # MaterialManager is unaware of the material until it is saved + self.MaterialManager.save("User", material, "Example/Frakenstein.FCMat") + + # Now the UUID is valid + uuid = material.UUID + self.assertEqual(len(material.UUID), 36) + self.assertIn(uuid, self.MaterialManager.Materials) + self.assertIsNotNone(self.MaterialManager.getMaterialByPath("Example/Frakenstein.FCMat", "User")) + self.assertIsNotNone(self.MaterialManager.getMaterial(uuid))