Files
yet-another-cad-viewer/yacv_server/cad.py

213 lines
7.6 KiB
Python

"""
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, Shape, 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: CADLike) -> Optional[ColorTuple]:
"""Get color from a CAD Object"""
if 'color' in dir(obj):
if isinstance(obj.color, tuple):
c = None
if len(obj.color) == 3:
c = obj.color + (1,)
elif len(obj.color) == 4:
c = obj.color
# noinspection PyTypeChecker
return [min(max(float(x), 0), 1) for x in c]
if isinstance(obj.color, Color):
return obj.color.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
shapes_bd = [Shape(shape) for shape in shapes_raw_filtered_sorted if shape is not None]
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.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),
], [
(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], color: Optional[ColorTuple], **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 color:
hasher.update(str(color).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()