Skip to content

Commit 2e05819

Browse files
authored
Add Basic Materials Functionality (#1923)
* Allow passing of material as a string when adding a subassembly * Fixed metadata bug because of _copy call * Reworked Material class to wrap XCAFDoc_Material and XCAFDoc_VisMaterial instead * Allow pickling of material class
1 parent 568a875 commit 2e05819

5 files changed

Lines changed: 216 additions & 4 deletions

File tree

cadquery/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
)
3838
from .sketch import Sketch
3939
from .cq import CQ, Workplane
40-
from .assembly import Assembly, Color, Constraint
40+
from .assembly import Assembly, Color, Constraint, Material
4141
from . import selectors
4242
from . import plugins
4343

@@ -48,6 +48,7 @@
4848
"Assembly",
4949
"Color",
5050
"Constraint",
51+
"Material",
5152
"plugins",
5253
"selectors",
5354
"Plane",

cadquery/assembly.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from .cq import Workplane
2121
from .occ_impl.shapes import Shape, Compound, isSubshape, compound
2222
from .occ_impl.geom import Location
23-
from .occ_impl.assembly import Color
23+
from .occ_impl.assembly import Color, Material
2424
from .occ_impl.solver import (
2525
ConstraintKind,
2626
ConstraintSolver,
@@ -85,12 +85,20 @@ def _define_grammar():
8585
_grammar = _define_grammar()
8686

8787

88+
def _ensure_material(material):
89+
"""
90+
Convert string to Material if needed.
91+
"""
92+
return Material(material) if isinstance(material, str) else material
93+
94+
8895
class Assembly(object):
8996
"""Nested assembly of Workplane and Shape objects defining their relative positions."""
9097

9198
loc: Location
9299
name: str
93100
color: Optional[Color]
101+
material: Optional[Material]
94102
metadata: Dict[str, Any]
95103

96104
obj: AssemblyObjects
@@ -113,6 +121,7 @@ def __init__(
113121
loc: Optional[Location] = None,
114122
name: Optional[str] = None,
115123
color: Optional[Color] = None,
124+
material: Optional[Material] = None,
116125
metadata: Optional[Dict[str, Any]] = None,
117126
):
118127
"""
@@ -122,6 +131,7 @@ def __init__(
122131
:param loc: location of the root object (default: None, interpreted as identity transformation)
123132
:param name: unique name of the root object (default: None, resulting in an UUID being generated)
124133
:param color: color of the added object (default: None)
134+
:param material: material (for visual and/or physical properties) of the added object (default: None)
125135
:param metadata: a store for user-defined metadata (default: None)
126136
:return: An Assembly object.
127137
@@ -141,6 +151,7 @@ def __init__(
141151
self.loc = loc if loc else Location()
142152
self.name = name if name else str(uuid())
143153
self.color = color if color else None
154+
self.material = material if material else None
144155
self.metadata = metadata if metadata else {}
145156
self.parent = None
146157

@@ -159,7 +170,9 @@ def _copy(self) -> "Assembly":
159170
Make a deep copy of an assembly
160171
"""
161172

162-
rv = self.__class__(self.obj, self.loc, self.name, self.color, self.metadata)
173+
rv = self.__class__(
174+
self.obj, self.loc, self.name, self.color, self.material, self.metadata
175+
)
163176

164177
rv._subshape_colors = BiDict(self._subshape_colors)
165178
rv._subshape_names = BiDict(self._subshape_names)
@@ -203,6 +216,7 @@ def add(
203216
loc: Optional[Location] = None,
204217
name: Optional[str] = None,
205218
color: Optional[Color] = None,
219+
material: Optional[Union[Material, str]] = None,
206220
metadata: Optional[Dict[str, Any]] = None,
207221
) -> Self:
208222
"""
@@ -214,6 +228,8 @@ def add(
214228
:param name: unique name of the root object (default: None, resulting in an UUID being
215229
generated)
216230
:param color: color of the added object (default: None)
231+
:param material: material (for visual and/or physical properties) of the added object
232+
(default: None)
217233
:param metadata: a store for user-defined metadata (default: None)
218234
"""
219235
...
@@ -237,15 +253,23 @@ def add(self, arg, **kwargs):
237253
subassy.loc = kwargs["loc"] if kwargs.get("loc") else arg.loc
238254
subassy.name = kwargs["name"] if kwargs.get("name") else arg.name
239255
subassy.color = kwargs["color"] if kwargs.get("color") else arg.color
256+
subassy.material = _ensure_material(
257+
kwargs["material"] if kwargs.get("material") else arg.material
258+
)
240259
subassy.metadata = (
241260
kwargs["metadata"] if kwargs.get("metadata") else arg.metadata
242261
)
262+
243263
subassy.parent = self
244264

245265
self.children.append(subassy)
246266
self.objects.update(subassy._flatten())
247267

248268
else:
269+
# Convert the material string to a Material object, if needed
270+
if "material" in kwargs:
271+
kwargs["material"] = _ensure_material(kwargs["material"])
272+
249273
assy = self.__class__(arg, **kwargs)
250274
assy.parent = self
251275

cadquery/occ_impl/assembly.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
from typing_extensions import Protocol, Self
1414
from math import degrees, radians
1515

16+
from OCP.TCollection import TCollection_HAsciiString
1617
from OCP.TDocStd import TDocStd_Document
1718
from OCP.TCollection import TCollection_ExtendedString
1819
from OCP.XCAFDoc import (
1920
XCAFDoc_DocumentTool,
2021
XCAFDoc_ColorType,
2122
XCAFDoc_ColorGen,
23+
XCAFDoc_Material,
24+
XCAFDoc_VisMaterial,
2225
)
2326
from OCP.XCAFApp import XCAFApp_Application
2427
from OCP.BinXCAFDrivers import BinXCAFDrivers
@@ -58,6 +61,129 @@
5861
AssemblyObjects = Union[Shape, Workplane, None]
5962

6063

64+
class Material(object):
65+
"""
66+
Wrapper for the OCCT material classes XCAFDoc_Material and XCAFDoc_VisMaterial.
67+
XCAFDoc_Material is focused on physical material properties and
68+
XCAFDoc_VisMaterial is for visual properties to be used when rendering.
69+
"""
70+
71+
wrapped: XCAFDoc_Material
72+
wrapped_vis: XCAFDoc_VisMaterial
73+
74+
def __init__(self, name: str | None = None, **kwargs):
75+
"""
76+
Can be passed an arbitrary string name for the material along with keyword
77+
arguments defining some other characteristics of the material. If nothing is
78+
passed, arbitrary defaults are used.
79+
"""
80+
81+
# Create the default material object and prepare to set a few defaults
82+
self.wrapped = XCAFDoc_Material()
83+
84+
# Default values in case the user did not set any others
85+
aName = "Default"
86+
aDescription = "Default material with properties similar to low carbon steel"
87+
aDensity = 7.85
88+
aDensityName = "Mass density"
89+
aDensityTypeName = "g/cm^3"
90+
91+
# See if there are any non-defaults to be set
92+
if name:
93+
aName = name
94+
if "description" in kwargs.keys():
95+
aDescription = kwargs["description"]
96+
if "density" in kwargs.keys():
97+
aDensity = kwargs["density"]
98+
if "densityUnit" in kwargs.keys():
99+
aDensityTypeName = kwargs["densityUnit"]
100+
101+
# Set the properties on the material object
102+
self.wrapped.Set(
103+
TCollection_HAsciiString(aName),
104+
TCollection_HAsciiString(aDescription),
105+
aDensity,
106+
TCollection_HAsciiString(aDensityName),
107+
TCollection_HAsciiString(aDensityTypeName),
108+
)
109+
110+
# Create the default visual material object and allow it to be used just with
111+
# the OCC layer, for now. When this material class is expanded to include visual
112+
# attributes, the OCC docs say that XCAFDoc_VisMaterialTool should be used to
113+
# manage those attributes on the XCAFDoc_VisMaterial class.
114+
self.wrapped_vis = XCAFDoc_VisMaterial()
115+
116+
@property
117+
def name(self) -> str:
118+
"""
119+
Get the string name of the material.
120+
"""
121+
return self.wrapped.GetName().ToCString()
122+
123+
@property
124+
def description(self) -> str:
125+
"""
126+
Get the string description of the material.
127+
"""
128+
return self.wrapped.GetDescription().ToCString()
129+
130+
@property
131+
def density(self) -> float:
132+
"""
133+
Get the density value of the material.
134+
"""
135+
return self.wrapped.GetDensity()
136+
137+
@property
138+
def densityUnit(self) -> str:
139+
"""
140+
Get the units that the material density is defined in.
141+
"""
142+
return self.wrapped.GetDensValType().ToCString()
143+
144+
def toTuple(self) -> Tuple[str, str, float, str]:
145+
"""
146+
Convert Material to a tuple.
147+
"""
148+
name = self.name
149+
description = self.description
150+
density = self.density
151+
densityUnit = self.densityUnit
152+
153+
return (name, description, density, densityUnit)
154+
155+
def __hash__(self):
156+
"""
157+
Create a unique hash for this material via its tuple.
158+
"""
159+
return hash(self.toTuple())
160+
161+
def __eq__(self, other):
162+
"""
163+
Check equality of this material against another via its tuple.
164+
"""
165+
return self.toTuple() == other.toTuple()
166+
167+
def __getstate__(self) -> Tuple[str, str, float, str]:
168+
"""
169+
Allows pickling.
170+
"""
171+
return self.toTuple()
172+
173+
def __setstate__(self, data: Tuple[str, str, float, str]):
174+
"""
175+
Allows pickling.
176+
"""
177+
self.wrapped = XCAFDoc_Material()
178+
self.wrapped.Set(
179+
TCollection_HAsciiString(data[0]),
180+
TCollection_HAsciiString(data[1]),
181+
data[2],
182+
TCollection_HAsciiString("Mass density"),
183+
TCollection_HAsciiString(data[3]),
184+
)
185+
186+
61187
class Color(object):
62188
"""
63189
Wrapper for the OCCT color object Quantity_ColorRGBA.
@@ -239,6 +365,7 @@ def add(
239365
loc: Optional[Location] = None,
240366
name: Optional[str] = None,
241367
color: Optional[Color] = None,
368+
material: Optional[Union[Material, str]] = None,
242369
metadata: Optional[Dict[str, Any]] = None,
243370
) -> Self:
244371
...
@@ -249,6 +376,7 @@ def add(
249376
loc: Optional[Location] = None,
250377
name: Optional[str] = None,
251378
color: Optional[Color] = None,
379+
material: Optional[Union[Material, str]] = None,
252380
metadata: Optional[Dict[str, Any]] = None,
253381
**kwargs: Any,
254382
) -> Self:

tests/test_assembly.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from OCP.TDF import TDF_ChildIterator
3737
from OCP.Quantity import Quantity_ColorRGBA, Quantity_TOC_sRGB, Quantity_NameOfColor
3838
from OCP.TopAbs import TopAbs_ShapeEnum
39+
from OCP.Graphic3d import Graphic3d_NameOfMaterial
3940

4041

4142
@pytest.fixture(scope="function")
@@ -1527,6 +1528,55 @@ def test_colors_assy1(assy_fixture, request, tmpdir, kind):
15271528
check_assy(assy, assy_i)
15281529

15291530

1531+
def test_materials():
1532+
# Test a default material not attached to an assembly
1533+
mat_0 = cq.Material()
1534+
assert mat_0.name == "Default"
1535+
1536+
# Simple objects to be added to the assembly with the material
1537+
wp_1 = cq.Workplane().box(10, 10, 10)
1538+
wp_2 = cq.Workplane().box(5, 5, 5)
1539+
wp_3 = cq.Workplane().box(2.5, 2.5, 2.5)
1540+
1541+
# Add the object to the assembly with the material
1542+
assy = cq.Assembly()
1543+
1544+
# Test with a default material
1545+
mat_1 = cq.Material()
1546+
assy.add(wp_1, material=mat_1)
1547+
assert assy.children[0].material.name == "Default"
1548+
assert (
1549+
assy.children[0].material.description
1550+
== "Default material with properties similar to low carbon steel"
1551+
)
1552+
assert assy.children[0].material.density == 7.85
1553+
assert assy.children[0].material.densityUnit == "g/cm^3"
1554+
1555+
# Test with a user-defined material when passing properties in constructor
1556+
mat_2 = cq.Material(
1557+
"test", description="Test material", density=1.0, densityUnit="lb/in^3"
1558+
)
1559+
assy.add(wp_2, material=mat_2)
1560+
assert assy.children[1].material.name == "test"
1561+
assert assy.children[1].material.description == "Test material"
1562+
assert assy.children[1].material.density == 1.0
1563+
assert assy.children[1].material.densityUnit == "lb/in^3"
1564+
1565+
# The visualization material is left for later expansion
1566+
assert assy.children[1].material.wrapped_vis.IsEmpty()
1567+
1568+
# Test the ability to convert a material to a tuple
1569+
assert mat_2.toTuple() == ("test", "Test material", 1.0, "lb/in^3")
1570+
1571+
# Test the ability to has a material
1572+
assert mat_2.__hash__() == hash(("test", "Test material", 1.0, "lb/in^3"))
1573+
1574+
# Test the equality operator with material
1575+
assert mat_2 == cq.Material(
1576+
"test", description="Test material", density=1.0, densityUnit="lb/in^3"
1577+
)
1578+
1579+
15301580
@pytest.mark.parametrize(
15311581
"assy_fixture, expected",
15321582
[

tests/test_pickle.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Assembly,
1111
Color,
1212
Workplane,
13+
Material,
1314
)
1415
from cadquery.func import box
1516

@@ -42,6 +43,14 @@ def test_shape():
4243

4344
def test_assy():
4445

45-
assy = Assembly().add(box(1, 1, 1), color=Color("blue")).add(box(2, 2, 2))
46+
mat_1 = Material(
47+
"test", description="Test material", density=1.0, densityUnit="lb/in^3"
48+
)
49+
50+
assy = (
51+
Assembly()
52+
.add(box(1, 1, 1), color=Color("blue"), material=mat_1)
53+
.add(box(2, 2, 2))
54+
)
4655

4756
assert isinstance(loads(dumps(assy)), Assembly)

0 commit comments

Comments
 (0)