Skip to content

Support for materials #1815

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -38,3 +38,6 @@ out1.3mf
out2.3mf
out3.3mf
orig.dxf
box.brep
sketch.dxf
material_test*
6 changes: 5 additions & 1 deletion cadquery/__init__.py
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@
)
from .occ_impl import exporters
from .occ_impl import importers
from .materials import Color, Material, SimpleMaterial, PbrMaterial

# these items are the common implementation

@@ -37,7 +38,7 @@
)
from .sketch import Sketch
from .cq import CQ, Workplane
from .assembly import Assembly, Color, Constraint
from .assembly import Assembly, Constraint
from . import selectors
from . import plugins

@@ -47,6 +48,9 @@
"Workplane",
"Assembly",
"Color",
"Material",
"SimpleMaterial",
"PbrMaterial",
"Constraint",
"plugins",
"selectors",
37 changes: 29 additions & 8 deletions cadquery/assembly.py
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@
from .cq import Workplane
from .occ_impl.shapes import Shape, Compound
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,
@@ -85,6 +85,7 @@ class Assembly(object):
loc: Location
name: str
color: Optional[Color]
material: Optional[Material]
metadata: Dict[str, Any]

obj: AssemblyObjects
@@ -107,6 +108,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,
):
"""
@@ -116,6 +118,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 of the added object (default: None)
:param metadata: a store for user-defined metadata (default: None)
:return: An Assembly object.
@@ -135,6 +138,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

@@ -153,7 +157,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
)

for ch in self.children:
ch_copy = ch._copy()
@@ -172,6 +178,7 @@ def add(
loc: Optional[Location] = None,
name: Optional[str] = None,
color: Optional[Color] = None,
material: Optional[Material] = None,
) -> "Assembly":
"""
Add a subassembly to the current assembly.
@@ -183,6 +190,8 @@ def add(
the subassembly being used)
:param color: color of the added object (default: None, resulting in the color stored in the
subassembly being used)
:param material: material of the added object (default: None, resulting in the material stored in the
subassembly being used)
"""
...

@@ -193,6 +202,7 @@ def add(
loc: Optional[Location] = None,
name: Optional[str] = None,
color: Optional[Color] = None,
material: Optional[Material] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> "Assembly":
"""
@@ -204,6 +214,7 @@ 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 of the added object (default: None)
:param metadata: a store for user-defined metadata (default: None)
"""
...
@@ -225,6 +236,9 @@ 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 = (
kwargs["material"] if kwargs.get("material") else arg.material
)
subassy.metadata = (
kwargs["metadata"] if kwargs.get("metadata") else arg.metadata
)
@@ -658,22 +672,29 @@ def __iter__(
loc: Optional[Location] = None,
name: Optional[str] = None,
color: Optional[Color] = None,
) -> Iterator[Tuple[Shape, str, Location, Optional[Color]]]:
material: Optional[Material] = None,
) -> Iterator[Tuple[Shape, str, Location, Optional[Color], Optional[Material]]]:
"""
Assembly iterator yielding shapes, names, locations and colors.
Assembly iterator yielding shapes, names, locations, colors and materials.
"""

name = f"{name}/{self.name}" if name else self.name
loc = loc * self.loc if loc else self.loc
color = self.color if self.color else color
material = self.material if self.material else material

if self.obj:
yield self.obj if isinstance(self.obj, Shape) else Compound.makeCompound(
s for s in self.obj.vals() if isinstance(s, Shape)
), name, loc, color
shape = (
self.obj
if isinstance(self.obj, Shape)
else Compound.makeCompound(
s for s in self.obj.vals() if isinstance(s, Shape)
)
)
yield shape, name, loc, color, material

for ch in self.children:
yield from ch.__iter__(loc, name, color)
yield from ch.__iter__(loc, name, color, material)

def toCompound(self) -> Compound:
"""
6 changes: 3 additions & 3 deletions cadquery/cq_directive.py
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@

from json import dumps

from cadquery import exporters, Assembly, Compound, Color, Sketch
from cadquery import exporters, Assembly, Compound, Sketch
from cadquery import cqgi
from cadquery.occ_impl.assembly import toJSON
from cadquery.occ_impl.jupyter_tools import DEFAULT_COLOR
@@ -299,9 +299,9 @@ def run(self):
if isinstance(shape, Assembly):
assy = shape
elif isinstance(shape, Sketch):
assy = Assembly(shape._faces, color=Color(*DEFAULT_COLOR))
assy = Assembly(shape._faces, color=DEFAULT_COLOR)
else:
assy = Assembly(shape, color=Color(*DEFAULT_COLOR))
assy = Assembly(shape, color=DEFAULT_COLOR)
else:
raise result.exception

296 changes: 296 additions & 0 deletions cadquery/materials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
from dataclasses import dataclass
from typing import Optional, Tuple, TypeAlias, overload

from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA, Quantity_TOC_sRGB
from OCP.XCAFDoc import (
XCAFDoc_Material,
XCAFDoc_VisMaterial,
XCAFDoc_VisMaterialPBR,
XCAFDoc_VisMaterialCommon,
)
from OCP.TCollection import TCollection_HAsciiString
from vtkmodules.vtkRenderingCore import vtkActor
from vtkmodules.vtkCommonColor import vtkNamedColors


RGB: TypeAlias = Tuple[float, float, float]
RGBA: TypeAlias = Tuple[float, float, float, float]


@dataclass(frozen=True)
class Color:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have the the original Color class not removed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Color class has the same behaviour as the old one (except toTuple, I will bring that back as alias for rgba()). I think it makes sense to unify color handling throughout the codebase instead of mixing Color, str, and tuple (with 3 or 4 floats). As I touched every place where colors come up, and need them inside materials, it seemed obvious to me to update it (but you're right, backwards compatibility in the vtk functions is partially broken).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd propose to go back to the original class. Maybe also move the materials to the assy module.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the rationale behind that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minimize the impact of the PR. For the second point, the organization of your module does not follow what is in general happening in the codebase. At least it should go to occ_impl

"""
Simple color representation with optional alpha channel.
All values are in range [0.0, 1.0].
"""

red: float
green: float
blue: float
alpha: float = 1.0

@overload
def __init__(self):
"""
Construct a Color with default value (white).
"""
...

@overload
def __init__(self, name: str):
"""
Construct a Color from a name.
:param name: name of the color, e.g. green
"""
...

@overload
def __init__(self, red: float, green: float, blue: float, alpha: float = 1.0):
"""
Construct a Color from RGB(A) values.
:param red: red value, 0-1
:param green: green value, 0-1
:param blue: blue value, 0-1
:param alpha: alpha value, 0-1 (default: 1.0)
"""
...

def __init__(self, *args, **kwargs):
# Check for unknown kwargs
valid_kwargs = {"red", "green", "blue", "alpha", "name"}
unknown_kwargs = set(kwargs.keys()) - valid_kwargs
if unknown_kwargs:
raise TypeError(f"Got unexpected keyword arguments: {unknown_kwargs}")

number_of_args = len(args) + len(kwargs)
if number_of_args == 0:
# Handle no-args case (default yellow)
r, g, b, a = 1.0, 1.0, 0.0, 1.0
elif (number_of_args == 1 and isinstance(args[0], str)) or "name" in kwargs:
color_name = args[0] if number_of_args == 1 else kwargs["name"]

# Try to get color from OCCT first, fall back to VTK if not found
try:
# Get color from OCCT
occ_rgba = Quantity_ColorRGBA()
exists = Quantity_ColorRGBA.ColorFromName_s(color_name, occ_rgba)
if not exists:
raise ValueError(f"Unknown color name: {color_name}")
occ_rgb = occ_rgba.GetRGB()
r, g, b, a = (
occ_rgb.Red(),
occ_rgb.Green(),
occ_rgb.Blue(),
occ_rgba.Alpha(),
)
except ValueError:
# Check if color exists in VTK
vtk_colors = vtkNamedColors()
if not vtk_colors.ColorExists(color_name):
raise ValueError(f"Unsupported color name: {color_name}")

# Get color from VTK
vtk_rgba = vtk_colors.GetColor4d(color_name)
r = vtk_rgba.GetRed()
g = vtk_rgba.GetGreen()
b = vtk_rgba.GetBlue()
a = vtk_rgba.GetAlpha()

elif number_of_args <= 4:
r, g, b, a = args + (4 - len(args)) * (1.0,)

if "red" in kwargs:
r = kwargs["red"]
if "green" in kwargs:
g = kwargs["green"]
if "blue" in kwargs:
b = kwargs["blue"]
if "alpha" in kwargs:
a = kwargs["alpha"]

elif number_of_args > 4:
raise ValueError("Too many arguments")

# Validate values
for name, value in [("red", r), ("green", g), ("blue", b), ("alpha", a)]:
if not 0.0 <= value <= 1.0:
raise ValueError(f"{name} component must be between 0.0 and 1.0")

# Set all attributes at once
object.__setattr__(self, "red", r)
object.__setattr__(self, "green", g)
object.__setattr__(self, "blue", b)
object.__setattr__(self, "alpha", a)

def rgb(self) -> RGB:
"""Get RGB components as tuple."""
return (self.red, self.green, self.blue)

def rgba(self) -> RGBA:
"""Get RGBA components as tuple."""
return (self.red, self.green, self.blue, self.alpha)

def toTuple(self) -> RGBA:
"""Get RGBA components as tuple."""
return self.rgba()

def toQuantityColor(self) -> "Quantity_Color":
"""Convert Color to a Quantity_Color object."""

return Quantity_Color(self.red, self.green, self.blue, Quantity_TOC_sRGB)

def toQuantityColorRGBA(self) -> "Quantity_ColorRGBA":
"""Convert Color to a Quantity_ColorRGBA object."""
rgb = self.toQuantityColor()
return Quantity_ColorRGBA(rgb, self.alpha)

def __repr__(self) -> str:
"""String representation of the color."""
return f"Color(r={self.red}, g={self.green}, b={self.blue}, a={self.alpha})"

def __str__(self) -> str:
"""String representation of the color."""
return f"({self.red}, {self.green}, {self.blue}, {self.alpha})"


@dataclass(unsafe_hash=True)
class SimpleMaterial:
"""
Traditional material model matching OpenCascade's XCAFDoc_VisMaterialCommon.
"""

ambient_color: Color
diffuse_color: Color
specular_color: Color
shininess: float
transparency: float

def __post_init__(self):
"""Validate the material properties."""
# Validate ranges
if not 0.0 <= self.shininess <= 1.0:
raise ValueError("Shininess must be between 0.0 and 1.0")
if not 0.0 <= self.transparency <= 1.0:
raise ValueError("Transparency must be between 0.0 and 1.0")

def applyToVTKActor(self, actor: "vtkActor") -> None:
"""Apply common material properties to a VTK actor."""
prop = actor.GetProperty()
prop.SetInterpolationToPhong()
prop.SetAmbientColor(*self.ambient_color.rgb())
prop.SetDiffuseColor(*self.diffuse_color.rgb())
prop.SetSpecularColor(*self.specular_color.rgb())
prop.SetSpecular(self.shininess)
prop.SetOpacity(1.0 - self.transparency)


@dataclass(unsafe_hash=True)
class PbrMaterial:
"""
PBR material definition matching OpenCascade's XCAFDoc_VisMaterialPBR.
Note: Emission support will be added in a future version with proper texture support.
"""

# Base color and texture
base_color: Color
metallic: float
roughness: float
refraction_index: float

def __post_init__(self):
"""Validate the material properties."""
# Validate ranges
if not 0.0 <= self.metallic <= 1.0:
raise ValueError("Metallic must be between 0.0 and 1.0")
if not 0.0 <= self.roughness <= 1.0:
raise ValueError("Roughness must be between 0.0 and 1.0")
if not 1.0 <= self.refraction_index <= 3.0:
raise ValueError("Refraction index must be between 1.0 and 3.0")

def applyToVtkActor(self, actor: "vtkActor") -> None:
"""Apply PBR material properties to a VTK actor."""
prop = actor.GetProperty()
prop.SetInterpolationToPBR()
prop.SetColor(*self.base_color.rgb())
prop.SetOpacity(self.base_color.alpha)
prop.SetMetallic(self.metallic)
prop.SetRoughness(self.roughness)
prop.SetBaseIOR(self.refraction_index)


@dataclass(unsafe_hash=True)
class Material:
"""
Material class that can store multiple representation types simultaneously.
Different exporters/viewers can use the most appropriate representation.
"""

name: str
description: str
density: float
density_unit: str = "kg/m³"

# Material representations
color: Optional[Color] = None
simple: Optional[SimpleMaterial] = None
pbr: Optional[PbrMaterial] = None

def __post_init__(self):
"""Validate that at least one representation is provided."""
if not any([self.color, self.simple, self.pbr]):
raise ValueError("Material must have at least one representation defined")

def applyToVtkActor(self, actor: "vtkActor") -> None:
"""Apply material properties to a VTK actor."""
prop = actor.GetProperty()
prop.SetMaterialName(self.name)

if self.pbr:
self.pbr.applyToVtkActor(actor)
elif self.simple:
self.simple.applyToVTKActor(actor)
elif self.color:
r, g, b, a = self.color.toTuple()
prop.SetColor(r, g, b)
prop.SetOpacity(a)

def toXCAFDocMaterial(self) -> "XCAFDoc_Material":
"""Convert to OCCT material object."""

occt_material = XCAFDoc_Material()
occt_material.Set(
TCollection_HAsciiString(self.name),
TCollection_HAsciiString(self.description),
self.density,
TCollection_HAsciiString(self.density_unit),
TCollection_HAsciiString("DENSITY"),
)
return occt_material

def toXCAFDocVisMaterial(self) -> "XCAFDoc_VisMaterial":
"""Convert to OCCT visualization material object."""
vis_mat = XCAFDoc_VisMaterial()

# Set up PBR material if provided
if self.pbr:
pbr_mat = XCAFDoc_VisMaterialPBR()
pbr_mat.BaseColor = self.pbr.base_color.toQuantityColorRGBA()
pbr_mat.Metallic = self.pbr.metallic
pbr_mat.Roughness = self.pbr.roughness
pbr_mat.RefractionIndex = self.pbr.refraction_index
vis_mat.SetPbrMaterial(pbr_mat)

# Set up common material if provided
if self.simple:
common_mat = XCAFDoc_VisMaterialCommon()
common_mat.AmbientColor = self.simple.ambient_color.toQuantityColor()
common_mat.DiffuseColor = self.simple.diffuse_color.toQuantityColor()
common_mat.SpecularColor = self.simple.specular_color.toQuantityColor()
common_mat.Shininess = self.simple.shininess
common_mat.Transparency = self.simple.transparency
vis_mat.SetCommonMaterial(common_mat)

return vis_mat
299 changes: 149 additions & 150 deletions cadquery/occ_impl/assembly.py

Large diffs are not rendered by default.

58 changes: 50 additions & 8 deletions cadquery/occ_impl/exporters/assembly.py
Original file line number Diff line number Diff line change
@@ -3,12 +3,11 @@

from tempfile import TemporaryDirectory
from shutil import make_archive
from itertools import chain
from typing import Optional
from typing_extensions import Literal

from vtkmodules.vtkIOExport import vtkJSONSceneExporter, vtkVRMLExporter
from vtkmodules.vtkRenderingCore import vtkRenderer, vtkRenderWindow
from vtkmodules.vtkRenderingCore import vtkRenderWindow

from OCP.XSControl import XSControl_WorkSession
from OCP.STEPCAFControl import STEPCAFControl_Writer
@@ -30,10 +29,14 @@
from OCP.Message import Message_ProgressRange
from OCP.Interface import Interface_Static

from ..assembly import AssemblyProtocol, toCAF, toVTK, toFusedCAF
from ..assembly import (
AssemblyProtocol,
toCAF,
toVTK,
toFusedCAF,
)
from ..geom import Location
from ..shapes import Shape, Compound
from ..assembly import Color


class ExportModes:
@@ -140,6 +143,8 @@ def exportStepMeta(
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
layer_tool = XCAFDoc_DocumentTool.LayerTool_s(doc.Main())
material_tool = XCAFDoc_DocumentTool.MaterialTool_s(doc.Main())
vis_material_tool = XCAFDoc_DocumentTool.VisMaterialTool_s(doc.Main())

def _process_child(child: AssemblyProtocol, assy_label: TDF_Label):
"""
@@ -167,16 +172,51 @@ def _process_child(child: AssemblyProtocol, assy_label: TDF_Label):
child.name,
child.loc,
child.color,
child.material,
)

if child_items:
shape, name, loc, color = child_items
shape, name, loc, color, material = child_items

# Handle shape name, color and location
part_label = shape_tool.AddShape(shape.wrapped, False)
TDataStd_Name.Set_s(part_label, TCollection_ExtendedString(name))
if color:
color_tool.SetColor(part_label, color.wrapped, XCAFDoc_ColorGen)

# Handle color and material
if material:
# Set color from material if available
if material.color:
color_tool.SetColor(
part_label,
material.color.toQuantityColorRGBA(),
XCAFDoc_ColorGen,
)

# Convert material to OCCT format and add to document
occ_mat = material.toXCAFDocMaterial()
occ_vis_mat = material.toXCAFDocVisMaterial()

# Create material label
mat_lab = material_tool.AddMaterial(
occ_mat.GetName(),
occ_mat.GetDescription(),
occ_mat.GetDensity(),
occ_mat.GetDensName(),
occ_mat.GetDensValType(),
)
material_tool.SetMaterial(part_label, mat_lab)

# Add visualization material to the document
vis_mat_lab = vis_material_tool.AddMaterial(
occ_vis_mat, TCollection_AsciiString(material.name)
)
vis_material_tool.SetShapeMaterial(part_label, vis_mat_lab)
elif color:
# If no material but color exists, set the color directly
color_tool.SetColor(
part_label, color.toQuantityColorRGBA(), XCAFDoc_ColorGen
)

shape_tool.AddComponent(assy_label, part_label, loc.wrapped)

# If this assembly has shape metadata, add it to the shape
@@ -207,7 +247,9 @@ def _process_child(child: AssemblyProtocol, assy_label: TDF_Label):
# Set the individual face color
if face in colors:
color_tool.SetColor(
face_label, colors[face].wrapped, XCAFDoc_ColorGen,
face_label,
colors[face].toQuantityColorRGBA(),
XCAFDoc_ColorGen,
)

# Also add a layer to hold the face label data
2 changes: 1 addition & 1 deletion cadquery/occ_impl/jupyter_tools.py
Original file line number Diff line number Diff line change
@@ -168,7 +168,7 @@ def display(shape):
payload.append(
dict(
shape=toString(shape),
color=DEFAULT_COLOR,
color=DEFAULT_COLOR.rgb(),
position=[0, 0, 0],
orientation=[0, 0, 0],
)
45 changes: 21 additions & 24 deletions cadquery/vis.py
Original file line number Diff line number Diff line change
@@ -12,7 +12,7 @@
)
from .occ_impl.assembly import _loc2vtk, toVTKAssy

from typing import Union, Any, List, Tuple, Iterable, cast, Optional
from typing import Union, List, Tuple, Iterable, cast, Optional

from typish import instance_of

@@ -36,20 +36,19 @@
)
from vtkmodules.vtkCommonCore import vtkPoints
from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkPolyData
from vtkmodules.vtkCommonColor import vtkNamedColors
from vtkmodules.vtkIOImage import vtkPNGWriter


DEFAULT_COLOR = (1, 0.8, 0)
DEFAULT_EDGE_COLOR = (0, 0, 0)
DEFAULT_COLOR = Color(1, 0.8, 0)
DEFAULT_EDGE_COLOR = Color(0, 0, 0)
DEFAULT_BG_COLOR = Color(1.0, 1.0, 1.0)
DEFAULT_PT_SIZE = 7.5
DEFAULT_PT_COLOR = "darkviolet"
DEFAULT_CTRL_PT_COLOR = "crimson"
DEFAULT_CTRL_PT_SIZE = 7.5

SPECULAR = 0.3
SPECULAR_POWER = 100
SPECULAR_COLOR = vtkNamedColors().GetColor3d("White")
SPECULAR_COLOR = Color(1.0, 1.0, 1.0, 1.0)

ShapeLike = Union[Shape, Workplane, Assembly, Sketch, TopoDS_Shape]
Showable = Union[
@@ -58,15 +57,13 @@


def _to_assy(
*objs: ShapeLike,
color: Tuple[float, float, float] = DEFAULT_COLOR,
alpha: float = 1,
*objs: ShapeLike, color: Color = DEFAULT_COLOR, alpha: float = 1,
) -> Assembly:
"""
Convert shapes to Assembly.
"""

assy = Assembly(color=Color(*color, alpha))
assy = Assembly(color=Color(*color.rgb(), alpha))

for obj in objs:
if isinstance(obj, (Shape, Workplane, Assembly)):
@@ -139,7 +136,7 @@ def _to_vtk_pts(

rv.SetMapper(mapper)

rv.GetProperty().SetColor(vtkNamedColors().GetColor3d(color))
rv.GetProperty().SetColor(*Color(color).rgb())
rv.GetProperty().SetPointSize(size)

return rv
@@ -168,8 +165,8 @@ def _to_vtk_axs(locs: List[Location], scale: float = 0.1) -> vtkAssembly:

def _to_vtk_shapes(
obj: List[ShapeLike],
color: Tuple[float, float, float] = DEFAULT_COLOR,
edgecolor: Tuple[float, float, float] = DEFAULT_EDGE_COLOR,
color: Color = DEFAULT_COLOR,
edgecolor: Color = DEFAULT_EDGE_COLOR,
edges: bool = True,
linewidth: float = 2,
alpha: float = 1,
@@ -181,7 +178,7 @@ def _to_vtk_shapes(

return toVTKAssy(
_to_assy(*obj, color=color, alpha=alpha),
edgecolor=(*edgecolor, 1),
edgecolor=edgecolor,
edges=edges,
linewidth=linewidth,
tolerance=tolerance,
@@ -271,7 +268,7 @@ def ctrlPts(
rv.SetMapper(mapper)

props = rv.GetProperty()
props.SetColor(vtkNamedColors().GetColor3d(color))
props.SetColor(*Color(color).rgb())
props.SetPointSize(size)
props.SetLineWidth(size / 3)
props.SetRenderPointsAsSpheres(True)
@@ -321,8 +318,8 @@ def style(
# styling functions
def _apply_style(actor):
props = actor.GetProperty()
props.SetEdgeColor(vtkNamedColors().GetColor3d(meshcolor))
props.SetVertexColor(vtkNamedColors().GetColor3d(vertexcolor))
props.SetEdgeColor(*Color(meshcolor).rgb())
props.SetVertexColor(*Color(vertexcolor).rgb())
props.SetPointSize(markersize)
props.SetLineWidth(linewidth)
props.SetRenderPointsAsSpheres(spheres)
@@ -332,11 +329,11 @@ def _apply_style(actor):
if specular:
props.SetSpecular(SPECULAR)
props.SetSpecularPower(SPECULAR_POWER)
props.SetSpecularColor(SPECULAR_COLOR)
props.SetSpecularColor(*SPECULAR_COLOR.rgb())

def _apply_color(actor):
props = actor.GetProperty()
props.SetColor(vtkNamedColors().GetColor3d(color))
props.SetColor(*Color(color).rgb())
props.SetOpacity(alpha)

# split showables
@@ -348,8 +345,8 @@ def _apply_color(actor):
if shapes:
rv = _to_vtk_shapes(
shapes,
color=vtkNamedColors().GetColor3d(color),
edgecolor=vtkNamedColors().GetColor3d(edgecolor),
color=Color(color),
edgecolor=Color(edgecolor),
edges=edges,
linewidth=linewidth,
alpha=alpha,
@@ -396,7 +393,7 @@ def show(
width: Union[int, float] = 0.5,
height: Union[int, float] = 0.5,
trihedron: bool = True,
bgcolor: tuple[float, float, float] = (1, 1, 1),
bgcolor: Color = DEFAULT_BG_COLOR,
gradient: bool = True,
xpos: Union[int, float] = 0,
ypos: Union[int, float] = 0,
@@ -440,7 +437,7 @@ def show(
if specular:
propt.SetSpecular(SPECULAR)
propt.SetSpecularPower(SPECULAR_POWER)
propt.SetSpecularColor(SPECULAR_COLOR)
propt.SetSpecularColor(*SPECULAR_COLOR.rgb())

# rendering related settings
vtkMapper.SetResolveCoincidentTopologyToPolygonOffset()
@@ -473,7 +470,7 @@ def show(
orient_widget.InteractiveOff()

# use gradient background
renderer.SetBackground(*bgcolor)
renderer.SetBackground(*bgcolor.rgb())

if gradient:
renderer.GradientBackgroundOn()
12 changes: 12 additions & 0 deletions doc/apireference.rst
Original file line number Diff line number Diff line change
@@ -291,4 +291,16 @@ Workplane and Shape objects can be connected together into assemblies
Assembly.constrain
Assembly.solve
Constraint

Materials
----------

For material properties (physical and visual) of assembly items.

.. currentmodule:: cadquery
.. autosummary::

Color
Material
PbrMaterial
SimpleMaterial
159 changes: 158 additions & 1 deletion doc/assy.rst
Original file line number Diff line number Diff line change
@@ -879,7 +879,163 @@ Where:
show_object(assy)


Assembly colors
Materials
----------------

Materials can be assigned to objects in an assembly to define their visual
and physical properties. CadQuery supports three types of material representations:

1. Color Only Material
2. PBR (Physically Based Rendering) Material - the modern standard for material definition
3. Simple Material - traditional lighting model representation

A material can have multiple representations defined simultaneously - color,
simple, and PBR properties can all be specified for the same material.
The appropriate representation will be used based on the export format.
This allows you to define a material once and have it work well across
different export formats. For example:

.. code-block:: python
# Material with all three representations
gold_material = cq.Material(
name="Gold",
description="A golden material with multiple representations",
density=19300, # kg/m³
# Simple color for basic visualization
color=cq.Color(1.0, 0.8, 0.0, 1.0),
# PBR material for modern physically-based rendering
pbr=cq.PbrMaterial(
base_color=cq.Color(1.0, 0.8, 0.0, 1.0),
metallic=1.0,
roughness=0.2,
refraction_index=1.0,
),
# Traditional lighting model
simple=cq.SimpleMaterial(
ambient_color=cq.Color(0.2, 0.2, 0.0, 1.0),
diffuse_color=cq.Color(0.8, 0.8, 0.0, 1.0),
specular_color=cq.Color(1.0, 1.0, 0.0, 1.0),
shininess=0.9,
transparency=0.0,
),
)
Material Types
=============

Color Only Material
~~~~~~~~~~~~~~~~~~~

The simplest form of material definition includes just a name,
description, density, and color:

.. code-block:: python
material = cq.Material(
name="Red Plastic",
description="A simple red plastic material",
density=1200, # kg/m³
color=cq.Color(1.0, 0.0, 0.0, 1.0), # Red with full opacity
)
PBR Material
~~~~~~~~~~~

PBR (Physically Based Rendering) materials provide physically accurate material representation and are the recommended way to define materials in CadQuery:

.. code-block:: python
material = cq.Material(
name="Clear Glass",
description="A transparent glass material",
density=2500, # kg/m³
pbr=cq.PbrMaterial(
base_color=cq.Color(0.9, 0.9, 0.9, 0.3), # Base color with transparency
metallic=0.0, # 0.0 for non-metals, 1.0 for metals
roughness=0.1, # 0.0 for smooth, 1.0 for rough
refraction_index=1.5, # Must be between 1.0 and 3.0
),
)
Simple Material
~~~~~~~~~~~~~

Simple materials use a traditional lighting model with ambient, diffuse, and specular colors.
This representation is useful for compatibility with older visualization systems and file formats.

.. code-block:: python
material = cq.Material(
name="Polished Steel",
description="A shiny metallic material",
density=7850, # kg/m³
simple=cq.SimpleMaterial(
ambient_color=cq.Color(0.2, 0.2, 0.2, 1.0), # Base color in shadow
diffuse_color=cq.Color(0.5, 0.5, 0.5, 1.0), # Main surface color
specular_color=cq.Color(0.8, 0.8, 0.8, 1.0), # Highlight color
shininess=0.8, # Controls highlight size (0.0-1.0)
transparency=0.0, # 0.0 is opaque, 1.0 is transparent
),
)
Export Support
=============

Different export formats support different material properties. The table below shows which material representations are supported by each format:

.. raw:: html

<table style="width: 100%; border-spacing: 0 10px; margin-bottom: 20px;">
<tr style="border-bottom: 2px solid #000;">
<th style="padding: 10px; vertical-align: middle;">Format</th>
<th style="text-align: center; vertical-align: middle;">Color</th>
<th style="text-align: center; vertical-align: middle;">Simple Material</th>
<th style="text-align: center; vertical-align: middle;">PBR Material</th>
</tr>

<tr style="background-color: #f2f2f2;">
<td style="padding: 10px; vertical-align: middle;">STEP</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
<td style="text-align: center; vertical-align: middle;">❌</td>
</tr>

<tr>
<td style="padding: 10px; vertical-align: middle;">VTK</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
</tr>

<tr style="background-color: #f2f2f2;">
<td style="padding: 10px; vertical-align: middle;">VRML</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
<td style="text-align: center; vertical-align: middle;">❌</td>
</tr>

<tr>
<td style="padding: 10px; vertical-align: middle;">GLTF/GLB</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
<td style="text-align: center; vertical-align: middle;">✅</td>
</tr>

<tr style="background-color: #f2f2f2;">
<td style="padding: 10px; vertical-align: middle;">STL</td>
<td style="text-align: center; vertical-align: middle;">❌</td>
<td style="text-align: center; vertical-align: middle;">❌</td>
<td style="text-align: center; vertical-align: middle;">❌</td>
</tr>

</table>

For the best visual appearance, especially with PBR materials, use VTK visualization with an HDR skybox
as demonstrated in Example 028. The skybox provides realistic environment lighting and reflections,
making materials like metals and glass look more realistic.

Predefined Colors
---------------

Aside from RGBA values, the :class:`~cadquery.Color` class can be instantiated from a text name. Valid names are
@@ -1410,3 +1566,4 @@ listed along with a color sample below:
<div style="background-color:rgba(65,65,0,1.0);padding:10px;border-radius:5px;color:rgba(255,255,255);">yellow4</div>
<div style="background-color:rgba(82,155,8,1.0);padding:10px;border-radius:5px;color:rgba(255,255,255);">yellowgreen</div>
</div>

71 changes: 71 additions & 0 deletions examples/Ex027_Materials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import cadquery as cq

# Create a simple cube
cube = cq.Workplane().box(10, 10, 10)

# Define different materials
# 1. Simple color material
red_material = cq.Material(
name="Red Plastic",
description="A simple red plastic material",
density=1200, # kg/m³
color=cq.Color(1.0, 0.0, 0.0, 1.0), # Red with full opacity
)

# 2. Common (legacy) material with traditional properties
metal_material = cq.Material(
name="Polished Steel",
description="A shiny metallic material",
density=7850, # kg/m³
simple=cq.SimpleMaterial(
ambient_color=cq.Color(0.2, 0.2, 0.2, 1.0),
diffuse_color=cq.Color(0.5, 0.5, 0.5, 1.0),
specular_color=cq.Color(0.8, 0.8, 0.8, 1.0),
shininess=0.8, # High shininess for metallic look
transparency=0.0,
),
)

# 3. PBR material with modern properties
glass_material = cq.Material(
name="Clear Glass",
description="A transparent glass material",
density=2500, # kg/m³
pbr=cq.PbrMaterial(
base_color=cq.Color(0.9, 0.9, 0.9, 0.3), # Light gray with transparency
metallic=0.0, # Non-metallic
roughness=0.1, # Very smooth
refraction_index=1.5, # Typical glass refractive index
),
)

# 4. Combined material with both common and PBR properties
gold_material = cq.Material(
name="Gold",
description="A golden material with both traditional and PBR properties",
density=19300, # kg/m³
simple=cq.SimpleMaterial(
ambient_color=cq.Color(0.2, 0.2, 0.0, 1.0),
diffuse_color=cq.Color(0.8, 0.8, 0.0, 1.0),
specular_color=cq.Color(1.0, 1.0, 0.0, 1.0),
shininess=0.9,
transparency=0.0,
),
pbr=cq.PbrMaterial(
base_color=cq.Color(1.0, 0.8, 0.0, 1.0), # Gold color
metallic=1.0, # Fully metallic
roughness=0.2, # Slightly rough
refraction_index=1.0, # Minimum valid refractive index for metals
),
)

# Create an assembly with different materials
assy = cq.Assembly()
assy.add(cube, name="red_cube", material=red_material)
assy.add(cube.translate((15, 0, 0)), name="metal_cube", material=metal_material)
assy.add(cube.translate((30, 0, 0)), name="glass_cube", material=glass_material)
assy.add(cube.translate((45, 0, 0)), name="gold_cube", material=gold_material)


# Show the assembly in the UI
show_object(assy)
177 changes: 177 additions & 0 deletions examples/Ex028_VTK.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
Example 028 - VTK Visualization with Materials and Environment Mapping
This example demonstrates how to:
1. Create 3D objects with different materials (simple color, common material, PBR material)
2. Set up a VTK visualization with environment mapping
3. Use HDR textures for realistic lighting and reflections
4. Configure camera and rendering settings
The example creates three objects:
- A red box with a simple color material
- A gold cylinder with common material properties (ambient, diffuse, specular)
- A chrome sphere with PBR (Physically Based Rendering) material properties
The scene is rendered with an HDR environment map that provides realistic lighting
and reflections on the materials.
Note: Emission support will be added in a future version with proper texture support.
"""

from pathlib import Path
from cadquery.occ_impl.assembly import toVTK
from vtkmodules.vtkRenderingCore import (
vtkRenderWindow,
vtkRenderWindowInteractor,
vtkTexture,
)
from vtkmodules.vtkIOImage import vtkHDRReader
from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera
from vtkmodules.vtkRenderingOpenGL2 import vtkOpenGLSkybox
import cadquery as cq


# Create basic shapes for our example
red_box = cq.Workplane().box(10, 10, 10) # Create a 10x10x10 box
gold_cylinder = cq.Workplane().cylinder(
20, 5
) # Create a cylinder with radius 5 and height 20
chrome_sphere = cq.Workplane().sphere(8) # Create a sphere with radius 8

# Create a hexagonal prism
glass_hex = (
cq.Workplane("XY")
.polygon(6, 15) # Create a hexagon with radius 15
.extrude(10) # Extrude 10 units in Z direction
)

# Create an assembly to hold our objects
assy = cq.Assembly(name="material_test")

# Add a red box with a simple color material
# This demonstrates the most basic material type
assy.add(
red_box,
name="red_box",
loc=cq.Location((-60, 0, 0)), # Position the box to the left
material=cq.Material(
name="Red",
description="Simple red material",
density=1000.0,
color=cq.Color(1, 0, 0, 1), # Pure red with full opacity
),
)

# Add a gold cylinder with common material properties
# This demonstrates traditional material properties (ambient, diffuse, specular)
assy.add(
gold_cylinder,
name="gold_cylinder",
loc=cq.Location((-20, 0, 0)), # Position the cylinder to the left of center
material=cq.Material(
name="Gold",
description="Metallic gold material",
density=19320.0, # Actual density of gold in kg/m³
simple=cq.SimpleMaterial(
ambient_color=cq.Color(0.24, 0.2, 0.07), # Dark gold ambient color
diffuse_color=cq.Color(0.75, 0.6, 0.22), # Gold diffuse color
specular_color=cq.Color(0.63, 0.56, 0.37), # Light gold specular color
shininess=0.8, # High shininess for metallic look
transparency=0.0, # Fully opaque
),
),
)

# Add a chrome sphere with PBR material properties
# This demonstrates modern physically based rendering materials
assy.add(
chrome_sphere,
name="chrome_sphere",
loc=cq.Location((20, 0, 0)), # Position the sphere to the right of center
material=cq.Material(
name="Chrome",
description="Polished chrome material",
density=7190.0, # Density of chrome in kg/m³
pbr=cq.PbrMaterial(
base_color=cq.Color(0.8, 0.8, 0.8), # Light gray base color
metallic=1.0, # Fully metallic
roughness=0.1, # Very smooth surface
refraction_index=2.4, # High refraction index for chrome
),
),
)

# Add a glass hexagonal prism with PBR material properties
# This demonstrates transparent materials with PBR
assy.add(
glass_hex,
name="glass_hex",
loc=cq.Location((60, 0, 0)), # Position the hexagon to the right
material=cq.Material(
name="Glass",
description="Clear glass material",
density=2500.0, # Density of glass in kg/m³
pbr=cq.PbrMaterial(
base_color=cq.Color(0.9, 0.9, 0.9, 0.1), # Light gray with transparency
metallic=0, # Non-metallic
roughness=0.1, # Smooth surface
refraction_index=2, # Typical glass refraction index
),
),
)


# Convert the assembly to VTK format for visualization
renderer = toVTK(assy, edges=False)

# Set up the render window
render_window = vtkRenderWindow()
render_window.SetSize(1920, 1080) # Set to Full HD resolution
render_window.AddRenderer(renderer)

# Load the HDR texture for environment mapping
reader = vtkHDRReader()
reader.SetFileName(Path(__file__).parent / "golden_gate_hills_1k.hdr")
reader.Update()

# Create and configure the texture
texture = vtkTexture()
texture.SetColorModeToDirectScalars() # Use HDR values directly
texture.SetInputConnection(reader.GetOutputPort())
texture.MipmapOn() # Enable mipmapping for better quality
texture.InterpolateOn() # Enable texture interpolation
texture.SetRepeat(False) # Prevent texture repetition
texture.SetEdgeClamp(True) # Clamp texture edges

# Create a skybox using the HDR texture
skybox = vtkOpenGLSkybox()
skybox.SetTexture(texture)
skybox.SetProjectionToCube() # Use cube map projection
renderer.AddActor(skybox)

# Set up PBR environment lighting
renderer.UseImageBasedLightingOn() # Enable image-based lighting
renderer.SetEnvironmentTexture(texture) # Use HDR texture for lighting
renderer.UseSphericalHarmonicsOn() # Use spherical harmonics for better performance

# Set up the interactor for user interaction
interactor = vtkRenderWindowInteractor()
interactor.SetRenderWindow(render_window)

# Configure the renderer and camera
renderer = render_window.GetRenderers().GetFirstRenderer()
renderer.SetBackground(0.2, 0.3, 0.4) # Set dark blue-gray background
camera = renderer.GetActiveCamera()
camera.SetPosition(0, -10, 200) # Position camera above the scene
camera.SetFocalPoint(0, 0, 0) # Look at the center of the scene
camera.SetViewUp(0, 1, 0) # Set Y axis as up to see horizon
camera.SetViewAngle(30) # Set field of view

# Set up trackball camera interaction style
interactor_style = vtkInteractorStyleTrackballCamera()
interactor.SetInteractorStyle(interactor_style)

if __name__ == "__main__":
# Start the visualization
interactor.Initialize()
interactor.Start()
Binary file added examples/golden_gate_hills_1k.hdr
Binary file not shown.
23 changes: 0 additions & 23 deletions tests/test_assembly.py
Original file line number Diff line number Diff line change
@@ -537,29 +537,6 @@ def solve_result_check(solve_result: dict) -> bool:
return all(checks)


def test_color():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need to remove a test, something is really wrong with the PR. I think you made to many backward incompatible changes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test was not removed, just moved here:

def test_occt_conversion(self):

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert the removal.


c1 = cq.Color("red")
assert c1.wrapped.GetRGB().Red() == 1
assert c1.wrapped.Alpha() == 1

c2 = cq.Color(1, 0, 0)
assert c2.wrapped.GetRGB().Red() == 1
assert c2.wrapped.Alpha() == 1

c3 = cq.Color(1, 0, 0, 0.5)
assert c3.wrapped.GetRGB().Red() == 1
assert c3.wrapped.Alpha() == 0.5

c4 = cq.Color()

with pytest.raises(ValueError):
cq.Color("?????")

with pytest.raises(ValueError):
cq.Color(1, 2, 3, 4, 5)


def test_assembly(simple_assy, nested_assy):

# basic checks
3 changes: 3 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
@@ -20,6 +20,9 @@ def find_examples(pattern="examples/*.py", path=Path("examples")):
with open(p, encoding="UTF-8") as f:
code = f.read()

# Inject __file__ for assets etc.
code = f"""__file__ = "{Path(p).absolute()}"\n""" + code

yield code, path


658 changes: 658 additions & 0 deletions tests/test_materials.py

Large diffs are not rendered by default.