Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cadquery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -48,6 +48,7 @@
"Assembly",
"Color",
"Constraint",
"Material",
"plugins",
"selectors",
"Plane",
Expand Down
28 changes: 26 additions & 2 deletions cadquery/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
):
"""
Expand All @@ -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.

Expand All @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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)
"""
...
Expand All @@ -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

Expand Down
57 changes: 57 additions & 0 deletions cadquery/occ_impl/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from typing_extensions import Protocol, Self
from math import degrees, radians

from OCP.TCollection import TCollection_AsciiString
from OCP.TDocStd import TDocStd_Document
from OCP.TCollection import TCollection_ExtendedString
from OCP.XCAFDoc import (
Expand All @@ -37,6 +38,7 @@
from OCP.BOPAlgo import BOPAlgo_GlueEnum, BOPAlgo_MakeConnected
from OCP.TopoDS import TopoDS_Shape
from OCP.gp import gp_EulerSequence
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial

from vtkmodules.vtkRenderingCore import (
vtkActor,
Expand All @@ -57,6 +59,59 @@
AssemblyObjects = Union[Shape, Workplane, None]


class Material(object):
"""
Wrapper for the OCCT material object Graphic3d_MaterialAspect.
Graphic3d_MaterialAspect is mainly for rendering purposes, so this
Material class could be extended to also contain physical material
properties.
"""

wrapped: Graphic3d_MaterialAspect

def __init__(self, name: str | Graphic3d_NameOfMaterial | None = None, **kwargs):
"""
Can be passed an arbitrary string name, a string name of an OCC material,
or a Graphic3d_NameOfMaterial object. If nothing is passed, the default OCC
material is used.
"""

# Get the name from the positional arguments or the kwargs
material_name = (
name
if name is not None
else kwargs["name"]
if name in kwargs.keys()
else None
)

# The caller wants a default materials object
if material_name is None:
self.wrapped = Graphic3d_MaterialAspect()
# If we have a string, there may be some additional lookup that needs to happen
elif isinstance(material_name, str):
# Check to see if the name is one of the pre-defined materials in OpenCASCADE
if material_name in dir(Graphic3d_NameOfMaterial):
occ_mat = getattr(Graphic3d_NameOfMaterial, material_name)
self.wrapped = Graphic3d_MaterialAspect(occ_mat)
else:
# An arbitrary user-defined name is being used
self.wrapped = Graphic3d_MaterialAspect(
Graphic3d_NameOfMaterial.Graphic3d_NameOfMaterial_UserDefined
)
self.wrapped.SetMaterialName(TCollection_AsciiString(material_name))
# The caller is passing a direct OCC material type
elif isinstance(material_name, Graphic3d_NameOfMaterial):
self.wrapped = Graphic3d_MaterialAspect(material_name)

@property
def name(self) -> str:
"""
Read-only property to get a simple string name from the material.
"""
return self.wrapped.StringName().ToCString()


class Color(object):
"""
Wrapper for the OCCT color object Quantity_ColorRGBA.
Expand Down Expand Up @@ -238,6 +293,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:
...
Expand All @@ -248,6 +304,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:
Expand Down
38 changes: 38 additions & 0 deletions tests/test_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -1523,6 +1524,43 @@ def test_colors_assy1(assy_fixture, request, tmpdir, kind):
check_assy(assy, assy_i)


def test_materials():
# Test a default material
mat_0 = cq.Material()
assert mat_0.wrapped.StringName().ToCString() == "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)
wp_4 = cq.Workplane().box(1.25, 1.25, 1.25)

# Add the object to the assembly with the material
assy = cq.Assembly()

# Test with the string name of a standard pre-defined material from OpenCASCADE
mat_1 = cq.Material(name="Graphic3d_NameOfMaterial_Brass")
assy.add(wp_1, material=mat_1)
assert assy.children[0].material.wrapped.StringName().ToCString() == "Brass"
assert assy.children[0].material.name == "Brass"

# Test with a user-defined material
mat_2 = cq.Material(name="test")
assy.add(wp_2, material=mat_2)
assert assy.children[1].material.wrapped.StringName().ToCString() == "test"
assert assy.children[1].material.name == "test"

# Test with an OpenCASCADE material type
mat_3 = cq.Material(Graphic3d_NameOfMaterial.Graphic3d_NameOfMaterial_Stone)
assy.add(wp_3, material=mat_3)
assert assy.children[2].material.wrapped.StringName().ToCString() == "Stone"
assert assy.children[2].material.name == "Stone"

# Test plain material string passed when adding subassembly
assy.add(wp_4, material="Unobtanium")
assert assy.children[3].material.name == "Unobtanium"


@pytest.mark.parametrize(
"assy_fixture, expected",
[
Expand Down