Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
86 changes: 86 additions & 0 deletions cadquery/occ_impl/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
...
Expand All @@ -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:
Expand Down
39 changes: 39 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,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",
[
Expand Down