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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FormatColorSimple MaterialPBR Material
STEP
VTK
VRML
GLTF/GLB
STL
+ +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:
yellow4
yellowgreen
+ diff --git a/examples/Ex027_Materials.py b/examples/Ex027_Materials.py new file mode 100644 index 000000000..344fe5cd1 --- /dev/null +++ b/examples/Ex027_Materials.py @@ -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) diff --git a/examples/Ex028_VTK.py b/examples/Ex028_VTK.py new file mode 100644 index 000000000..afe1ece5f --- /dev/null +++ b/examples/Ex028_VTK.py @@ -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() diff --git a/examples/golden_gate_hills_1k.hdr b/examples/golden_gate_hills_1k.hdr new file mode 100644 index 000000000..08ae0bbec Binary files /dev/null and b/examples/golden_gate_hills_1k.hdr differ diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 75acac7b1..21e647138 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -537,29 +537,6 @@ def solve_result_check(solve_result: dict) -> bool: return all(checks) -def test_color(): - - 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 diff --git a/tests/test_examples.py b/tests/test_examples.py index 6f022484e..650f70a4e 100755 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -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 diff --git a/tests/test_materials.py b/tests/test_materials.py new file mode 100644 index 000000000..4a0168986 --- /dev/null +++ b/tests/test_materials.py @@ -0,0 +1,658 @@ +import re +import pytest +from cadquery.materials import Color, SimpleMaterial, PbrMaterial, Material +import cadquery as cq +import os +import json +from tests.test_assembly import read_step, get_doc_nodes +from cadquery.occ_impl.exporters.assembly import exportStepMeta, _vtkRenderWindow +from cadquery.occ_impl.assembly import toJSON, toVTKAssy +from tempfile import TemporaryDirectory +from shutil import make_archive +from vtkmodules.vtkIOExport import vtkJSONSceneExporter + + +def approx_equal_tuples(tuple1, tuple2, rel=1e-6, abs=1e-12): + """Compare two tuples of floats for approximate equality. + + Args: + tuple1: First tuple of floats + tuple2: Second tuple of floats + rel: Relative tolerance (default: 1e-6) + abs: Absolute tolerance (default: 1e-12) + + Returns: + bool: True if tuples are approximately equal + """ + if len(tuple1) != len(tuple2): + return False + return all( + pytest.approx(v1, rel=rel, abs=abs) == v2 for v1, v2 in zip(tuple1, tuple2) + ) + + +class TestColor: + def test_default_constructor(self): + color = Color() + assert color.red == 1.0 + assert color.green == 1.0 + assert color.blue == 0.0 + assert color.alpha == 1.0 + + def test_rgb_constructor(self): + color = Color(0.1, 0.2, 0.3) + assert color.red == 0.1 + assert color.green == 0.2 + assert color.blue == 0.3 + assert color.alpha == 1.0 + + def test_rgba_constructor(self): + color = Color(0.1, 0.2, 0.3, 0.4) + assert color.red == 0.1 + assert color.green == 0.2 + assert color.blue == 0.3 + assert color.alpha == 0.4 + + def test_kwargs_constructor(self): + color = Color(red=0.1, green=0.2, blue=0.3, alpha=0.4) + assert color.red == 0.1 + assert color.green == 0.2 + assert color.blue == 0.3 + assert color.alpha == 0.4 + + def test_invalid_values(self): + with pytest.raises(ValueError): + Color(1.5, 0.2, 0.3) # r > 1.0 + with pytest.raises(ValueError): + Color(0.1, -0.2, 0.3) # g < 0.0 + with pytest.raises(ValueError): + Color(0.1, 0.2, 0.3, 1.5) # a > 1.0 + + def test_rgb_method(self): + color = Color(0.1, 0.2, 0.3) + assert color.rgb() == (0.1, 0.2, 0.3) + + def test_rgba_method(self): + color = Color(0.1, 0.2, 0.3, 0.4) + assert color.rgba() == (0.1, 0.2, 0.3, 0.4) + + def test_to_tuple(self): + color = Color(0.1, 0.2, 0.3, 0.4) + assert color.toTuple() == (0.1, 0.2, 0.3, 0.4) + + def test_equality(self): + color1 = Color(0.1, 0.2, 0.3, 0.4) + color2 = Color(0.1, 0.2, 0.3, 0.4) + color3 = Color(0.2, 0.2, 0.3, 0.4) + assert color1 == color2 + assert color1 != color3 + assert color1 != "not a color" + + def test_hash(self): + color1 = Color(0.1, 0.2, 0.3, 0.4) + color2 = Color(0.1, 0.2, 0.3, 0.4) + color3 = Color(0.2, 0.2, 0.3, 0.4) + assert hash(color1) == hash(color2) + assert hash(color1) != hash(color3) + + def test_repr(self): + color = Color(0.1, 0.2, 0.3, 0.4) + assert repr(color) == "Color(r=0.1, g=0.2, b=0.3, a=0.4)" + + def test_str(self): + color = Color(0.1, 0.2, 0.3, 0.4) + assert str(color) == "(0.1, 0.2, 0.3, 0.4)" + + def test_occt_conversion(self): + c1 = cq.Color("red") + occt_c1 = c1.toQuantityColorRGBA() + assert occt_c1.GetRGB().Red() == 1 + assert occt_c1.Alpha() == 1 + + c2 = cq.Color(1, 0, 0) + occt_c2 = c2.toQuantityColorRGBA() + assert occt_c2.GetRGB().Red() == 1 + assert occt_c2.Alpha() == 1 + + c3 = cq.Color(1, 0, 0, 0.5) + occt_c3 = c3.toQuantityColorRGBA() + assert occt_c3.GetRGB().Red() == 1 + assert occt_c3.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_invalid_kwargs(self): + with pytest.raises(TypeError): + Color(red=0.1, green=0.2, blue=0.3, alpha=0.4, unknown_kwarg=1) + + def test_too_many_args(self): + with pytest.raises(ValueError): + Color(0.1, 0.2, 0.3, 0.4, 0.5) + + +class TestCommonMaterial: + @pytest.fixture + def default_colors(self): + return { + "ambient": Color(0.1, 0.1, 0.1), + "diffuse": Color(0.2, 0.2, 0.2), + "specular": Color(0.3, 0.3, 0.3), + } + + def test_valid_construction(self, default_colors): + material = SimpleMaterial( + ambient_color=default_colors["ambient"], + diffuse_color=default_colors["diffuse"], + specular_color=default_colors["specular"], + shininess=0.5, + transparency=0.2, + ) + assert material.shininess == 0.5 + assert material.transparency == 0.2 + + def test_invalid_shininess(self, default_colors): + with pytest.raises(ValueError): + SimpleMaterial( + ambient_color=default_colors["ambient"], + diffuse_color=default_colors["diffuse"], + specular_color=default_colors["specular"], + shininess=1.5, # Invalid: > 1.0 + transparency=0.2, + ) + + def test_invalid_transparency(self, default_colors): + with pytest.raises(ValueError): + SimpleMaterial( + ambient_color=default_colors["ambient"], + diffuse_color=default_colors["diffuse"], + specular_color=default_colors["specular"], + shininess=0.5, + transparency=-0.1, # Invalid: < 0.0 + ) + + def test_equality(self, default_colors): + mat1 = SimpleMaterial( + ambient_color=default_colors["ambient"], + diffuse_color=default_colors["diffuse"], + specular_color=default_colors["specular"], + shininess=0.5, + transparency=0.2, + ) + mat2 = SimpleMaterial( + ambient_color=default_colors["ambient"], + diffuse_color=default_colors["diffuse"], + specular_color=default_colors["specular"], + shininess=0.5, + transparency=0.2, + ) + assert mat1 == mat2 + assert mat1 != "not a material" + + def test_hash(self, default_colors): + mat1 = SimpleMaterial( + ambient_color=default_colors["ambient"], + diffuse_color=default_colors["diffuse"], + specular_color=default_colors["specular"], + shininess=0.5, + transparency=0.2, + ) + mat2 = SimpleMaterial( + ambient_color=default_colors["ambient"], + diffuse_color=default_colors["diffuse"], + specular_color=default_colors["specular"], + shininess=0.5, + transparency=0.2, + ) + mat3 = SimpleMaterial( + ambient_color=default_colors["ambient"], + diffuse_color=default_colors["diffuse"], + specular_color=default_colors["specular"], + shininess=0.6, # Different shininess + transparency=0.2, + ) + assert hash(mat1) == hash(mat2) + assert hash(mat1) != hash(mat3) + + +class TestPbrMaterial: + def test_valid_construction(self): + material = PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.6, + refraction_index=1.5, + ) + assert material.metallic == 0.5 + assert material.roughness == 0.6 + assert material.refraction_index == 1.5 + + def test_invalid_metallic(self): + with pytest.raises(ValueError): + PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=1.5, # Invalid: > 1.0 + roughness=0.6, + refraction_index=1.5, + ) + + def test_invalid_roughness(self): + with pytest.raises(ValueError): + PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=-0.1, # Invalid: < 0.0 + refraction_index=1.5, + ) + + def test_invalid_refraction_index(self): + with pytest.raises(ValueError): + PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.6, + refraction_index=3.5, # Invalid: > 3.0 + ) + + def test_equality(self): + mat1 = PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.6, + refraction_index=1.5, + ) + mat2 = PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.6, + refraction_index=1.5, + ) + assert mat1 == mat2 + assert mat1 != "not a material" + + def test_hash(self): + mat1 = PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.6, + refraction_index=1.5, + ) + mat2 = PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.6, + refraction_index=1.5, + ) + mat3 = PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.7, # Different roughness + refraction_index=1.5, + ) + assert hash(mat1) == hash(mat2) + assert hash(mat1) != hash(mat3) + + +class TestMaterial: + def test_color_only(self): + material = Material( + name="test", + description="test material", + density=1000.0, + color=Color(0.1, 0.2, 0.3), + ) + assert material.name == "test" + assert material.description == "test material" + assert material.density == 1000.0 + assert material.color is not None + assert material.simple is None + assert material.pbr is None + + def test_common_only(self): + material = Material( + name="test", + description="test material", + density=1000.0, + simple=SimpleMaterial( + ambient_color=Color(0.1, 0.1, 0.1), + diffuse_color=Color(0.2, 0.2, 0.2), + specular_color=Color(0.3, 0.3, 0.3), + shininess=0.5, + transparency=0.2, + ), + ) + assert material.color is None + assert material.simple is not None + assert material.pbr is None + + def test_pbr_only(self): + material = Material( + name="test", + description="test material", + density=1000.0, + pbr=PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.6, + refraction_index=1.5, + ), + ) + assert material.color is None + assert material.simple is None + assert material.pbr is not None + + def test_no_representation(self): + with pytest.raises(ValueError): + Material( + name="test", description="test material", density=1000.0, + ) + + def test_equality(self): + mat1 = Material( + name="test", + description="test material", + density=1000.0, + color=Color(0.1, 0.2, 0.3), + ) + mat2 = Material( + name="test", + description="test material", + density=1000.0, + color=Color(0.1, 0.2, 0.3), + ) + assert mat1 == mat2 + assert mat1 != "not a material" + + def test_hash(self): + mat1 = Material( + name="test", + description="test material", + density=1000.0, + color=Color(0.1, 0.2, 0.3), + ) + mat2 = Material( + name="test", + description="test material", + density=1000.0, + color=Color(0.1, 0.2, 0.3), + ) + mat3 = Material( + name="test", + description="test material", + density=1000.0, + color=Color(0.2, 0.2, 0.3), # Different color + ) + assert hash(mat1) == hash(mat2) + assert hash(mat1) != hash(mat3) + + # Test hash with different material types + mat4 = Material( + name="test", + description="test material", + density=1000.0, + simple=SimpleMaterial( + ambient_color=Color(0.1, 0.1, 0.1), + diffuse_color=Color(0.2, 0.2, 0.2), + specular_color=Color(0.3, 0.3, 0.3), + shininess=0.5, + transparency=0.2, + ), + ) + mat5 = Material( + name="test", + description="test material", + density=1000.0, + pbr=PbrMaterial( + base_color=Color(0.1, 0.2, 0.3), + metallic=0.5, + roughness=0.6, + refraction_index=1.5, + ), + ) + assert hash(mat1) != hash(mat4) + assert hash(mat4) != hash(mat5) + + +@pytest.fixture +def material_assy(tmp_path_factory): + """Create an assembly with various materials for testing exports.""" + + # Create a box with a simple color + red_box = cq.Workplane().box(10, 10, 10) + + # Create a cylinder with common material + gold_cylinder = cq.Workplane().cylinder(5, 20) + + # Create a sphere with PBR material + chrome_sphere = cq.Workplane().sphere(8) + + # Create the assembly + assy = cq.Assembly(name="material_test") + + # Add red box with simple color - using simple color representation + assy.add( + red_box, + name="red_box", + material=Material( + name="Red", + description="Simple red material", + density=1000.0, + color=Color(1, 0, 0, 1), # Pure red + ), + ) + + # Add gold cylinder with common material + assy.add( + gold_cylinder, + name="gold_cylinder", + loc=cq.Location((40, 0, 0)), + material=Material( + name="Gold", + description="Metallic gold material", + density=19320.0, # Actual density of gold in kg/m³ + simple=SimpleMaterial( + ambient_color=Color(0.24, 0.2, 0.07), + diffuse_color=Color(0.75, 0.6, 0.22), + specular_color=Color(0.63, 0.56, 0.37), + shininess=0.8, + transparency=0.0, + ), + ), + ) + + # Add chrome sphere with PBR material + assy.add( + chrome_sphere, + name="chrome_sphere", + loc=cq.Location((80, 0, 0)), + material=Material( + name="Chrome", + description="Polished chrome material", + density=7190.0, # Density of chrome in kg/m³ + pbr=PbrMaterial( + base_color=Color(0.8, 0.8, 0.8), + metallic=1.0, + roughness=0.1, + refraction_index=2.4, + ), + ), + ) + + return assy + + +def test_material_gltf_export(material_assy): + """Test that materials are correctly exported to glTF.""" + + # Export to glTF in current directory + gltf_path = "material_test.gltf" + + # Export to glTF + material_assy.export(gltf_path) + + # Verify file exists + assert os.path.exists(gltf_path) + + # Read and verify the glTF content + with open(gltf_path, "r") as f: + content = f.read() + # Check for material properties + assert '"baseColorFactor":[1.0,0.0,0.0,1.0]' in content # Red color + assert '"metallicFactor":1.0' in content # Chrome metallic + assert '"roughnessFactor":0.1' in content # Chrome roughness + + # Current glTF exporter does not support material names + # assert '"name":"Chrome"' in content # Material name + # assert '"name":"Red"' in content # Material name + + +def test_material_step_export(material_assy): + """Test that materials are correctly exported to STEP.""" + + # Export to STEP in current directory + step_path = "material_test.step" + + # Export to STEP + material_assy.export(step_path) + + # Verify file exists + assert os.path.exists(step_path) + + # Read the STEP file and verify colors + doc = read_step(step_path) + nodes = get_doc_nodes(doc, True) + + # Find and verify the red box + red_box = [n for n in nodes if "red_box" in n["name"]][0] + assert approx_equal_tuples(red_box["color"], (1.0, 0.0, 0.0, 1.0)) + + # Find and verify the gold cylinder - should use diffuse color from common material + gold_cylinder = [n for n in nodes if "gold_cylinder" in n["name"]][0] + assert approx_equal_tuples(gold_cylinder["color"], (0.75, 0.6, 0.22, 1.0)) + + # Find and verify the chrome sphere - should use base color from PBR + chrome_sphere = [n for n in nodes if "chrome_sphere" in n["name"]][0] + assert approx_equal_tuples(chrome_sphere["color"], (0.8, 0.8, 0.8, 1.0)) + + +def test_material_step_meta_export(material_assy): + """Test that materials are correctly exported to STEP with metadata.""" + + # Export to STEP in current directory + step_path = "material_test_meta.step" + + # Export to STEP with metadata + exportStepMeta(material_assy, step_path) + + # Verify file exists + assert os.path.exists(step_path) + + # Read the contents to verify material metadata was written + with open(step_path, "r") as f: + content = f.read() + # Check for material definitions + assert "material name" in content + assert "COLOUR_RGB" in content + # Check for specific material names + assert "Red" in content + assert "Gold" in content + assert "Chrome" in content + # Check for material properties + assert re.search(r"1\.932[eE]\+04", content) # Gold density + assert re.search(r"7\.19[eE]\+03", content) # Chrome density + + +def test_material_json_export(material_assy): + """Test that materials are correctly exported to JSON.""" + + # Get JSON data + json_data = toJSON(material_assy) + + # Verify we have the expected number of objects (3 parts) + assert len(json_data) == 3 + + # Save to file for examination + with open("material_test.json", "w") as f: + json.dump(json_data, f) + + +def test_material_vtkjs_export(material_assy): + """Test that materials are correctly exported to VTKJS using export().""" + + # Export to VTKJS in current directory + vtk_path = "material_test.vtkjs" + + # Export using regular export + material_assy.export(vtk_path) + assert os.path.exists(vtk_path + ".zip") + + # TODO: Add verification of VTK content once we have a parser + # This would require implementing a VTK file parser or using external tools + # For now we just verify the export succeeds + + +def test_material_vtkjs_assy_export(material_assy): + """Test that materials are correctly exported to VTKJS using toVTKAssy().""" + + # Export to VTKJS in current directory + vtk_path = "material_test_assy.vtkjs" + + # Create render window from assembly + renderWindow = _vtkRenderWindow(material_assy) + + # Export using temporary directory like in assembly.py + with TemporaryDirectory() as tmpdir: + exporter = vtkJSONSceneExporter() + exporter.SetFileName(tmpdir) + exporter.SetRenderWindow(renderWindow) + exporter.Write() + make_archive(vtk_path, "zip", tmpdir) + + # Verify zip file exists + assert os.path.exists(vtk_path + ".zip") + + # Also verify using toVTKAssy + vtk_assy = toVTKAssy(material_assy) + assert vtk_assy is not None + + # TODO: Add verification of VTK content once we have a parser + # This would require implementing a VTK file parser or using external tools + # For now we just verify the export succeeds + + +def test_material_vrml_export(material_assy): + """Test that materials are correctly exported to VRML.""" + + # Export to VRML in current directory + vrml_path = "material_test.vrml" + + # Export to VRML + material_assy.export(vrml_path) + + # Verify file exists + assert os.path.exists(vrml_path) + + # Read and verify the VRML content + with open(vrml_path, "r") as f: + content = f.read() + + # VRML should contain material definitions + assert "material Material {" in content + + # VRML uses ambient, diffuse, specular, emissive, shininess and transparency + # Each shape should have a material definition + material_blocks = content.count("material Material {") + # We expect multiple material blocks since each shape has one for faces, lines and points + assert material_blocks >= 3 # At least one set per shape (we have 3 shapes) + + # Check for material properties + assert "ambientIntensity" in content + assert "diffuseColor" in content + assert "specularColor" in content + assert "shininess" in content + assert "transparency" in content