""" Utilities to work with CAD objects """ import hashlib import io import re from typing import Optional, Union, Tuple from OCP.TopExp import TopExp from OCP.TopLoc import TopLoc_Location from OCP.TopTools import TopTools_IndexedMapOfShape from OCP.TopoDS import TopoDS_Shape from build123d import Compound, Color from yacv_server.gltf import GLTFMgr CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now CADLike = Union[CADCoreLike, any] # build123d and cadquery types ColorTuple = Tuple[float, float, float, float] def get_color(obj: any) -> Optional[ColorTuple]: """Get color from a CAD Object or any other color-like object""" if 'color' in dir(obj): obj = obj.color if isinstance(obj, tuple): c = None if len(obj) == 3: c = obj + (1,) elif len(obj) == 4: c = obj # noinspection PyTypeChecker return [min(max(float(x), 0), 1) for x in c] if isinstance(obj, Color): return obj.to_tuple() return None def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]: """ Get the shape of a CAD-like object """ # Try to grab a shape if a different type of object was passed if isinstance(obj, TopoDS_Shape) or isinstance(obj, TopLoc_Location): return obj # Return locations (drawn as axes) if 'wrapped' in dir(obj) and isinstance(obj.wrapped, TopLoc_Location): return obj.wrapped # Build123D if 'part' in dir(obj): obj = obj.part if 'sketch' in dir(obj): obj = obj.sketch if 'line' in dir(obj): obj = obj.line # Build123D & CadQuery while 'wrapped' in dir(obj) and not isinstance(obj, TopoDS_Shape) and not isinstance(obj, TopLoc_Location): obj = obj.wrapped # Return shapes if isinstance(obj, TopoDS_Shape): return obj # Handle iterables like Build123d ShapeList by extracting all sub-shapes and making a compound if isinstance(obj, list) or isinstance(obj, tuple) or isinstance(obj, set) or isinstance(obj, dict): try: if isinstance(obj, dict): obj_iter = iter(obj.values()) else: obj_iter = iter(obj) # print(obj, ' -> ', obj_iter) shapes_raw = [get_shape(sub_obj, error=False) for sub_obj in obj_iter] # Silently drop non-shapes shapes_raw_filtered = [shape for shape in shapes_raw if shape is not None] if len(shapes_raw_filtered) > 0: # Continue if we found at least one shape # Sorting is required to improve hashcode consistency shapes_raw_filtered_sorted = sorted(shapes_raw_filtered, key=lambda x: _hashcode(x)) # Build a single compound shape (skip locations/axes here, they can't be in a Compound) shapes_bd = [Compound(shape) for shape in shapes_raw_filtered_sorted if shape is not None and not isinstance(shape, TopLoc_Location)] return get_shape(Compound(shapes_bd), error) except TypeError: pass if error: raise ValueError(f'Cannot show object of type {type(obj)} (submit issue?)') else: return None def grab_all_cad() -> set[Tuple[str, CADCoreLike]]: """ Grab all shapes by inspecting the stack """ import inspect stack = inspect.stack() shapes = set() for frame in stack: for key, value in frame.frame.f_locals.items(): shape = get_shape(value, error=False) if shape and shape not in shapes: shapes.add((key, shape)) return shapes def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None, name: Optional[str] = None, save_mime: str = 'image/jpeg', power_of_two: bool = True) \ -> Tuple[bytes, str]: """Convert an image to a GLTF CAD object.""" from PIL import Image import io import os from build123d import Plane from build123d import Location from build123d import Vector # Handle arguments if name is None: if isinstance(source, str): name, _ = os.path.splitext(os.path.basename(source)) else: hasher = hashlib.md5() hasher.update(source) name = 'image_' + hasher.hexdigest() _format: str if save_mime == 'image/jpeg': _format = 'JPEG' elif save_mime == 'image/png': _format = 'PNG' else: raise ValueError(f'Unsupported save MIME type (for GLTF files): {save_mime}') # Get the plane of the image center_loc = get_shape(center) if not isinstance(center_loc, TopLoc_Location): raise ValueError('Center location not valid') plane = Plane(Location(center_loc)) # Load the image to a byte buffer img = Image.open(source) img_buf = io.BytesIO() # Use the original dimensions for scaling the model if width is None and height is None: raise ValueError('At least one of width or height must be specified') # Fallback to pixels == mm? elif width is None: width = img.width / img.height * height elif height is None: height = height or img.height / img.width * width # Apply default aspect ratio if unspecified # Resize the image to a power of two if requested (recommended for GLTF) if power_of_two: new_width = 2 ** (img.width - 1).bit_length() new_height = 2 ** (img.height - 1).bit_length() img = img.resize((new_width, new_height)) # Save the image to a buffer img.save(img_buf, format=_format) img_buf = img_buf.getvalue() # Convert coordinates system as a last step (gltf is Y-up instead of Z-up) def vert(v: Vector) -> Vector: return Vector(v.X, v.Z, -v.Y) # Build the gltf mgr = GLTFMgr(image=(img_buf, save_mime)) mgr.add_face([ vert(plane.origin - plane.x_dir * width / 2 + plane.y_dir * height / 2), vert(plane.origin + plane.x_dir * width / 2 + plane.y_dir * height / 2), vert(plane.origin + plane.x_dir * width / 2 - plane.y_dir * height / 2), vert(plane.origin - plane.x_dir * width / 2 - plane.y_dir * height / 2), ], [vert(plane.z_dir)] * 4, [ (0, 2, 1), (0, 3, 2), ], [ (0, 0), (1, 0), (1, 1), (0, 1), ], (1, 1, 1, 1)) # Return the GLTF binary blob and the suggested name of the image return b''.join(mgr.build().save_to_bytes()), name def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str: """Utility to compute the STABLE hash code of a shape""" # NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program # This is best-effort and not guaranteed to be unique hasher = hashlib.md5(usedforsecurity=False) for k, v in extras.items(): hasher.update(str(k).encode()) hasher.update(str(v).encode()) if isinstance(obj, bytes): hasher.update(obj) elif isinstance(obj, TopLoc_Location): sub_data = io.BytesIO() obj.DumpJson(sub_data) hasher.update(sub_data.getvalue()) elif isinstance(obj, TopoDS_Shape): map_of_shapes = TopTools_IndexedMapOfShape() TopExp.MapShapes_s(obj, map_of_shapes) for i in range(1, map_of_shapes.Extent() + 1): sub_shape = map_of_shapes.FindKey(i) sub_data = io.BytesIO() TopoDS_Shape.DumpJson(sub_shape, sub_data) val = sub_data.getvalue() val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address hasher.update(val) else: raise ValueError(f'Cannot hash object of type {type(obj)}') return hasher.hexdigest()