From 0ff39e045ffe73bb16e4ae5b8420de599d3c3815 Mon Sep 17 00:00:00 2001 From: Yeicor <4929005+Yeicor@users.noreply.github.com> Date: Sun, 18 Feb 2024 21:06:45 +0100 Subject: [PATCH] start to pre-merge CAD models on the server to improve frontend performance --- .gitignore | 2 + yacv_server/glbs.py | 36 ---- yacv_server/gltf.py | 353 +++++++++++++++++++++++++------------- yacv_server/logo.py | 17 +- yacv_server/server.py | 113 ++---------- yacv_server/tessellate.py | 97 ++++------- 6 files changed, 288 insertions(+), 330 deletions(-) delete mode 100644 yacv_server/glbs.py diff --git a/.gitignore b/.gitignore index 1f3589a..b5129a1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ # TODO: Figure out which assets to keep in the repo /assets/fox.glb /assets/logo.glbs +/assets/logo.glb +/assets/logo.stl *.iml venv/ diff --git a/yacv_server/glbs.py b/yacv_server/glbs.py deleted file mode 100644 index 52b6f8b..0000000 --- a/yacv_server/glbs.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import AsyncGenerator - - -async def glb_sequence_to_glbs(glb_sequence: AsyncGenerator[bytes, None], count: int = -1) -> AsyncGenerator[bytes, None]: - """Converts a sequence of GLB files into a single GLBS file. - - This is a streaming response in the custom GLBS format which consists of the "GLBS" magic text followed by - a count of GLB files (0xffffffff if unknown) and a sequence of GLB files, each with a length prefix. All numbers are - 4-byte little-endian unsigned integers.""" - - # Write the magic text - yield b'GLBS' - - # Write the count - yield count.to_bytes(4, 'little') - - # Write the GLB files - async for glb in glb_sequence: - # Write the length prefix - yield len(glb).to_bytes(4, 'little') - # Write the GLB file - yield glb - - -if __name__ == '__main__': - import asyncio - - async def test_glb_sequence_to_glbs(): - async def glb_sequence(): - yield b'glb00001' - yield b'glb2' - - async for chunk in glb_sequence_to_glbs(glb_sequence(), 2): - print(chunk) - - asyncio.run(test_glb_sequence_to_glbs()) \ No newline at end of file diff --git a/yacv_server/gltf.py b/yacv_server/gltf.py index 662a28a..61c553a 100644 --- a/yacv_server/gltf.py +++ b/yacv_server/gltf.py @@ -1,133 +1,244 @@ import numpy as np +from build123d import Vector from pygltflib import * -_checkerboard_image = Image(uri='data:image/png;base64,' - 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVQI12N49OjR////Gf' - '/////48WMATwULS8tcyj8AAAAASUVORK5CYII=') +_checkerboard_image_bytes = base64.decodebytes( + b'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVQI12N49OjR////Gf' + b'/////48WMATwULS8tcyj8AAAAASUVORK5CYII=') -def create_gltf(vertices: np.ndarray, indices: np.ndarray, tex_coord: np.ndarray, mode: int = TRIANGLES, - material: Optional[Material] = None, images: Optional[List[Image]] = None) -> GLTF2: - """Create a glTF object from vertices and optionally indices. +class GLTFMgr: + """A utility class to build our GLTF2 objects easily and incrementally""" - If indices are not set, vertices are interpreted as line_strip.""" - - assert vertices.ndim == 2 - assert vertices.shape[1] == 3 - vertices = vertices.astype(np.float32) - vertices_blob = vertices.tobytes() - # print(vertices) - - indices = indices.astype(np.uint8) - indices_blob = indices.flatten().tobytes() - # print(indices) - - tex_coord = tex_coord.astype(np.float32) - tex_coord_blob = tex_coord.tobytes() - # print(tex_coord) - - if images is None: - images = [] - image_blob = b'' - image_blob_pointers = [] - for i, img in enumerate(images): - img = copy.deepcopy(img) # Avoid modifying the original image - assert img.bufferView is None - assert img.uri is not None - assert img.uri.startswith('data:') - image_blob_pointers.append(len(image_blob)) - image_blob += base64.decodebytes(img.uri.split('base64,', maxsplit=1)[1].encode('ascii')) - img.mimeType = img.uri.split(';', maxsplit=1)[0].split(':', maxsplit=1)[1] - img.uri = None - img.bufferView = 3 + len(image_blob_pointers) - 1 - images[i] = img # Replace the original image with the new copied and modified one - - gltf = GLTF2( - scene=0, + gltf: GLTF2 = GLTF2( scenes=[Scene(nodes=[0])], nodes=[Node(mesh=0)], - meshes=[ - Mesh( - primitives=[ - Primitive( - attributes=Attributes(POSITION=1, TEXCOORD_0=2) if len(tex_coord) > 0 else Attributes( - POSITION=1), - indices=0, - mode=mode, - material=0 if material is not None else None, - ) - ] - ) - ], - materials=[material] if material is not None else [], - accessors=[ - Accessor( - bufferView=0, - componentType=UNSIGNED_BYTE, - count=indices.size, - type=SCALAR, - max=[int(indices.max())], - min=[int(indices.min())], - ), - Accessor( - bufferView=1, - componentType=FLOAT, - count=len(vertices), - type=VEC3, - max=vertices.max(axis=0).tolist(), - min=vertices.min(axis=0).tolist(), - ), - ] + ([ - Accessor( - bufferView=2, - componentType=FLOAT, - count=len(tex_coord), - type=VEC2, - max=tex_coord.max(axis=0).tolist(), - min=tex_coord.min(axis=0).tolist(), - )] if len(tex_coord) > 0 else []) - , + meshes=[Mesh(primitives=[])], + accessors=[], bufferViews=[ - BufferView( - buffer=0, - byteLength=len(indices_blob), - target=ELEMENT_ARRAY_BUFFER, - ), - BufferView( - buffer=0, - byteOffset=len(indices_blob), - byteLength=len(vertices_blob), - target=ARRAY_BUFFER, - ), - ] + ( - [ - BufferView( - buffer=0, - byteOffset=len(indices_blob) + len(vertices_blob), - byteLength=len(tex_coord_blob), - target=ARRAY_BUFFER, - ), - ] if len(tex_coord) > 0 else []) + ( - [ - BufferView( - buffer=0, - byteOffset=len(indices_blob) + len( - vertices_blob) + len(tex_coord_blob) + image_blob_pointers[i], - byteLength=image_blob_pointers[i + 1] - image_blob_pointers[i] if i + 1 < len( - image_blob_pointers) else len(image_blob) - image_blob_pointers[i], - ) - for i, img in enumerate(images) - ] if len(images) > 0 else []), - buffers=[ - Buffer( - byteLength=len(indices_blob) + len(vertices_blob) + len(tex_coord_blob) + len(image_blob), - ) - ], - samplers=[Sampler(magFilter=NEAREST)] if len(images) > 0 else [], - textures=[Texture(source=i, sampler=0) for i, _ in enumerate(images)], - images=images, + BufferView(buffer=0, byteLength=len(_checkerboard_image_bytes), byteOffset=0, target=ELEMENT_ARRAY_BUFFER)], + buffers=[Buffer(byteLength=len(_checkerboard_image_bytes))], + samplers=[Sampler(magFilter=NEAREST)], + textures=[Texture(source=0, sampler=0)], + images=[Image(bufferView=0, mimeType='image/png')], + materials=[Material(pbrMetallicRoughness=PbrMetallicRoughness(baseColorTexture=TextureInfo(index=0)))], ) - gltf.set_binary_blob(indices_blob + vertices_blob + tex_coord_blob + image_blob) + def __init__(self): + self.gltf.set_binary_blob(_checkerboard_image_bytes) - return gltf + def add_face(self, vertices: np.ndarray, indices: np.ndarray, tex_coord: np.ndarray): + """Add a face to the GLTF as a new primitive of the unique mesh""" + self._add_any(vertices, indices, tex_coord, mode=TRIANGLES) + + def add_edge(self, vertices: np.ndarray): + """Add an edge to the GLTF as a new primitive of the unique mesh""" + indices = np.array(list(map(lambda i: [i, i + 1], range(len(vertices) - 1))), dtype=np.uint8) + tex_coord = np.array([[i / (len(vertices) - 1), 0] for i in range(len(vertices))], dtype=np.float32) + self._add_any(vertices, indices, tex_coord, mode=LINE_STRIP) + + def add_vertex(self, vertex: Vector): + """Add a vertex to the GLTF as a new primitive of the unique mesh""" + vertices = np.array([[vertex.X, vertex.Y, vertex.Z]]) + indices = np.array([0], dtype=np.uint8) + tex_coord = np.array([[0, 0]], dtype=np.float32) + self._add_any(vertices, indices, tex_coord, mode=POINTS) + + def _add_any(self, vertices: np.ndarray, indices: np.ndarray, tex_coord: np.ndarray, mode: int = TRIANGLES): + assert vertices.ndim == 2 + assert vertices.shape[1] == 3 + vertices = vertices.astype(np.float32) + vertices_blob = vertices.tobytes() + + indices = indices.astype(np.uint8) + indices_blob = indices.flatten().tobytes() + + tex_coord = tex_coord.astype(np.float32) + tex_coord_blob = tex_coord.tobytes() + + accessor_base = len(self.gltf.accessors) + self.gltf.meshes[0].primitives.append( + Primitive( + attributes=Attributes(POSITION=accessor_base + 1, TEXCOORD_0=accessor_base + 2), + indices=accessor_base, + mode=mode, + material=0, # TODO special selected material and face/edge/vertex default materials + ) + ) + + buffer_view_base = len(self.gltf.bufferViews) + self.gltf.accessors.extend([ + Accessor( + bufferView=buffer_view_base, + componentType=UNSIGNED_BYTE, + count=indices.size, + type=SCALAR, + max=[int(indices.max())], + min=[int(indices.min())], + ), + Accessor( + bufferView=buffer_view_base + 1, + componentType=FLOAT, + count=len(vertices), + type=VEC3, + max=vertices.max(axis=0).tolist(), + min=vertices.min(axis=0).tolist(), + ), + Accessor( + bufferView=buffer_view_base + 2, + componentType=FLOAT, + count=len(tex_coord), + type=VEC2, + max=tex_coord.max(axis=0).tolist(), + min=tex_coord.min(axis=0).tolist(), + ) + ]) + + binary_blob = self.gltf.binary_blob() + binary_blob_base = len(binary_blob) + self.gltf.bufferViews.extend([ + BufferView( + buffer=0, + byteOffset=binary_blob_base, + byteLength=len(indices_blob), + target=ELEMENT_ARRAY_BUFFER, + ), + BufferView( + buffer=0, + byteOffset=binary_blob_base + len(indices_blob), + byteLength=len(vertices_blob), + target=ARRAY_BUFFER, + ), + BufferView( + buffer=0, + byteOffset=binary_blob_base + len(indices_blob) + len(vertices_blob), + byteLength=len(tex_coord_blob), + target=ARRAY_BUFFER, + ) + ]) + + self.gltf.set_binary_blob(binary_blob + indices_blob + vertices_blob + tex_coord_blob) + + +# +# +# def create_gltf(vertices: np.ndarray, indices: np.ndarray, tex_coord: np.ndarray, mode: int = TRIANGLES, +# material: Optional[Material] = None, images: Optional[List[Image]] = None) -> GLTF2: +# """Create a glTF object from vertices and optionally indices. +# +# If indices are not set, vertices are interpreted as line_strip.""" +# +# assert vertices.ndim == 2 +# assert vertices.shape[1] == 3 +# vertices = vertices.astype(np.float32) +# vertices_blob = vertices.tobytes() +# # print(vertices) +# +# indices = indices.astype(np.uint8) +# indices_blob = indices.flatten().tobytes() +# # print(indices) +# +# tex_coord = tex_coord.astype(np.float32) +# tex_coord_blob = tex_coord.tobytes() +# # print(tex_coord) +# +# if images is None: +# images = [] +# image_blob = b'' +# image_blob_pointers = [] +# for i, img in enumerate(images): +# image_blob = img_to_blob(i, image_blob, image_blob_pointers, images, img) +# +# gltf = GLTF2( +# scene=0, +# scenes=[Scene(nodes=[0])], +# nodes=[Node(mesh=0)], +# meshes=[ +# Mesh( +# primitives=[ +# Primitive( +# attributes=Attributes(POSITION=1, TEXCOORD_0=2) if len(tex_coord) > 0 else Attributes( +# POSITION=1), +# indices=0, +# mode=mode, +# material=0 if material is not None else None, +# ) +# ] +# ) +# ], +# materials=[material] if material is not None else [], +# accessors=[ +# Accessor( +# bufferView=0, +# componentType=UNSIGNED_BYTE, +# count=indices.size, +# type=SCALAR, +# max=[int(indices.max())], +# min=[int(indices.min())], +# ), +# Accessor( +# bufferView=1, +# componentType=FLOAT, +# count=len(vertices), +# type=VEC3, +# max=vertices.max(axis=0).tolist(), +# min=vertices.min(axis=0).tolist(), +# ), +# ] + ([ +# Accessor( +# bufferView=2, +# componentType=FLOAT, +# count=len(tex_coord), +# type=VEC2, +# max=tex_coord.max(axis=0).tolist(), +# min=tex_coord.min(axis=0).tolist(), +# )] if len(tex_coord) > 0 else []) +# , +# bufferViews=[ +# BufferView( +# buffer=0, +# byteLength=len(indices_blob), +# target=ELEMENT_ARRAY_BUFFER, +# ), +# BufferView( +# buffer=0, +# byteOffset=len(indices_blob), +# byteLength=len(vertices_blob), +# target=ARRAY_BUFFER, +# ), +# ] + ( +# [ +# BufferView( +# buffer=0, +# byteOffset=len(indices_blob) + len(vertices_blob), +# byteLength=len(tex_coord_blob), +# target=ARRAY_BUFFER, +# ), +# ] if len(tex_coord) > 0 else []) + ( +# [ +# BufferView( +# buffer=0, +# byteOffset=len(indices_blob) + len( +# vertices_blob) + len(tex_coord_blob) + image_blob_pointers[i], +# byteLength=image_blob_pointers[i + 1] - image_blob_pointers[i] if i + 1 < len( +# image_blob_pointers) else len(image_blob) - image_blob_pointers[i], +# ) +# for i, img in enumerate(images) +# ] if len(images) > 0 else []), +# buffers=[ +# Buffer( +# byteLength=len(indices_blob) + len(vertices_blob) + len(tex_coord_blob) + len(image_blob), +# ) +# ], +# samplers=[Sampler(magFilter=NEAREST)] if len(images) > 0 else [], +# textures=[Texture(source=i, sampler=0) for i, _ in enumerate(images)], +# images=images, +# ) +# +# gltf.set_binary_blob(indices_blob + vertices_blob + tex_coord_blob + image_blob) +# +# return gltf + + +def img_blob(img: Image) -> bytes: + return base64.decodebytes(img.uri.split('base64,', maxsplit=1)[1].encode('ascii')) diff --git a/yacv_server/logo.py b/yacv_server/logo.py index 8f774f5..538d125 100644 --- a/yacv_server/logo.py +++ b/yacv_server/logo.py @@ -30,21 +30,16 @@ if __name__ == "__main__": from __init__ import show_object, server ASSETS_DIR = os.getenv('ASSETS_DIR', os.path.join(os.path.dirname(__file__), '..', 'assets')) - # 1. Add the CAD part of the logo to the server + # Add the CAD part of the logo to the server obj = build_logo() + Shape(obj).export_stl(os.path.join(ASSETS_DIR, 'logo.stl')) show_object(obj, 'logo') - # 2. Load the GLTF part of the logo - with open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb') as f: - gltf = f.read() - show_object(gltf, 'fox') - - # 3. Save the complete logo to a GLBS file - with open(os.path.join(ASSETS_DIR, 'logo.glbs'), 'wb') as f: + # Save the complete logo to a single GLB file + with open(os.path.join(ASSETS_DIR, 'logo.glb'), 'wb') as f: async def writer(): - async for chunk in server.export_all(): - f.write(chunk) + f.write(await server.export('logo')) asyncio.run(writer()) - print('Logo saved to', os.path.join(ASSETS_DIR, 'logo.glbs')) + print('Logo saved to', os.path.join(ASSETS_DIR, 'logo.glb')) diff --git a/yacv_server/server.py b/yacv_server/server.py index c911b2a..75e6fa8 100644 --- a/yacv_server/server.py +++ b/yacv_server/server.py @@ -1,26 +1,22 @@ import asyncio import atexit import hashlib -import logging import os import signal import sys import time from dataclasses import dataclass, field from threading import Thread -from typing import Optional, Dict, Union, AsyncGenerator, List +from typing import Optional, Dict, Union -import tqdm.asyncio from OCP.TopoDS import TopoDS_Shape from aiohttp import web from build123d import Shape, Axis from dataclasses_json import dataclass_json, config -from tqdm.contrib.logging import logging_redirect_tqdm -from glbs import glb_sequence_to_glbs from mylogger import logger from pubsub import BufferedPubSub -from tessellate import _hashcode, tessellate_count, tessellate +from tessellate import _hashcode, tessellate FRONTEND_BASE_PATH = os.getenv('FRONTEND_BASE_PATH', '../dist') UPDATES_API_PATH = '/api/updates' @@ -197,42 +193,28 @@ class Server: self._show_common(name, _hashcode(obj), start, obj) - async def _api_object(self, request: web.Request) -> web.StreamResponse: + async def _api_object(self, request: web.Request) -> web.Response: """Returns the object file with the matching name, building it if necessary.""" - # Start exporting the object (or fail if not found) - export_data = self._export(request.match_info['name']) - response = web.StreamResponse() + # Export the object (or fail if not found) + exported_glb = self.export(request.match_info['name']) + response = web.Response() try: - # First exported element is the object itself, grab it to collect data - export_obj = await anext(export_data) - # Create a new stream response with custom content type and headers - response.content_type = 'model/gltf-binary-sequence' - response.headers['Content-Disposition'] = f'attachment; filename="{request.match_info["name"]}.glbs"' - total_parts = 1 if export_obj is None else tessellate_count(export_obj) - response.headers['X-Object-Parts'] = str(total_parts) + response.content_type = 'model/gltf-binary' + response.headers['Content-Disposition'] = f'attachment; filename="{request.match_info["name"]}.glb"' await response.prepare(request) - # Convert the GLB sequence to a GLBS sequence and write it to the response - with logging_redirect_tqdm(tqdm_class=tqdm.asyncio.tqdm): - if logger.isEnabledFor(logging.INFO): - # noinspection PyTypeChecker - export_data_iter = tqdm.asyncio.tqdm(export_data, total=total_parts) - else: - export_data_iter = export_data - async for chunk in glb_sequence_to_glbs(export_data_iter, total_parts): - await response.write(chunk) + # Stream the export data to the response + response.body = exported_glb finally: - # Close the export data subscription - await export_data.aclose() # Close the response (if not an error) if response.prepared: await response.write_eof() return response - async def _export(self, name: str) -> AsyncGenerator[Union[TopoDS_Shape, bytes], None]: - """Export the given previously-shown object to a sequence of GLB files, building it if necessary.""" + async def export(self, name: str) -> bytes: + """Export the given previously-shown object to a single GLB file, building it if necessary.""" start = time.time() # Check that the object to build exists and grab it if it does found = False @@ -249,9 +231,6 @@ class Server: if not found: raise web.HTTPNotFound(text=f'No object named {name} was previously shown') - # First published element is the TopoDS_Shape, which is None for glTF objects - yield obj - # Use the lock to ensure that we don't build the object twice async with self.object_events_lock: # If there are no object events for this name, we need to build the object @@ -261,15 +240,12 @@ class Server: self.object_events[name] = publish_to def _build_object(): - # Build the object - part_count = 0 - for tessellation_update in tessellate(obj): - # We publish the object parts as soon as we have a new tessellation - list_of_bytes = tessellation_update.gltf.save_to_bytes() - publish_to.publish_nowait(b''.join(list_of_bytes)) - part_count += 1 - publish_to.publish_nowait(b'') # Signal the end of the stream - logger.info('export(%s) took %.3f seconds, %d parts', name, time.time() - start, part_count) + # Build and publish the object (once) + gltf = tessellate(obj) # TODO: Publish tessellate options + glb_list_of_bytes = gltf.save_to_bytes() + publish_to.publish_nowait(b''.join(glb_list_of_bytes)) + logger.info('export(%s) took %.3f seconds, %d parts', name, time.time() - start, + len(gltf.meshes[0].primitives)) # We should build it fully even if we are cancelled, so we use a separate task # Furthermore, building is CPU-bound, so we use the default executor @@ -278,57 +254,6 @@ class Server: # In either case return the elements of a subscription to the async generator subscription = self.object_events[name].subscribe() try: - async for chunk in subscription: - if chunk == b'': - break - yield chunk - finally: - await subscription.aclose() - - async def export_all(self) -> AsyncGenerator[bytes, None]: - """Export all previously shown objects to a single GLBS file, returned as an async generator. - - This is useful for fully-static deployments where the frontend handles everything.""" - # Check that the object to build exists and grab it if it does - all_object_names: List[str] = [] - total_export_size = 0 - subscription = self.show_events.subscribe(include_future=False) - try: - async for data in subscription: - all_object_names.append(data.name) - if data.obj is not None: - total_export_size += tessellate_count(data.obj) - else: - total_export_size += 1 - finally: - await subscription.aclose() - - # Create a generator that merges the export of all objects - async def _merge_exports() -> AsyncGenerator[bytes, None]: - for i, name in enumerate(all_object_names): - obj_subscription = self._export(name) - try: - obj = await anext(obj_subscription) - glb_parts = obj_subscription - if logger.isEnabledFor(logging.INFO): - total = tessellate_count(obj) if obj is not None else 1 - # noinspection PyTypeChecker - glb_parts = tqdm.asyncio.tqdm(obj_subscription, total=total) - async for glb_part in glb_parts: - yield glb_part - finally: - await obj_subscription.aclose() - - # Need to have a single subscription to all objects to write a valid GLBS file - subscription = _merge_exports() - try: - with logging_redirect_tqdm(tqdm_class=tqdm.asyncio.tqdm): - glbs_parts = subscription - if logger.isEnabledFor(logging.INFO): - # noinspection PyTypeChecker - glbs_parts = tqdm.asyncio.tqdm(glbs_parts, total=total_export_size, position=0) - glbs_parts = glb_sequence_to_glbs(glbs_parts, total_export_size) - async for glbs_part in glbs_parts: - yield glbs_part + return await anext(subscription) finally: await subscription.aclose() diff --git a/yacv_server/tessellate.py b/yacv_server/tessellate.py index 6ae0f15..fd31034 100644 --- a/yacv_server/tessellate.py +++ b/yacv_server/tessellate.py @@ -19,7 +19,7 @@ from build123d import Face, Vector, Shape, Vertex from pygltflib import LINE_STRIP, GLTF2, Material, PbrMetallicRoughness, TRIANGLES, POINTS, TextureInfo import mylogger -from gltf import create_gltf, _checkerboard_image +from gltf import GLTFMgr @dataclass @@ -55,57 +55,36 @@ def tessellate_count(ocp_shape: TopoDS_Shape) -> int: def tessellate( ocp_shape: TopoDS_Shape, - tolerance: float = 0.1, + tolerance: float = 1e-3, angular_tolerance: float = 0.1, -) -> Generator[TessellationUpdate, None, None]: - """Tessellate a whole shape into a list of triangle vertices and a list of triangle indices. - - NOTE: The logic of the method is weird because multiprocessing was tested, but it seems too inefficient - with slow native packages. - """ + faces: bool = True, + edges: bool = True, + vertices: bool = True, +) -> GLTF2: + """Tessellate a whole shape into a list of triangle vertices and a list of triangle indices.""" + mgr = GLTFMgr() shape = Shape(ocp_shape) - features = [] - # Submit tessellation tasks - for face in shape.faces(): - features.append(_tessellate_element(face.wrapped, tolerance, angular_tolerance)) - for edge in shape.edges(): - features.append(_tessellate_element(edge.wrapped, tolerance, angular_tolerance)) - for vertex in shape.vertices(): - features.append(_tessellate_element(vertex.wrapped, tolerance, angular_tolerance)) + # Perform tessellation tasks + if faces: + for face in shape.faces(): + _tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance) + if edges: + for edge in shape.edges(): + _tessellate_edge(mgr, edge.wrapped, tolerance, angular_tolerance) + if vertices: + for vertex in shape.vertices(): + _tessellate_vertex(mgr, vertex.wrapped) - # Collect results as they come in - for i, future in enumerate(features): - sub_shape, gltf = future - yield TessellationUpdate( - progress=(i + 1) / len(features), - shape=sub_shape, - gltf=gltf, - ) - - -# Define the function that will tessellate each element in parallel -def _tessellate_element( - element: TopoDS_Shape, tolerance: float, angular_tolerance: float) -> Tuple[TopoDS_Shape, GLTF2]: - if isinstance(element, TopoDS_Face): - return element, _tessellate_face(element, tolerance, angular_tolerance) - elif isinstance(element, TopoDS_Edge): - return element, _tessellate_edge(element, angular_tolerance, angular_tolerance) - elif isinstance(element, TopoDS_Vertex): - return element, _tessellate_vertex(element) - else: - raise ValueError(f"Unknown element type: {element}") - - -TriMesh = Tuple[list[Vector], list[Tuple[int, int, int]]] + return mgr.gltf def _tessellate_face( + mgr: GLTFMgr, ocp_face: TopoDS_Face, - tolerance: float = 0.1, + tolerance: float = 1e-3, angular_tolerance: float = 0.1 -) -> GLTF2: - """Tessellate a face into a list of triangle vertices and a list of triangle indices""" +): face = Face(ocp_face) face.mesh(tolerance, angular_tolerance) loc = TopLoc_Location() @@ -124,19 +103,15 @@ def _tessellate_face( vertices = np.array(list(map(lambda v: [v.X, v.Y, v.Z], tri_mesh[0]))) indices = np.array(tri_mesh[1]) tex_coord = np.array(uv) - mode = TRIANGLES - material = Material(pbrMetallicRoughness=PbrMetallicRoughness( - baseColorFactor=[0.3, 1.0, 0.2, 1.0], metallicFactor=0.1, baseColorTexture=TextureInfo(index=0)), - alphaCutoff=None) - return create_gltf(vertices, indices, tex_coord, mode, material, images=[_checkerboard_image]) + mgr.add_face(vertices, indices, tex_coord) def _tessellate_edge( + mgr: GLTFMgr, ocp_edge: TopoDS_Edge, - angular_deflection: float = 0.1, + angular_deflection: float = 1e-3, curvature_deflection: float = 0.1, -) -> GLTF2: - """Tessellate a wire or edge into a list of ordered vertices""" +): curve = BRepAdaptor_Curve(ocp_edge) discretizer = GCPnts_TangentialDeflection(curve, angular_deflection, curvature_deflection) assert discretizer.NbPoints() > 1, "Edge is too small??" @@ -151,26 +126,12 @@ def _tessellate_edge( for i in range(1, discretizer.NbPoints() + 1) ) ] - indices = np.array(list(map(lambda i: [i, i + 1], range(len(vertices) - 1))), dtype=np.uint8) - tex_coord = np.array([], dtype=np.float32) - mode = LINE_STRIP - material = Material( - pbrMetallicRoughness=PbrMetallicRoughness(baseColorFactor=[0.0, 0.0, 0.3, 1.0]), - alphaCutoff=None) - return create_gltf(np.array(vertices), indices, tex_coord, mode, material) + mgr.add_edge(np.array(vertices)) -def _tessellate_vertex(ocp_vertex: TopoDS_Vertex) -> GLTF2: - """Tessellate a vertex into a list of triangle vertices and a list of triangle indices""" +def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex): c = Vertex(ocp_vertex).center() - vertices = np.array([[c.X, c.Y, c.Z]]) - indices = np.array([0]) - tex_coord = np.array([], dtype=np.float32) - mode = POINTS - material = Material( - pbrMetallicRoughness=PbrMetallicRoughness(baseColorFactor=[1.0, 0.5, 0.5, 1.0]), - alphaCutoff=None) - return create_gltf(vertices, indices, tex_coord, mode, material) + mgr.add_vertex(c) def _hashcode(obj: TopoDS_Shape) -> str: