From 10ed5e2e9e34f18956e8478d360c5763b479b7c5 Mon Sep 17 00:00:00 2001 From: Yeicor <4929005+Yeicor@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:43:05 +0200 Subject: [PATCH] Minor backend improvements: better color and textured handling, smooth shading, better demo --- yacv_server/cad.py | 5 ++- yacv_server/gltf.py | 31 +++++++------ yacv_server/logo.py | 17 ++++--- yacv_server/main.py | 6 --- yacv_server/tessellate.py | 47 +++++++++++--------- yacv_server/yacv.py | 94 +++++++++++++++++++++++++++++---------- 6 files changed, 126 insertions(+), 74 deletions(-) delete mode 100644 yacv_server/main.py diff --git a/yacv_server/cad.py b/yacv_server/cad.py index fd06fd9..1a464ba 100644 --- a/yacv_server/cad.py +++ b/yacv_server/cad.py @@ -78,7 +78,8 @@ def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]: # 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)] + 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 @@ -168,7 +169,7 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non 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), ], [ diff --git a/yacv_server/gltf.py b/yacv_server/gltf.py index df61a2d..94386c1 100644 --- a/yacv_server/gltf.py +++ b/yacv_server/gltf.py @@ -4,10 +4,6 @@ import numpy as np from build123d import Location, Plane, Vector from pygltflib import * -# PNG file containing 1x1 while pixel -_default_tex = (b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=') - -_default_color = (1.0, 0.75, 0.0, 1.0) def get_version() -> str: try: @@ -25,6 +21,7 @@ class GLTFMgr: # - Face data face_indices: List[int] # 3 indices per triangle face_positions: List[float] # x, y, z + face_normals: List[float] # x, y, z face_tex_coords: List[float] # u, v face_colors: List[float] # r, g, b, a image: Optional[Tuple[bytes, str]] # image/png @@ -37,7 +34,7 @@ class GLTFMgr: vertex_positions: List[float] # x, y, z vertex_colors: List[float] # r, g, b, a - def __init__(self, image: Optional[Tuple[bytes, str]] = (_default_tex, 'image/png')): + def __init__(self, image: Optional[Tuple[bytes, str]] = None): self.gltf = GLTF2( asset=Asset(generator=f"yacv_server@{get_version()}"), scene=0, @@ -55,6 +52,7 @@ class GLTFMgr: ) self.face_indices = [] self.face_positions = [] + self.face_normals = [] self.face_tex_coords = [] self.face_colors = [] self.image = image @@ -77,24 +75,23 @@ class GLTFMgr: def _vertices_primitive(self) -> Primitive: return [p for p in self.gltf.meshes[0].primitives if p.mode == POINTS][0] - def add_face(self, vertices_raw: List[Vector], indices_raw: List[Tuple[int, int, int]], - tex_coord_raw: List[Tuple[float, float]], color: Optional[Tuple[float, float, float, float]] = None): + def add_face(self, vertices_raw: List[Vector], normals: List[Vector], indices_raw: List[Tuple[int, int, int]], + tex_coord_raw: List[Tuple[float, float]], color: Tuple[float, float, float, float]): """Add a face to the GLTF mesh""" - if color is None: color = _default_color # assert len(vertices_raw) == len(tex_coord_raw), f"Vertices and texture coordinates have different lengths" # assert min([i for t in indices_raw for i in t]) == 0, f"Face indices start at {min(indices_raw)}" # assert max([e for t in indices_raw for e in t]) < len(vertices_raw), f"Indices have non-existing vertices" base_index = len(self.face_positions) // 3 # All the new indices reference the new vertices self.face_indices.extend([base_index + i for t in indices_raw for i in t]) self.face_positions.extend([v for t in vertices_raw for v in t]) + self.face_normals.extend([n for t in normals for n in t]) self.face_tex_coords.extend([c for t in tex_coord_raw for c in t]) self.face_colors.extend([col for _ in range(len(vertices_raw)) for col in color]) self._faces_primitive.extras["face_triangles_end"].append(len(self.face_indices)) def add_edge(self, vertices_raw: List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]], - color: Optional[Tuple[float, float, float, float]] = None): + color: Tuple[float, float, float, float]): """Add an edge to the GLTF mesh""" - if color is None: color = (0.1, 0.1, 1.0, 1.0) vertices_flat = [v for t in vertices_raw for v in t] # Line from 0 to 1, 2 to 3, 4 to 5, etc. base_index = len(self.edge_positions) // 3 self.edge_indices.extend([base_index + i for i in range(len(vertices_flat))]) @@ -102,9 +99,8 @@ class GLTFMgr: self.edge_colors.extend([col for _ in range(len(vertices_flat)) for col in color]) self._edges_primitive.extras["edge_points_end"].append(len(self.edge_indices)) - def add_vertex(self, vertex: Tuple[float, float, float], color: Optional[Tuple[float, float, float, float]] = None): + def add_vertex(self, vertex: Tuple[float, float, float], color: Tuple[float, float, float, float]): """Add a vertex to the GLTF mesh""" - if color is None: color = (0.1, 0.1, 0.1, 1.0) base_index = len(self.vertex_positions) // 3 self.vertex_indices.append(base_index) self.vertex_positions.extend(vertex) @@ -118,10 +114,11 @@ class GLTFMgr: return v.X, v.Y, v.Z # Add 1 origin vertex and 3 edges with custom colors to identify the X, Y and Z axis - self.add_vertex(vert(pl.origin)) - self.add_edge([(vert(pl.origin), vert(pl.origin + pl.x_dir))], color=(0.97, 0.24, 0.24, 1)) - self.add_edge([(vert(pl.origin), vert(pl.origin + pl.y_dir))], color=(0.42, 0.8, 0.15, 1)) - self.add_edge([(vert(pl.origin), vert(pl.origin + pl.z_dir))], color=(0.09, 0.55, 0.94, 1)) + # The colors are hardcoded. You can add vertices and edges manually to change them. + self.add_vertex(vert(pl.origin), color=(0.1, 0.1, 0.1, 1.0)) + self.add_edge([(vert(pl.origin), vert(pl.origin + pl.x_dir))], color=(0.97, 0.24, 0.24, 1.0)) + self.add_edge([(vert(pl.origin), vert(pl.origin + pl.y_dir))], color=(0.42, 0.8, 0.15, 1.0)) + self.add_edge([(vert(pl.origin), vert(pl.origin + pl.z_dir))], color=(0.09, 0.55, 0.94, 1.0)) def build(self) -> GLTF2: """Merge the intermediate data into the GLTF object and return it""" @@ -132,6 +129,8 @@ class GLTFMgr: buffers_list.append(_gen_buffer_metadata(self.face_indices, 1)) self._faces_primitive.attributes.POSITION = len(buffers_list) buffers_list.append(_gen_buffer_metadata(self.face_positions, 3)) + self._faces_primitive.attributes.NORMAL = len(buffers_list) + buffers_list.append(_gen_buffer_metadata(self.face_normals, 3)) self._faces_primitive.attributes.TEXCOORD_0 = len(buffers_list) buffers_list.append(_gen_buffer_metadata(self.face_tex_coords, 2)) self._faces_primitive.attributes.COLOR_0 = len(buffers_list) diff --git a/yacv_server/logo.py b/yacv_server/logo.py index 697d039..d74d563 100644 --- a/yacv_server/logo.py +++ b/yacv_server/logo.py @@ -6,24 +6,30 @@ from build123d import * ASSETS_DIR = os.getenv('ASSETS_DIR', os.path.join(os.path.dirname(__file__), '..', 'assets')) -def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]: +def build_logo(text: bool = True) -> Dict[str, Union[Compound, Location, str]]: """Builds the CAD part of the logo""" with BuildPart(Plane.XY.offset(50)) as logo_obj: Box(22, 40, 30) fillet(edges().filter_by(Axis.Y).group_by(Axis.Z)[-1], 10) offset(solid(), 2, openings=faces().group_by(Axis.Z)[0] + faces().filter_by(Plane.XZ)) if text: - text_at_plane = Plane.YZ - text_at_plane.origin = faces().group_by(Axis.X)[-1].face().center() - with BuildSketch(text_at_plane.location): + with BuildSketch(Plane.YZ.move(Pos(faces().group_by(Axis.X)[-1].face().center()))): Text('Yet Another\nCAD Viewer', 6, font_path='/usr/share/fonts/TTF/Hack-Regular.ttf') extrude(amount=1) + logo_face_curved_front = faces().filter_by(GeomType.CYLINDER).group_by(Axis.X)[-1].face() # Highlight text edges with a custom color to_highlight = logo_obj.edges().group_by(Axis.X)[-1] logo_obj_hl = Compound(to_highlight).translate((1e-3, 0, 0)) # To avoid z-fighting logo_obj_hl.color = (0, 0.3, 0.3, 1) + # Highlight face with custom texture + logo_face_curved_front.yacv_texture = \ + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVQI12N49OjR////Gf' \ + '/////48WMATwULS8tcyj8AAAAASUVORK5CYII=' + logo_face_curved_front.color = (0, 0.5, 0.0, 1) + logo_obj = Compound(logo_obj.faces() - ShapeList([logo_face_curved_front])) # Remove face from the main object + # Add a logo image to the CAD part logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location logo_img_location *= Location((0, 0, 4e-2), (0, 0, 90)) # Avoid overlapping and adjust placement @@ -33,7 +39,8 @@ def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]: # Add an animated fox to the CAD part fox_glb_bytes = open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read() - return {'fox': fox_glb_bytes, 'logo': logo_obj, 'logo_hl': logo_obj_hl, 'location': logo_img_location, img_name: img_glb_bytes} + return {'fox': fox_glb_bytes, 'logo': logo_obj, 'logo_hl': logo_obj_hl, 'logo_hl_tex': logo_face_curved_front, + 'location': logo_img_location, img_name: img_glb_bytes} if __name__ == "__main__": diff --git a/yacv_server/main.py b/yacv_server/main.py deleted file mode 100644 index 404c411..0000000 --- a/yacv_server/main.py +++ /dev/null @@ -1,6 +0,0 @@ -from os import system - -if __name__ == '__main__': - # Just a reminder that a hot-reloading server can be started with the following command: - # Need to disable auto-start to avoid conflicts with the hot-reloading server - system('YACV_DISABLE_SERVER=true aiohttp-devtools runserver __init__.py --port 32323 --app-factory _get_app') diff --git a/yacv_server/tessellate.py b/yacv_server/tessellate.py index e8a3eb0..15b5bb0 100644 --- a/yacv_server/tessellate.py +++ b/yacv_server/tessellate.py @@ -3,9 +3,11 @@ from typing import List, Dict, Tuple, Optional from OCP.BRep import BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_Curve from OCP.GCPnts import GCPnts_TangentialDeflection +from OCP.OCP.BRepLib import BRepLib_ToolTriangulatedShape +from OCP.OCP.TopAbs import TopAbs_Orientation from OCP.TopLoc import TopLoc_Location from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex -from build123d import Vertex, Face, Location, Compound +from build123d import Vertex, Face, Location, Compound, Vector from pygltflib import GLTF2 from yacv_server.cad import CADCoreLike, ColorTuple @@ -14,14 +16,9 @@ from yacv_server.mylogger import logger def tessellate( - cad_like: CADCoreLike, - tolerance: float = 0.1, - angular_tolerance: float = 0.1, - faces: bool = True, - edges: bool = True, - vertices: bool = True, - obj_color: Optional[ColorTuple] = None, - texture: Optional[Tuple[bytes, str]] = None, + cad_like: CADCoreLike, color_faces: ColorTuple, color_edges: ColorTuple, color_vertices: ColorTuple, + color_obj: Optional[ColorTuple] = None, tolerance: float = 0.1, angular_tolerance: float = 0.1, + faces: bool = True, edges: bool = True, vertices: bool = True, texture: Optional[Tuple[bytes, str]] = None, ) -> GLTF2: """Tessellate a whole shape into a list of triangle vertices and a list of triangle indices.""" if texture is None: @@ -41,23 +38,24 @@ def tessellate( if faces and hasattr(shape, 'faces'): shape_faces = shape.faces() for face in shape_faces: - _tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance, obj_color) + _tessellate_face(mgr, face.wrapped, color_obj or color_faces, tolerance, angular_tolerance) if edges: for edge in face.edges(): edge_to_faces[edge.wrapped] = edge_to_faces.get(edge.wrapped, []) + [face.wrapped] if vertices: for vertex in face.vertices(): vertex_to_faces[vertex.wrapped] = vertex_to_faces.get(vertex.wrapped, []) + [face.wrapped] - if len(shape_faces) > 0: obj_color = None # Don't color edges/vertices if faces are colored + if len(shape_faces) > 0: color_obj = None # Don't color edges/vertices if faces are colored if edges and hasattr(shape, 'edges'): shape_edges = shape.edges() for edge in shape_edges: - _tessellate_edge(mgr, edge.wrapped, edge_to_faces.get(edge.wrapped, []), angular_tolerance, - angular_tolerance, obj_color) - if len(shape_edges) > 0: obj_color = None # Don't color vertices if edges are colored + _tessellate_edge(mgr, edge.wrapped, edge_to_faces.get(edge.wrapped, []), color_obj or color_edges, + angular_tolerance, angular_tolerance) + if len(shape_edges) > 0: color_obj = None # Don't color vertices if edges are colored if vertices and hasattr(shape, 'vertices'): for vertex in shape.vertices(): - _tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []), obj_color) + _tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []), + color_obj or color_vertices) else: raise TypeError(f"Unsupported type: {type(cad_like)}: {cad_like}") @@ -68,9 +66,9 @@ def tessellate( def _tessellate_face( mgr: GLTFMgr, ocp_face: TopoDS_Face, + color: ColorTuple, tolerance: float = 1e-3, angular_tolerance: float = 0.1, - color: Optional[ColorTuple] = None, ): face = Compound(ocp_face) # face.mesh(tolerance, angular_tolerance) @@ -81,6 +79,14 @@ def _tessellate_face( logger.warn("No triangulation found for face") return GLTF2() + # Get the normal for each vertex (for smooth instead of flat shading!) + BRepLib_ToolTriangulatedShape.ComputeNormals_s(face.wrapped, poly) + reversed_face = face.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED + normals = [ + -Vector(v) if reversed_face else Vector(v) + for v in (poly.Normal(i) for i in range(1, poly.NbNodes() + 1)) + ] + # Get UV of each face from the parameters uv = [ (v.X(), v.Y()) @@ -89,7 +95,7 @@ def _tessellate_face( vertices = tri_mesh[0] indices = tri_mesh[1] - mgr.add_face(vertices, indices, uv, color) + mgr.add_face(vertices, normals, indices, uv, color) return None @@ -113,9 +119,9 @@ def _tessellate_edge( mgr: GLTFMgr, ocp_edge: TopoDS_Edge, faces: List[TopoDS_Face], + color: ColorTuple, angular_deflection: float = 0.1, curvature_deflection: float = 0.1, - color: Optional[ColorTuple] = None, ): # Use a curve discretizer to get the vertices curve = BRepAdaptor_Curve(ocp_edge) @@ -136,9 +142,6 @@ def _tessellate_edge( mgr.add_edge(vertices, color) -def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[TopoDS_Face], - color: Optional[ColorTuple] = None): +def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[TopoDS_Face], color: ColorTuple): c = Vertex(ocp_vertex).center() mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces), color) - - diff --git a/yacv_server/yacv.py b/yacv_server/yacv.py index 3d1a22e..868f985 100644 --- a/yacv_server/yacv.py +++ b/yacv_server/yacv.py @@ -20,7 +20,7 @@ from PIL import Image from build123d import Shape, Axis, Location, Vector from dataclasses_json import dataclass_json -from yacv_server.cad import _hashcode, get_color +from yacv_server.cad import _hashcode, get_color, ColorTuple from yacv_server.cad import get_shape, grab_all_cad, CADCoreLike, CADLike from yacv_server.gltf import get_version from yacv_server.myhttp import HTTPHandler @@ -93,12 +93,39 @@ class YACV: texture: Optional[Tuple[bytes, str]] """Default texture to use for model faces, in (data, mimetype) format. - If left as None, a default checkerboard texture will be used. + If left as None, no texture will be used. - It can be set with the YACV_BASE_TEXTURE= and overridden by `show(..., texture="")`. + It can be set with the YACV_TEXTURE= and overridden by the custom `yacv_texture` attribute of an object. The can be file: or data:;base64, where is the mime type and is the base64 encoded image.""" + color_faces: Optional[ColorTuple] + """Overrides the default color to use for model faces. Applies even if a texture is used. + + You can use `show(..., color_faces=...)` or the standard way of setting colors for build123d/cadquery objects to + override this color. + + It can be set with the YACV_COLOR_FACES= environment variable, where is a color + in the hexadecimal format #RRGGBB or #RRGGBBAA.""" + + color_edges: Optional[ColorTuple] + """Overrides the default color to use for model edges. + + You can use `show(..., color_edges=...) or the standard way of setting colors for build123d/cadquery objects to + override this color. + + It can be set with the YACV_COLOR_EDGES= environment variable, where is a color + in the hexadecimal format #RRGGBB or #RRGGBBAA.""" + + color_vertices: Optional[ColorTuple] + """Overrides the default color to use for model vertices. + + You can use `show(..., color_vertices=...)` or the standard way of setting colors for build123d/cadquery objects to + override this color. + + It can be set with the YACV_COLOR_VERTICES= environment variable, where is a color + in the hexadecimal format #RRGGBB or #RRGGBBAA.""" + def __init__(self): self.server_thread = None self.server = None @@ -109,7 +136,10 @@ class YACV: self.at_least_one_client = threading.Event() self.shutting_down = threading.Event() self.frontend_lock = RWLock() - self.texture = _read_texture_uri(os.getenv("YACV_BASE_TEXTURE")) + self.texture = _read_texture_uri(os.getenv("YACV_TEXTURE")) + self.color_faces = _read_color(os.getenv("YACV_COLOR_FACES", "#ffbf00")) # Default yellow + self.color_edges = _read_color(os.getenv("YACV_COLOR_EDGES", "#1a1aff")) # Default blue + self.color_vertices = _read_color(os.getenv("YACV_COLOR_VERTICES", "#1a1a1a")) # Default dark gray logger.info('Using yacv-server v%s', get_version()) def start(self): @@ -201,8 +231,9 @@ class YACV: if isinstance(names, str): names = [names] assert len(names) == len(objs), 'Number of names must match the number of objects' - if 'color' in kwargs: - kwargs['color'] = get_color(kwargs['color']) + for color_name in ('color_faces', 'color_edges', 'color_vertices'): + if color_name in kwargs: + kwargs[color_name] = get_color(kwargs[color_name]) or _read_color(kwargs[color_name]) # Handle auto clearing of previous objects if kwargs.get('auto_clear', True): @@ -218,13 +249,15 @@ class YACV: # Publish the show event for obj, name in zip(objs, names): obj_color = get_color(obj) + # Some properties may be lost in preprocessing, so save them in kwargs + _kwargs = kwargs.copy() if obj_color is not None: - kwargs = kwargs.copy() - kwargs['color'] = obj_color + _kwargs['color_obj'] = obj_color # Only applies to highest-dimensional objects + _kwargs['texture'] = _read_texture_uri(getattr(obj, 'yacv_texture', None) or kwargs.get('texture', None)) if not isinstance(obj, bytes): - obj = _preprocess_cad(obj, **kwargs) - _hash = _hashcode(obj, **kwargs) - event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=kwargs or {}) + obj = _preprocess_cad(obj, **_kwargs) + _hash = _hashcode(obj, **_kwargs) + event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=_kwargs or {}) self.show_events.publish(event) logger.info('show %s took %.3f seconds', names, time.time() - start) @@ -309,17 +342,17 @@ class YACV: if isinstance(event.obj, bytes): # Already a GLTF publish_to.publish(event.obj) else: # CAD object to tessellate and convert to GLTF - texture_override_uri = event.kwargs.get('texture', None) - texture_override = None - if isinstance(texture_override_uri, str): - texture_override = _read_texture_uri(texture_override_uri) - gltf = tessellate(event.obj, tolerance=event.kwargs.get('tolerance', 0.1), - angular_tolerance=event.kwargs.get('angular_tolerance', 0.1), - faces=event.kwargs.get('faces', True), - edges=event.kwargs.get('edges', True), - vertices=event.kwargs.get('vertices', True), - obj_color=event.kwargs.get('color', None), - texture=texture_override or self.texture) + gltf = tessellate( + event.obj, + color_faces=event.kwargs.get('color_faces', self.color_faces), + color_edges=event.kwargs.get('color_edges', self.color_edges), + color_vertices=event.kwargs.get('color_vertices', self.color_vertices), + color_obj=event.kwargs.get('color_obj', None), + tolerance=event.kwargs.get('tolerance', 0.1), + angular_tolerance=event.kwargs.get('angular_tolerance', 0.1), + faces=event.kwargs.get('faces', True), edges=event.kwargs.get('edges', True), + vertices=event.kwargs.get('vertices', True), + texture=event.kwargs.get('texture', self.texture)) glb_list_of_bytes = gltf.save_to_bytes() glb_bytes = b''.join(glb_list_of_bytes) publish_to.publish(glb_bytes) @@ -355,7 +388,7 @@ def _read_texture_uri(uri: str) -> Optional[Tuple[bytes, str]]: img = Image.open(buf) mtype = img.get_format_mimetype() return data, mtype - if uri.startswith("data:"): # https://en.wikipedia.org/wiki/Data_URI_scheme#Syntax (limited) + if uri.startswith("data:"): # https://en.wikipedia.org/wiki/Data_URI_scheme#Syntax (limited) mtype_and_data = uri[len("data:"):] mtype = mtype_and_data.split(";", 1)[0] data_str = mtype_and_data.split(",", 1)[1] @@ -363,6 +396,20 @@ def _read_texture_uri(uri: str) -> Optional[Tuple[bytes, str]]: return data, mtype return None + +def _read_color(color: str) -> Optional[ColorTuple]: + """Reads a color from a string in the format #RRGGBB or #RRGGBBAA""" + if color is None: + return None + if not color.startswith('#') or len(color) not in (7, 9): + raise ValueError(f'Invalid color format: {color}') + r = float(int(color[1:3], 16)) / 255.0 + g = float(int(color[3:5], 16)) / 255.0 + b = float(int(color[5:7], 16)) / 255.0 + a = float(int(color[7:9], 16)) / 255.0 if len(color) == 9 else 1.0 + return r, g, b, a + + # noinspection PyUnusedLocal def _preprocess_cad(obj: CADLike, **kwargs) -> CADCoreLike: # Get the shape of a CAD-like object @@ -384,6 +431,7 @@ def _preprocess_cad(obj: CADLike, **kwargs) -> CADCoreLike: _obj_name_counts = {} + def _find_var_name(obj: any, avoid_levels: int = 2) -> str: """A hacky way to get a stable name for an object that may change over time"""