diff --git a/cadquery/__init__.py b/cadquery/__init__.py index 38b06cbee..7f4a13623 100644 --- a/cadquery/__init__.py +++ b/cadquery/__init__.py @@ -37,7 +37,7 @@ ) from .sketch import Sketch from .cq import CQ, Workplane -from .assembly import Assembly, Color, Constraint +from .assembly import Assembly, Color, Constraint, Material from . import selectors from . import plugins @@ -48,6 +48,7 @@ "Assembly", "Color", "Constraint", + "Material", "plugins", "selectors", "Plane", diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 65c024702..d025581bb 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -19,7 +19,7 @@ from .cq import Workplane from .occ_impl.shapes import Shape, Compound, isSubshape from .occ_impl.geom import Location -from .occ_impl.assembly import Color +from .occ_impl.assembly import Color, Material from .occ_impl.solver import ( ConstraintKind, ConstraintSolver, @@ -82,12 +82,20 @@ def _define_grammar(): _grammar = _define_grammar() +def _ensure_material(material): + """ + Convert string to Material if needed. + """ + return Material(material) if isinstance(material, str) else material + + class Assembly(object): """Nested assembly of Workplane and Shape objects defining their relative positions.""" loc: Location name: str color: Optional[Color] + material: Optional[Material] metadata: Dict[str, Any] obj: AssemblyObjects @@ -110,6 +118,7 @@ def __init__( loc: Optional[Location] = None, name: Optional[str] = None, color: Optional[Color] = None, + material: Optional[Material] = None, metadata: Optional[Dict[str, Any]] = None, ): """ @@ -119,6 +128,7 @@ def __init__( :param loc: location of the root object (default: None, interpreted as identity transformation) :param name: unique name of the root object (default: None, resulting in an UUID being generated) :param color: color of the added object (default: None) + :param material: material (for visual and/or physical properties) of the added object (default: None) :param metadata: a store for user-defined metadata (default: None) :return: An Assembly object. @@ -138,6 +148,7 @@ def __init__( self.loc = loc if loc else Location() self.name = name if name else str(uuid()) self.color = color if color else None + self.material = material if material else None self.metadata = metadata if metadata else {} self.parent = None @@ -156,7 +167,9 @@ def _copy(self) -> "Assembly": Make a deep copy of an assembly """ - rv = self.__class__(self.obj, self.loc, self.name, self.color, self.metadata) + rv = self.__class__( + self.obj, self.loc, self.name, self.color, self.material, self.metadata + ) rv._subshape_colors = dict(self._subshape_colors) rv._subshape_names = dict(self._subshape_names) @@ -200,6 +213,7 @@ def add( loc: Optional[Location] = None, name: Optional[str] = None, color: Optional[Color] = None, + material: Optional[Union[Material, str]] = None, metadata: Optional[Dict[str, Any]] = None, ) -> Self: """ @@ -211,6 +225,8 @@ def add( :param name: unique name of the root object (default: None, resulting in an UUID being generated) :param color: color of the added object (default: None) + :param material: material (for visual and/or physical properties) of the added object + (default: None) :param metadata: a store for user-defined metadata (default: None) """ ... @@ -234,15 +250,23 @@ def add(self, arg, **kwargs): subassy.loc = kwargs["loc"] if kwargs.get("loc") else arg.loc subassy.name = kwargs["name"] if kwargs.get("name") else arg.name subassy.color = kwargs["color"] if kwargs.get("color") else arg.color + subassy.material = _ensure_material( + kwargs["material"] if kwargs.get("material") else arg.material + ) subassy.metadata = ( kwargs["metadata"] if kwargs.get("metadata") else arg.metadata ) + subassy.parent = self self.children.append(subassy) self.objects.update(subassy._flatten()) else: + # Convert the material string to a Material object, if needed + if "material" in kwargs: + kwargs["material"] = _ensure_material(kwargs["material"]) + assy = self.__class__(arg, **kwargs) assy.parent = self diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index d76422bb7..416e29261 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -13,12 +13,15 @@ from typing_extensions import Protocol, Self from math import degrees, radians +from OCP.TCollection import TCollection_HAsciiString from OCP.TDocStd import TDocStd_Document from OCP.TCollection import TCollection_ExtendedString from OCP.XCAFDoc import ( XCAFDoc_DocumentTool, XCAFDoc_ColorType, XCAFDoc_ColorGen, + XCAFDoc_Material, + XCAFDoc_VisMaterial, ) from OCP.XCAFApp import XCAFApp_Application from OCP.BinXCAFDrivers import BinXCAFDrivers @@ -57,6 +60,87 @@ AssemblyObjects = Union[Shape, Workplane, None] +class Material(object): + """ + Wrapper for the OCCT material classes XCAFDoc_Material and XCAFDoc_VisMaterial. + XCAFDoc_Material is focused on physical material properties and + XCAFDoc_VisMaterial is for visual properties to be used when rendering. + """ + + wrapped: XCAFDoc_Material + wrapped_vis: XCAFDoc_VisMaterial + + def __init__(self, name: str | None = None, **kwargs): + """ + Can be passed an arbitrary string name for the material along with keyword + arguments defining some other characteristics of the material. If nothing is + passed, arbitrary defaults are used. + """ + + # Create the default material object and prepare to set a few defaults + self.wrapped = XCAFDoc_Material() + + # Default values in case the user did not set any others + aName = "Default" + aDescription = "Default material with properties similar to low carbon steel" + aDensity = 7.85 + aDensityName = "Mass density" + aDensityTypeName = "g/cm^3" + + # See if there are any non-defaults to be set + if name: + aName = name + if "description" in kwargs.keys(): + aDescription = kwargs["description"] + if "density" in kwargs.keys(): + aDensity = kwargs["density"] + if "densityUnit" in kwargs.keys(): + aDensityTypeName = kwargs["densityUnit"] + + # Set the properties on the material object + self.wrapped.Set( + TCollection_HAsciiString(aName), + TCollection_HAsciiString(aDescription), + aDensity, + TCollection_HAsciiString(aDensityName), + TCollection_HAsciiString(aDensityTypeName), + ) + + # Create the default visual material object and allow it to be used just with + # the OCC layer, for now. When this material class is expanded to include visual + # attributes, the OCC docs say that XCAFDoc_VisMaterialTool should be used to + # manage those attributes on the XCAFDoc_VisMaterial class. + self.wrapped_vis = XCAFDoc_VisMaterial() + + @property + def name(self) -> str: + """ + Get the string name of the material. + """ + return self.wrapped.GetName().ToCString() + + @property + def description(self) -> str: + """ + Get the string description of the material. + """ + return self.wrapped.GetDescription().ToCString() + + @property + def density(self) -> float: + """ + Get the density value of the material. + """ + return self.wrapped.GetDensity() + + @property + def densityUnit(self) -> str: + """ + Get the units that the material density is defined in. + """ + return self.wrapped.GetDensValType().ToCString() + + class Color(object): """ Wrapper for the OCCT color object Quantity_ColorRGBA. @@ -238,6 +322,7 @@ def add( loc: Optional[Location] = None, name: Optional[str] = None, color: Optional[Color] = None, + material: Optional[Union[Material, str]] = None, metadata: Optional[Dict[str, Any]] = None, ) -> Self: ... @@ -248,6 +333,7 @@ def add( loc: Optional[Location] = None, name: Optional[str] = None, color: Optional[Color] = None, + material: Optional[Union[Material, str]] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> Self: diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 4e73b518d..6ef117332 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -36,6 +36,7 @@ from OCP.TDF import TDF_ChildIterator from OCP.Quantity import Quantity_ColorRGBA, Quantity_TOC_sRGB, Quantity_NameOfColor from OCP.TopAbs import TopAbs_ShapeEnum +from OCP.Graphic3d import Graphic3d_NameOfMaterial @pytest.fixture(scope="function") @@ -1523,6 +1524,44 @@ def test_colors_assy1(assy_fixture, request, tmpdir, kind): check_assy(assy, assy_i) +def test_materials(): + # Test a default material not attached to an assembly + mat_0 = cq.Material() + assert mat_0.name == "Default" + + # Simple objects to be added to the assembly with the material + wp_1 = cq.Workplane().box(10, 10, 10) + wp_2 = cq.Workplane().box(5, 5, 5) + wp_3 = cq.Workplane().box(2.5, 2.5, 2.5) + + # Add the object to the assembly with the material + assy = cq.Assembly() + + # Test with a default material + mat_1 = cq.Material() + assy.add(wp_1, material=mat_1) + assert assy.children[0].material.name == "Default" + assert ( + assy.children[0].material.description + == "Default material with properties similar to low carbon steel" + ) + assert assy.children[0].material.density == 7.85 + assert assy.children[0].material.densityUnit == "g/cm^3" + + # Test with a user-defined material when passing properties in constructor + mat_2 = cq.Material( + "test", description="Test material", density=1.0, densityUnit="lb/in^3" + ) + assy.add(wp_2, material=mat_2) + assert assy.children[1].material.name == "test" + assert assy.children[1].material.description == "Test material" + assert assy.children[1].material.density == 1.0 + assert assy.children[1].material.densityUnit == "lb/in^3" + + # The visualization material is left for later expansion + assert assy.children[1].material.wrapped_vis.IsEmpty() + + @pytest.mark.parametrize( "assy_fixture, expected", [