diff --git a/.gitignore b/.gitignore index 41ad64e42..7c1bc9c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out1.3mf out2.3mf out3.3mf orig.dxf +box.brep +sketch.dxf +material_test* \ No newline at end of file diff --git a/cadquery/__init__.py b/cadquery/__init__.py index 0678377a6..aa95949c4 100644 --- a/cadquery/__init__.py +++ b/cadquery/__init__.py @@ -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", diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 1aac2cb3d..810fe6d8f 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -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: """ diff --git a/cadquery/cq_directive.py b/cadquery/cq_directive.py index 0368a0eba..bbb04fb06 100644 --- a/cadquery/cq_directive.py +++ b/cadquery/cq_directive.py @@ -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 diff --git a/cadquery/materials.py b/cadquery/materials.py new file mode 100644 index 000000000..2e2729c22 --- /dev/null +++ b/cadquery/materials.py @@ -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: + """ + 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 diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 4ef486e26..bce11ae12 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -4,7 +4,6 @@ Iterator, Tuple, Dict, - overload, Optional, Any, List, @@ -14,127 +13,49 @@ from math import degrees, radians from OCP.TDocStd import TDocStd_Document -from OCP.TCollection import TCollection_ExtendedString -from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorType, XCAFDoc_ColorGen +from OCP.TCollection import ( + TCollection_ExtendedString, + TCollection_AsciiString, +) +from OCP.XCAFDoc import ( + XCAFDoc_DocumentTool, + XCAFDoc_ColorType, + XCAFDoc_ColorGen, +) from OCP.XCAFApp import XCAFApp_Application from OCP.TDataStd import TDataStd_Name from OCP.TDF import TDF_Label from OCP.TopLoc import TopLoc_Location -from OCP.Quantity import ( - Quantity_ColorRGBA, - Quantity_Color, - Quantity_TOC_sRGB, -) from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse from OCP.TopTools import TopTools_ListOfShape from OCP.BOPAlgo import BOPAlgo_GlueEnum, BOPAlgo_MakeConnected from OCP.TopoDS import TopoDS_Shape from OCP.gp import gp_EulerSequence +from vtkmodules.util.vtkConstants import VTK_LINE, VTK_VERTEX from vtkmodules.vtkRenderingCore import ( vtkActor, vtkPolyDataMapper as vtkMapper, vtkRenderer, vtkAssembly, ) - from vtkmodules.vtkFiltersExtraction import vtkExtractCellsByType -from vtkmodules.vtkCommonDataModel import VTK_TRIANGLE, VTK_LINE, VTK_VERTEX +from vtkmodules.vtkCommonDataModel import VTK_TRIANGLE from .geom import Location from .shapes import Shape, Solid, Compound from .exporters.vtk import toString from ..cq import Workplane +from ..materials import Material, Color + +# Default colors +DEFAULT_COLOR = Color(1.0, 1.0, 1.0, 1.0) # White +DEFAULT_EDGE_COLOR = Color(0.0, 0.0, 0.0, 1.0) # Black # type definitions AssemblyObjects = Union[Shape, Workplane, None] -class Color(object): - """ - Wrapper for the OCCT color object Quantity_ColorRGBA. - """ - - wrapped: Quantity_ColorRGBA - - @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, r: float, g: float, b: float, a: float = 0): - """ - Construct a Color from RGB(A) values. - - :param r: red value, 0-1 - :param g: green value, 0-1 - :param b: blue value, 0-1 - :param a: alpha value, 0-1 (default: 0) - """ - ... - - @overload - def __init__(self): - """ - Construct a Color with default value. - """ - ... - - def __init__(self, *args, **kwargs): - - if len(args) == 0: - self.wrapped = Quantity_ColorRGBA() - elif len(args) == 1: - self.wrapped = Quantity_ColorRGBA() - exists = Quantity_ColorRGBA.ColorFromName_s(args[0], self.wrapped) - if not exists: - raise ValueError(f"Unknown color name: {args[0]}") - elif len(args) == 3: - r, g, b = args - self.wrapped = Quantity_ColorRGBA( - Quantity_Color(r, g, b, Quantity_TOC_sRGB), 1 - ) - if kwargs.get("a"): - self.wrapped.SetAlpha(kwargs.get("a")) - elif len(args) == 4: - r, g, b, a = args - self.wrapped = Quantity_ColorRGBA( - Quantity_Color(r, g, b, Quantity_TOC_sRGB), a - ) - else: - raise ValueError(f"Unsupported arguments: {args}, {kwargs}") - - def __hash__(self): - - return hash(self.toTuple()) - - def __eq__(self, other): - - return self.toTuple() == other.toTuple() - - def toTuple(self) -> Tuple[float, float, float, float]: - """ - Convert Color to RGB tuple. - """ - a = self.wrapped.Alpha() - rgb = self.wrapped.GetRGB().Values(Quantity_TOC_sRGB) - - return (*rgb, a) - - def __getstate__(self) -> Tuple[float, float, float, float]: - - return self.toTuple() - - def __setstate__(self, data: Tuple[float, float, float, float]): - - self.wrapped = Quantity_ColorRGBA(*data) - - class AssemblyProtocol(Protocol): @property def loc(self) -> Location: @@ -156,6 +77,10 @@ def parent(self) -> Optional["AssemblyProtocol"]: def color(self) -> Optional[Color]: ... + @property + def material(self) -> Optional[Material]: + ... + @property def obj(self) -> AssemblyObjects: ... @@ -188,18 +113,19 @@ 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]]]: ... def setName(l: TDF_Label, name: str, tool): - + """Set the name of a label in the document.""" TDataStd_Name.Set_s(l, TCollection_ExtendedString(name)) def setColor(l: TDF_Label, color: Color, tool): - - tool.SetColor(l, color.wrapped, XCAFDoc_ColorType.XCAFDoc_ColorSurf) + """Set the color of a label in the document.""" + tool.SetColor(l, color.toQuantityColorRGBA(), XCAFDoc_ColorType.XCAFDoc_ColorSurf) def toCAF( @@ -219,25 +145,30 @@ def toCAF( tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) tool.SetAutoNaming_s(False) ctool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) + mtool = XCAFDoc_DocumentTool.MaterialTool_s(doc.Main()) + vmtool = XCAFDoc_DocumentTool.VisMaterialTool_s(doc.Main()) - # used to store labels with unique part-color combinations - unique_objs: Dict[Tuple[Color, AssemblyObjects], TDF_Label] = {} + # used to store labels with unique part-color-material combinations + unique_objs: Dict[ + Tuple[Optional[Color], Optional[Material], AssemblyObjects], TDF_Label + ] = {} # used to cache unique, possibly meshed, compounds; allows to avoid redundant meshing operations if same object is referenced multiple times in an assy compounds: Dict[AssemblyObjects, Compound] = {} - def _toCAF(el, ancestor, color) -> TDF_Label: + def _toCAF(el, ancestor, color, material) -> TDF_Label: # create a subassy subassy = tool.NewShape() setName(subassy, el.name, tool) - # define the current color + # define the current color and material current_color = el.color if el.color else color + current_material: Optional[Material] = el.material if el.material else material # add a leaf with the actual part if needed if el.obj: # get/register unique parts referenced in the assy - key0 = (current_color, el.obj) # (color, shape) + key0 = (current_color, current_material, el.obj) # (color, material, shape) key1 = el.obj # shape if key0 in unique_objs: @@ -261,6 +192,38 @@ def _toCAF(el, ancestor, color) -> TDF_Label: if coloredSTEP and current_color: setColor(lab, current_color, ctool) + # handle materials + if current_material: + # Assign color directly to the shape + if current_material.color: + ctool.SetColor( + lab, + current_material.color.toQuantityColorRGBA(), + XCAFDoc_ColorType.XCAFDoc_ColorSurf, + ) + + # Convert material to OCCT format and add to document + mat, vis_mat = ( + current_material.toXCAFDocMaterial(), + current_material.toXCAFDocVisMaterial(), + ) + + # Create material label + mat_lab = mtool.AddMaterial( + mat.GetName(), + mat.GetDescription(), + mat.GetDensity(), + mat.GetDensName(), + mat.GetDensValType(), + ) + mtool.SetMaterial(lab, mat_lab) + + # Add visualization material to the document + vis_mat_lab = vmtool.AddMaterial( + vis_mat, TCollection_AsciiString(current_material.name) + ) + vmtool.SetShapeMaterial(lab, vis_mat_lab) + tool.AddComponent(subassy, lab, TopLoc_Location()) # handle colors when *not* exporting to STEP @@ -269,7 +232,7 @@ def _toCAF(el, ancestor, color) -> TDF_Label: # add children recursively for child in el.children: - _toCAF(child, subassy, current_color) + _toCAF(child, subassy, current_color, current_material) if ancestor: tool.AddComponent(ancestor, subassy, el.loc.wrapped) @@ -283,7 +246,7 @@ def _toCAF(el, ancestor, color) -> TDF_Label: return rv # process the whole assy recursively - top = _toCAF(assy, None, None) + top = _toCAF(assy, None, None, None) tool.UpdateAssemblies() @@ -309,8 +272,8 @@ def _loc2vtk( def toVTKAssy( assy: AssemblyProtocol, - color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), - edgecolor: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), + color: Color = DEFAULT_COLOR, + edgecolor: Color = DEFAULT_EDGE_COLOR, edges: bool = True, linewidth: float = 2, tolerance: float = 1e-3, @@ -319,35 +282,30 @@ def toVTKAssy( rv = vtkAssembly() - for shape, _, loc, col_ in assy: - - col = col_.toTuple() if col_ else color - + for shape, _, loc, col, material in assy: trans, rot = _loc2vtk(loc) data = shape.toVtkPolyData(tolerance, angularTolerance) - # extract faces + # Extract edges (lines and vertices) extr = vtkExtractCellsByType() extr.SetInputDataObject(data) - extr.AddCellType(VTK_LINE) extr.AddCellType(VTK_VERTEX) extr.Update() data_edges = extr.GetOutput() - # extract edges + # Extract faces (triangles) extr = vtkExtractCellsByType() extr.SetInputDataObject(data) - extr.AddCellType(VTK_TRIANGLE) extr.Update() data_faces = extr.GetOutput() - # remove normals from edges + # Remove normals from edges since they're not needed data_edges.GetPointData().RemoveArray("Normals") - # add both to the vtkAssy + # Create actor for faces with material/color mapper = vtkMapper() mapper.AddInputDataObject(data_faces) @@ -355,11 +313,23 @@ def toVTKAssy( actor.SetMapper(mapper) actor.SetPosition(*trans) actor.SetOrientation(*rot) - actor.GetProperty().SetColor(*col[:3]) - actor.GetProperty().SetOpacity(col[3]) + + # Apply material or color + if material: + material.applyToVtkActor(actor) + else: + # Apply color directly + use_color = col if col else color + prop = actor.GetProperty() + prop.SetColor(*use_color.rgb()) + prop.SetOpacity(use_color.alpha) rv.AddPart(actor) + if not edges: + continue + + # Create actor for edges mapper = vtkMapper() mapper.AddInputDataObject(data_edges) @@ -368,9 +338,11 @@ def toVTKAssy( actor.SetPosition(*trans) actor.SetOrientation(*rot) actor.GetProperty().SetLineWidth(linewidth) - actor.SetVisibility(edges) - actor.GetProperty().SetColor(*edgecolor[:3]) - actor.GetProperty().SetLineWidth(edgecolor[3]) + + # Apply edge color directly + prop = actor.GetProperty() + prop.SetColor(*edgecolor.rgb()) + prop.SetOpacity(edgecolor.alpha) rv.AddPart(actor) @@ -379,42 +351,39 @@ def toVTKAssy( def toVTK( assy: AssemblyProtocol, - color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), + color: Color = DEFAULT_COLOR, tolerance: float = 1e-3, angularTolerance: float = 0.1, + *, + edges: bool = True, ) -> vtkRenderer: renderer = vtkRenderer() - for shape, _, loc, col_ in assy: - - col = col_.toTuple() if col_ else color - + for shape, _, loc, col, material in assy: trans, rot = _loc2vtk(loc) data = shape.toVtkPolyData(tolerance, angularTolerance) - # extract faces + # Extract edges (lines and vertices) extr = vtkExtractCellsByType() extr.SetInputDataObject(data) - extr.AddCellType(VTK_LINE) extr.AddCellType(VTK_VERTEX) extr.Update() data_edges = extr.GetOutput() - # extract edges + # Extract faces (triangles) extr = vtkExtractCellsByType() extr.SetInputDataObject(data) - extr.AddCellType(VTK_TRIANGLE) extr.Update() data_faces = extr.GetOutput() - # remove normals from edges + # Remove normals from edges since they're not needed data_edges.GetPointData().RemoveArray("Normals") - # add both to the renderer + # Create actor for faces with material/color mapper = vtkMapper() mapper.AddInputDataObject(data_faces) @@ -422,11 +391,23 @@ def toVTK( actor.SetMapper(mapper) actor.SetPosition(*trans) actor.SetOrientation(*rot) - actor.GetProperty().SetColor(*col[:3]) - actor.GetProperty().SetOpacity(col[3]) + + # Apply material or color + if material: + material.applyToVtkActor(actor) + else: + # Apply color directly + use_color = col if col else color + prop = actor.GetProperty() + prop.SetColor(*use_color.rgb()) + prop.SetOpacity(use_color.alpha) renderer.AddActor(actor) + if not edges: + continue + + # Create actor for edges mapper = vtkMapper() mapper.AddInputDataObject(data_edges) @@ -441,9 +422,7 @@ def toVTK( def toJSON( - assy: AssemblyProtocol, - color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), - tolerance: float = 1e-3, + assy: AssemblyProtocol, color: Color = DEFAULT_COLOR, tolerance: float = 1e-3, ) -> List[Dict[str, Any]]: """ Export an object to a structure suitable for converting to VTK.js JSON. @@ -451,18 +430,32 @@ def toJSON( rv = [] - for shape, _, loc, col_ in assy: - + for shape, _, loc, col, material in assy: val: Any = {} data = toString(shape, tolerance) trans, rot = loc.toTuple() val["shape"] = data - val["color"] = col_.toTuple() if col_ else color + val["color"] = col.toTuple() if col else color.toTuple() val["position"] = trans val["orientation"] = tuple(radians(r) for r in rot) + # Add material properties if available + if material: + val["material"] = { + "name": material.name, + "description": material.description, + "density": material.density, + } + if material.pbr: + val["material"]["pbr"] = { + "base_color": material.pbr.base_color.toTuple(), + "metallic": material.pbr.metallic, + "roughness": material.pbr.roughness, + "refraction_index": material.pbr.refraction_index, + } + rv.append(val) return rv @@ -498,11 +491,11 @@ def toFusedCAF( # Walk the entire assembly, collecting the located shapes and colors shapes: List[Shape] = [] - colors = [] + colors: List[Optional[Color]] = [] - for shape, _, loc, color in assy: + for shape, _, loc, col, _ in assy: shapes.append(shape.moved(loc).copy()) - colors.append(color) + colors.append(col) # Initialize with a dummy value for mypy top_level_shape = cast(TopoDS_Shape, None) @@ -549,7 +542,9 @@ def toFusedCAF( # See if the face can be treated as-is cur_lbl = shape_tool.AddSubShape(top_level_lbl, face.wrapped) if color and not cur_lbl.IsNull() and not fuse_op.IsDeleted(face.wrapped): - color_tool.SetColor(cur_lbl, color.wrapped, XCAFDoc_ColorGen) + color_tool.SetColor( + cur_lbl, color.toQuantityColorRGBA(), XCAFDoc_ColorGen + ) # Handle any modified faces modded_list = fuse_op.Modified(face.wrapped) @@ -558,7 +553,9 @@ def toFusedCAF( # Add the face as a subshape and set its color to match the parent assembly component cur_lbl = shape_tool.AddSubShape(top_level_lbl, mod) if color and not cur_lbl.IsNull() and not fuse_op.IsDeleted(mod): - color_tool.SetColor(cur_lbl, color.wrapped, XCAFDoc_ColorGen) + color_tool.SetColor( + cur_lbl, color.toQuantityColorRGBA(), XCAFDoc_ColorGen + ) # Handle any generated faces gen_list = fuse_op.Generated(face.wrapped) @@ -567,7 +564,9 @@ def toFusedCAF( # Add the face as a subshape and set its color to match the parent assembly component cur_lbl = shape_tool.AddSubShape(top_level_lbl, gen) if color and not cur_lbl.IsNull(): - color_tool.SetColor(cur_lbl, color.wrapped, XCAFDoc_ColorGen) + color_tool.SetColor( + cur_lbl, color.toQuantityColorRGBA(), XCAFDoc_ColorGen + ) return top_level_lbl, doc @@ -580,8 +579,8 @@ def imprint(assy: AssemblyProtocol) -> Tuple[Shape, Dict[Shape, Tuple[str, ...]] # make the id map id_map = {} - for obj, name, loc, _ in assy: - for s in obj.moved(loc).Solids(): + for shape, name, loc, _, _ in assy: + for s in shape.moved(loc).Solids(): id_map[s] = name # connect topologically diff --git a/cadquery/occ_impl/exporters/assembly.py b/cadquery/occ_impl/exporters/assembly.py index 328d8c04a..bace3ead2 100644 --- a/cadquery/occ_impl/exporters/assembly.py +++ b/cadquery/occ_impl/exporters/assembly.py @@ -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 diff --git a/cadquery/occ_impl/jupyter_tools.py b/cadquery/occ_impl/jupyter_tools.py index 2f5edb95d..4fb9ebad3 100644 --- a/cadquery/occ_impl/jupyter_tools.py +++ b/cadquery/occ_impl/jupyter_tools.py @@ -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], ) diff --git a/cadquery/vis.py b/cadquery/vis.py index 5fbedb12f..4a2076cd2 100644 --- a/cadquery/vis.py +++ b/cadquery/vis.py @@ -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() diff --git a/doc/apireference.rst b/doc/apireference.rst index 93f39442c..623d7cca7 100644 --- a/doc/apireference.rst +++ b/doc/apireference.rst @@ -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 diff --git a/doc/assy.rst b/doc/assy.rst index 0c8d30dd5..154028038 100644 --- a/doc/assy.rst +++ b/doc/assy.rst @@ -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 + +
Format | +Color | +Simple Material | +PBR Material | +
---|---|---|---|
STEP | +✅ | +✅ | +❌ | +
VTK | +✅ | +✅ | +✅ | +
VRML | +✅ | +✅ | +❌ | +
GLTF/GLB | +✅ | +✅ | +✅ | +
STL | +❌ | +❌ | +❌ | +