Minor improvements to custom textures feature

This commit is contained in:
Yeicor
2024-11-09 18:20:46 +01:00
parent 822672c288
commit 874f9e8d6e
6 changed files with 95 additions and 73 deletions

View File

@@ -3,7 +3,7 @@ https://www.npmjs.com/package/generate-license-file
The following npm package may be included in this product: The following npm package may be included in this product:
- @google/model-viewer@3.5.0 - @google/model-viewer@4.0.0
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -980,7 +980,7 @@ third-party archives.
The following npm package may be included in this product: The following npm package may be included in this product:
- typescript@5.6.2 - typescript@5.6.3
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1458,7 +1458,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- three-mesh-bvh@0.8.0 - three-mesh-bvh@0.8.2
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1692,7 +1692,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- three@0.163.0 - three@0.169.0
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1782,7 +1782,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- vuetify@3.7.2 - vuetify@3.7.3
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1812,16 +1812,16 @@ THE SOFTWARE.
The following npm packages may be included in this product: The following npm packages may be included in this product:
- @vue/compiler-core@3.5.7 - @vue/compiler-core@3.5.12
- @vue/compiler-dom@3.5.7 - @vue/compiler-dom@3.5.12
- @vue/compiler-sfc@3.5.7 - @vue/compiler-sfc@3.5.12
- @vue/compiler-ssr@3.5.7 - @vue/compiler-ssr@3.5.12
- @vue/reactivity@3.5.7 - @vue/reactivity@3.5.12
- @vue/runtime-core@3.5.7 - @vue/runtime-core@3.5.12
- @vue/runtime-dom@3.5.7 - @vue/runtime-dom@3.5.12
- @vue/server-renderer@3.5.7 - @vue/server-renderer@3.5.12
- @vue/shared@3.5.7 - @vue/shared@3.5.12
- vue@3.5.7 - vue@3.5.12
These packages each contain the following license and notice below: These packages each contain the following license and notice below:
@@ -1851,7 +1851,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- ktx-parse@0.7.0 - ktx-parse@0.7.1
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1913,9 +1913,9 @@ SOFTWARE.
The following npm packages may be included in this product: The following npm packages may be included in this product:
- @gltf-transform/core@4.0.8 - @gltf-transform/core@4.0.10
- @gltf-transform/extensions@4.0.8 - @gltf-transform/extensions@4.0.10
- @gltf-transform/functions@4.0.8 - @gltf-transform/functions@4.0.10
These packages each contain the following license and notice below: These packages each contain the following license and notice below:

View File

@@ -22,8 +22,11 @@ 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 = Compound(to_highlight).translate((0, 0, 1e-3)) # To avoid z-fighting
example_hl.color = (1, 1, .0, 1) example_hl.color = (1, 1, .0, 1)
# Show it in the frontend with hot-reloading # Show it in the frontend with hot-reloading (texture and other keyword arguments are optional)
show(example, example_hl) texture = ( # MIT License Framework7 Line Icons: https://www.svgrepo.com/svg/437552/checkmark-seal
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASAQAAAAB+tbP6AAAAQ0lEQVQI12P4b3+A4Z/8AYYHBw8w"
"HHxwgOH8HyD+AsRPDjDMP+fAYD+fgcESiGfYOTCcqTnAcK4GogakFqQHpBdoBgAbGiPSbdzkhgAAAABJRU5ErkJggg==")
show(example, example_hl, texture=texture)
# %% # %%

View File

@@ -19,19 +19,20 @@ CADLike = Union[CADCoreLike, any] # build123d and cadquery types
ColorTuple = Tuple[float, float, float, float] ColorTuple = Tuple[float, float, float, float]
def get_color(obj: CADLike) -> Optional[ColorTuple]: def get_color(obj: any) -> Optional[ColorTuple]:
"""Get color from a CAD Object""" """Get color from a CAD Object or any other color-like object"""
if 'color' in dir(obj): if 'color' in dir(obj):
if isinstance(obj.color, tuple): obj = obj.color
c = None if isinstance(obj, tuple):
if len(obj.color) == 3: c = None
c = obj.color + (1,) if len(obj) == 3:
elif len(obj.color) == 4: c = obj + (1,)
c = obj.color elif len(obj) == 4:
# noinspection PyTypeChecker c = obj
return [min(max(float(x), 0), 1) for x in c] # noinspection PyTypeChecker
if isinstance(obj.color, Color): return [min(max(float(x), 0), 1) for x in c]
return obj.color.to_tuple() if isinstance(obj, Color):
return obj.to_tuple()
return None return None
@@ -181,7 +182,7 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
return b''.join(mgr.build().save_to_bytes()), name return b''.join(mgr.build().save_to_bytes()), name
def _hashcode(obj: Union[bytes, CADCoreLike], color: Optional[ColorTuple], **extras) -> str: def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str:
"""Utility to compute the STABLE hash code of a shape""" """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 # 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 # This is best-effort and not guaranteed to be unique
@@ -189,8 +190,6 @@ def _hashcode(obj: Union[bytes, CADCoreLike], color: Optional[ColorTuple], **ext
for k, v in extras.items(): for k, v in extras.items():
hasher.update(str(k).encode()) hasher.update(str(k).encode())
hasher.update(str(v).encode()) hasher.update(str(v).encode())
if color:
hasher.update(str(color).encode())
if isinstance(obj, bytes): if isinstance(obj, bytes):
hasher.update(obj) hasher.update(obj)
elif isinstance(obj, TopLoc_Location): elif isinstance(obj, TopLoc_Location):

View File

@@ -18,7 +18,6 @@ def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]:
with BuildSketch(text_at_plane.location): with BuildSketch(text_at_plane.location):
Text('Yet Another\nCAD Viewer', 6, font_path='/usr/share/fonts/TTF/Hack-Regular.ttf') Text('Yet Another\nCAD Viewer', 6, font_path='/usr/share/fonts/TTF/Hack-Regular.ttf')
extrude(amount=1) extrude(amount=1)
logo_obj.color = (0.7, 0.4, 0.1, 1) # Custom color for faces
# Highlight text edges with a custom color # Highlight text edges with a custom color
to_highlight = logo_obj.edges().group_by(Axis.X)[-1] to_highlight = logo_obj.edges().group_by(Axis.X)[-1]

View File

@@ -21,13 +21,14 @@ def tessellate(
edges: bool = True, edges: bool = True,
vertices: bool = True, vertices: bool = True,
obj_color: Optional[ColorTuple] = None, obj_color: Optional[ColorTuple] = None,
base_texture: Optional[Tuple[bytes, str]] = None, texture: Optional[Tuple[bytes, str]] = None,
) -> GLTF2: ) -> GLTF2:
"""Tessellate a whole shape into a list of triangle vertices and a list of triangle indices.""" """Tessellate a whole shape into a list of triangle vertices and a list of triangle indices."""
if base_texture is None: print("tessellate, obj_color: ", obj_color)
if texture is None:
mgr = GLTFMgr() mgr = GLTFMgr()
else: else:
mgr = GLTFMgr(base_texture) mgr = GLTFMgr(texture)
if isinstance(cad_like, TopLoc_Location): if isinstance(cad_like, TopLoc_Location):
mgr.add_location(Location(cad_like)) mgr.add_location(Location(cad_like))

View File

@@ -1,4 +1,5 @@
import atexit import atexit
import base64
import copy import copy
import inspect import inspect
import os import os
@@ -47,19 +48,15 @@ YACVSupported = Union[bytes, CADCoreLike]
class UpdatesApiFullData(UpdatesApiData): class UpdatesApiFullData(UpdatesApiData):
obj: YACVSupported obj: YACVSupported
"""The OCCT object (not serialized)""" """The OCCT object (not serialized)"""
color: Optional[ColorTuple]
"""The color of the object, if any (not serialized)"""
kwargs: Optional[Dict[str, any]] kwargs: Optional[Dict[str, any]]
"""The show_object options, if any (not serialized)""" """The show_object options, if any (not serialized)"""
def __init__(self, obj: YACVSupported, name: str, _hash: str, is_remove: Optional[bool] = False, def __init__(self, obj: YACVSupported, name: str, _hash: str, is_remove: Optional[bool] = False,
color: Optional[ColorTuple] = None,
kwargs: Optional[Dict[str, any]] = None): kwargs: Optional[Dict[str, any]] = None):
self.name = name self.name = name
self.hash = _hash self.hash = _hash
self.is_remove = is_remove self.is_remove = is_remove
self.obj = obj self.obj = obj
self.color = color
self.kwargs = kwargs self.kwargs = kwargs
def to_json(self) -> str: def to_json(self) -> str:
@@ -94,9 +91,13 @@ class YACV:
frontend_lock: RWLock frontend_lock: RWLock
"""Lock to ensure that the frontend has finished working before we shut down""" """Lock to ensure that the frontend has finished working before we shut down"""
base_texture: Optional[Tuple[bytes, str]] texture: Optional[Tuple[bytes, str]]
"""Base texture to use for model rendering, in (data, mimetype) format """Default texture to use for model faces, in (data, mimetype) format.
If set to None, will use default checkerboard texture""" If left as None, a default checkerboard texture will be used.
It can be set with the YACV_BASE_TEXTURE=<uri> and overriden by `show(..., texture="<uri>")`.
The <uri> can be file:<path> or data:<mime>;base64,<data> where <mime> is the mime type and
<data> is the base64 encoded image."""
def __init__(self): def __init__(self):
self.server_thread = None self.server_thread = None
@@ -108,7 +109,7 @@ class YACV:
self.at_least_one_client = threading.Event() self.at_least_one_client = threading.Event()
self.shutting_down = threading.Event() self.shutting_down = threading.Event()
self.frontend_lock = RWLock() self.frontend_lock = RWLock()
self.base_texture = _resolve_base_texture() self.texture = _read_texture_uri(os.getenv("YACV_BASE_TEXTURE"))
logger.info('Using yacv-server v%s', get_version()) logger.info('Using yacv-server v%s', get_version())
def start(self): def start(self):
@@ -176,12 +177,32 @@ class YACV:
self.server.serve_forever() self.server.serve_forever()
def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]]] = None, **kwargs): def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]]] = None, **kwargs):
"""
Shows the given CAD objects in the frontend. The objects will be tessellated and converted to GLTF. Optionally,
the following keyword arguments can be used:
- auto_clear: Whether to clear the previous objects before showing the new ones (default: True)
- texture: The texture to use for the faces of the object (see `YACV.texture` for more info)
- color: The default color to use for the objects (can be overridden by the `color` attribute of each object)
- tolerance: The tolerance for tessellating the object (default: 0.1)
- angular_tolerance: The angular tolerance for tessellating the object (default: 0.1)
- faces: Whether to tessellate and show the faces of the object (default: True)
- edges: Whether to tessellate and show the edges of the object (default: True)
- vertices: Whether to tessellate and show the vertices of the object (default: True)
:param objs: The CAD objects to show. Can be CAD-like objects (solids, locations, etc.) or bytes (GLTF) objects.
:param names: The names of the objects. If None, the variable names will be used (if possible). The number of
names must match the number of objects. An object of the same name will be replaced in the frontend.
:param kwargs: Additional options for the show_object event.
"""
# Prepare the arguments # Prepare the arguments
start = time.time() start = time.time()
names = names or [_find_var_name(obj) for obj in objs] names = names or [_find_var_name(obj) for obj in objs]
if isinstance(names, str): if isinstance(names, str):
names = [names] names = [names]
assert len(names) == len(objs), 'Number of names must match the number of objects' assert len(names) == len(objs), 'Number of names must match the number of objects'
if 'color' in kwargs:
kwargs['color'] = get_color(kwargs['color'])
# Handle auto clearing of previous objects # Handle auto clearing of previous objects
if kwargs.get('auto_clear', True): if kwargs.get('auto_clear', True):
@@ -196,17 +217,20 @@ class YACV:
# Publish the show event # Publish the show event
for obj, name in zip(objs, names): for obj, name in zip(objs, names):
color = get_color(obj) obj_color = get_color(obj)
if obj_color is not None:
kwargs = kwargs.copy()
kwargs['color'] = obj_color
if not isinstance(obj, bytes): if not isinstance(obj, bytes):
obj = _preprocess_cad(obj, **kwargs) obj = _preprocess_cad(obj, **kwargs)
_hash = _hashcode(obj, color, **kwargs) _hash = _hashcode(obj, **kwargs)
event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, color=color, kwargs=kwargs or {}) event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=kwargs or {})
self.show_events.publish(event) self.show_events.publish(event)
logger.info('show %s took %.3f seconds', names, time.time() - start) logger.info('show %s took %.3f seconds', names, time.time() - start)
def show_cad_all(self, **kwargs): def show_cad_all(self, **kwargs):
"""Publishes all CAD objects in the current scope to the server""" """Publishes all CAD objects in the current scope to the server. See `show` for more details."""
all_cad = list(grab_all_cad()) # List for reproducible iteration order all_cad = list(grab_all_cad()) # List for reproducible iteration order
self.show(*[cad for _, cad in all_cad], names=[name for name, _ in all_cad], **kwargs) self.show(*[cad for _, cad in all_cad], names=[name for name, _ in all_cad], **kwargs)
@@ -285,13 +309,17 @@ class YACV:
if isinstance(event.obj, bytes): # Already a GLTF if isinstance(event.obj, bytes): # Already a GLTF
publish_to.publish(event.obj) publish_to.publish(event.obj)
else: # CAD object to tessellate and convert to GLTF 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), gltf = tessellate(event.obj, tolerance=event.kwargs.get('tolerance', 0.1),
angular_tolerance=event.kwargs.get('angular_tolerance', 0.1), angular_tolerance=event.kwargs.get('angular_tolerance', 0.1),
faces=event.kwargs.get('faces', True), faces=event.kwargs.get('faces', True),
edges=event.kwargs.get('edges', True), edges=event.kwargs.get('edges', True),
vertices=event.kwargs.get('vertices', True), vertices=event.kwargs.get('vertices', True),
obj_color=event.color, obj_color=event.kwargs.get('color', None),
base_texture=self.base_texture) texture=texture_override or self.texture)
glb_list_of_bytes = gltf.save_to_bytes() glb_list_of_bytes = gltf.save_to_bytes()
glb_bytes = b''.join(glb_list_of_bytes) glb_bytes = b''.join(glb_list_of_bytes)
publish_to.publish(glb_bytes) publish_to.publish(glb_bytes)
@@ -315,31 +343,23 @@ class YACV:
f.write(self.export(name)[0]) f.write(self.export(name)[0])
def _resolve_base_texture() -> Optional[Tuple[bytes, str]]: def _read_texture_uri(uri: str) -> Optional[Tuple[bytes, str]]:
env_str = os.environ.get("YACV_BASE_TEXTURE") if uri is None:
if env_str is None:
return None return None
if env_str.startswith("file:"): if uri.startswith("file:"):
path = env_str[len("file:"):] path = uri[len("file:"):]
with open(path, 'rb') as f: with open(path, 'rb') as f:
data = f.read() data = f.read()
buf = BytesIO(data) buf = BytesIO(data)
img = Image.open(buf) img = Image.open(buf)
mtype = img.get_format_mimetype() mtype = img.get_format_mimetype()
return (data, mtype) return data, mtype
if env_str.startswith("base64-png:"): if uri.startswith("data:"): # https://en.wikipedia.org/wiki/Data_URI_scheme#Syntax (limited)
data = env_str[len("base64-png:"):] mtype_and_data = uri[len("data:"):]
data = base64.decodebytes(data.encode()) mtype = mtype_and_data.split(";", 1)[0]
return (data, 'image/png') data_str = mtype_and_data.split(",", 1)[1]
if env_str.startswith("preset:"): data = base64.b64decode(data_str)
preset = env_str[len("preset:"):] return data, mtype
color = Color(preset)
img = Image.new("RGBA", (16, 16))
color_tuple = tuple(int(i*256) for i in color.to_tuple())
img.paste(color_tuple, (0, 0, 16, 16))
buf = BytesIO()
img.save(buf, 'PNG')
return (buf.getvalue(), 'image/png')
return None return None
# noinspection PyUnusedLocal # noinspection PyUnusedLocal