diff --git a/example/object.py b/example/object.py index b177132..56d217f 100644 --- a/example/object.py +++ b/example/object.py @@ -3,6 +3,7 @@ import os from build123d import * # Also works with cadquery objects! +from build123d import Compound logging.basicConfig(level=logging.DEBUG) @@ -15,9 +16,14 @@ Box(10, 10, 5) Cylinder(4, 5, mode=Mode.SUBTRACT) -# Show it in the frontend with hot-reloading -show(example) +# Custom colors (optional) +example.color = (0.1, 0.3, 0.1, 1) # RGBA +to_highlight = example.edges().group_by(Axis.Z)[-1] +example_hl = Compound(to_highlight).translate((0, 0, 1e-3)) # To avoid z-fighting +example_hl.color = (1, 1, .0, 1) +# Show it in the frontend with hot-reloading +show(example, example_hl) # %% diff --git a/yacv_server/cad.py b/yacv_server/cad.py index d5bb972..c0d4be8 100644 --- a/yacv_server/cad.py +++ b/yacv_server/cad.py @@ -18,12 +18,18 @@ 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): - return obj.color + 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 @@ -175,7 +181,7 @@ def vert(v: Vector) -> Vector: return b''.join(mgr.build().save_to_bytes()), name -def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str: +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 @@ -183,6 +189,8 @@ def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str: 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): diff --git a/yacv_server/gltf.py b/yacv_server/gltf.py index 2f8e2c0..de0ad86 100644 --- a/yacv_server/gltf.py +++ b/yacv_server/gltf.py @@ -8,6 +8,12 @@ b'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVQI12N49OjR////Gf' b'/////48WMATwULS8tcyj8AAAAASUVORK5CYII=') +def get_version() -> str: + try: + return importlib.metadata.version("yacv_server") + except importlib.metadata.PackageNotFoundError: + return "unknown" + class GLTFMgr: """A utility class to build our GLTF2 objects easily and incrementally""" @@ -32,7 +38,7 @@ class GLTFMgr: def __init__(self, image: Optional[Tuple[bytes, str]] = (_checkerboard_image_bytes, 'image/png')): self.gltf = GLTF2( - asset=Asset(generator=f"yacv_server@{importlib.metadata.version('yacv_server')}"), + asset=Asset(generator=f"yacv_server@{get_version()}"), scene=0, scenes=[Scene(nodes=[0])], nodes=[Node(mesh=0)], # TODO: Server-side detection of shallow copies --> nodes @@ -71,9 +77,9 @@ 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: Tuple[float, float, float, float] = (1.0, 0.75, 0.0, 1.0)): + tex_coord_raw: List[Tuple[float, float]], color: Optional[Tuple[float, float, float, float]] = None): """Add a face to the GLTF mesh""" + if color is None: color = (1.0, 0.75, 0.0, 1.0) # 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" @@ -85,8 +91,9 @@ def add_face(self, vertices_raw: List[Vector], indices_raw: List[Tuple[int, int, 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: Tuple[float, float, float, float] = (0.1, 0.1, 1.0, 1.0)): + color: Optional[Tuple[float, float, float, float]] = None): """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))]) @@ -94,9 +101,9 @@ def add_edge(self, vertices_raw: List[Tuple[Tuple[float, float, float], Tuple[fl 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: Tuple[float, float, float, float] = (0.1, 0.1, 0.1, 1.0)): + def add_vertex(self, vertex: Tuple[float, float, float], color: Optional[Tuple[float, float, float, float]] = None): """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) diff --git a/yacv_server/logo.py b/yacv_server/logo.py index 602fde8..f998bc2 100644 --- a/yacv_server/logo.py +++ b/yacv_server/logo.py @@ -16,18 +16,25 @@ def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]: text_at_plane = Plane.YZ text_at_plane.origin = faces().group_by(Axis.X)[-1].face().center() with BuildSketch(text_at_plane.location): - Text('Yet Another\nCAD Viewer', 7, font_path='/usr/share/fonts/TTF/OpenSans-Regular.ttf') + Text('Yet Another\nCAD Viewer', 6, font_path='/usr/share/fonts/TTF/Hack-Regular.ttf') extrude(amount=1) + logo_obj.color = (0.7, 0.4, 0.1, 1) # Custom color for faces + # 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) + + # 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 - logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg') img_glb_bytes, img_name = image_to_gltf(logo_img_path, logo_img_location, height=18) + # 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, 'location': logo_img_location, img_name: img_glb_bytes} + return {'fox': fox_glb_bytes, 'logo': logo_obj, 'logo_hl': logo_obj_hl, 'location': logo_img_location, img_name: img_glb_bytes} if __name__ == "__main__": diff --git a/yacv_server/tessellate.py b/yacv_server/tessellate.py index b320b2d..0cc2cd1 100644 --- a/yacv_server/tessellate.py +++ b/yacv_server/tessellate.py @@ -35,7 +35,8 @@ def tessellate( edge_to_faces: Dict[str, List[TopoDS_Face]] = {} vertex_to_faces: Dict[str, List[TopoDS_Face]] = {} if faces: - for face in shape.faces(): + shape_faces = shape.faces() + for face in shape_faces: _tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance, obj_color) if edges: for edge in face.edges(): @@ -43,13 +44,16 @@ def tessellate( 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 edges: - for edge in 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) + angular_tolerance, obj_color) + if len(shape_edges) > 0: obj_color = None # Don't color vertices if edges are colored if vertices: for vertex in shape.vertices(): - _tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, [])) + _tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []), obj_color) return mgr.build() @@ -77,10 +81,7 @@ def _tessellate_face( vertices = tri_mesh[0] indices = tri_mesh[1] - if color is None: - mgr.add_face(vertices, indices, uv) - else: - mgr.add_face(vertices, indices, uv, color) + mgr.add_face(vertices, indices, uv, color) def _push_point(v: Tuple[float, float, float], faces: List[TopoDS_Face]) -> Tuple[float, float, float]: @@ -105,6 +106,7 @@ def _tessellate_edge( faces: List[TopoDS_Face], 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) @@ -122,11 +124,12 @@ def _tessellate_edge( # Convert strip of vertices to a list of pairs of vertices vertices = [(vertices[i], vertices[i + 1]) for i in range(len(vertices) - 1)] - mgr.add_edge(vertices) + mgr.add_edge(vertices, color) -def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[TopoDS_Face]): +def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[TopoDS_Face], + color: Optional[ColorTuple] = None): c = Vertex(ocp_vertex).center() - mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces)) + 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 022ab49..7a3f77e 100644 --- a/yacv_server/yacv.py +++ b/yacv_server/yacv.py @@ -8,7 +8,6 @@ import time from dataclasses import dataclass from http.server import ThreadingHTTPServer -from importlib.metadata import version from threading import Thread from typing import Optional, Dict, Union, Callable, List, Tuple @@ -18,13 +17,14 @@ from build123d import Shape, Axis, Location, Vector from dataclasses_json import dataclass_json +from yacv_server.cad import _hashcode, ColorTuple, get_color 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 from yacv_server.mylogger import logger from yacv_server.pubsub import BufferedPubSub from yacv_server.rwlock import RWLock from yacv_server.tessellate import tessellate -from yacv_server.cad import _hashcode, ColorTuple, get_color @dataclass_json @@ -44,7 +44,9 @@ class UpdatesApiData: class UpdatesApiFullData(UpdatesApiData): obj: YACVSupported - """The OCCT object, if any (not serialized)""" + """The OCCT object (not serialized)""" + color: Optional[ColorTuple] + """The color of the object, if any (not serialized)""" kwargs: Optional[Dict[str, any]] """The show_object options, if any (not serialized)""" @@ -100,7 +102,7 @@ def __init__(self): self.at_least_one_client = threading.Event() self.shutting_down = threading.Event() self.frontend_lock = RWLock() - logger.info('Using yacv-server v%s', version('yacv-server')) + logger.info('Using yacv-server v%s', get_version()) def start(self): """Starts the web server in the background""" @@ -190,7 +192,7 @@ def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]] color = get_color(obj) if not isinstance(obj, bytes): obj = _preprocess_cad(obj, **kwargs) - _hash = _hashcode(obj, **kwargs) + _hash = _hashcode(obj, color, **kwargs) event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, color=color, kwargs=kwargs or {}) self.show_events.publish(event)