From 274b759fe836cc33646244cb125d8b0667e94306 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Sun, 5 Jul 2026 14:47:26 +0100 Subject: [PATCH 1/7] feat: imlpement custom STL preview renderer from scratch --- src/tagstudio/qt/previews/renderer.py | 62 +++-- src/tagstudio/qt/previews/stl_renderer.py | 312 ++++++++++++++++++++++ 2 files changed, 346 insertions(+), 28 deletions(-) create mode 100644 src/tagstudio/qt/previews/stl_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index f3471e5f1..4d09edea2 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -10,6 +10,7 @@ import sqlite3 import struct import tarfile +import threading import xml.etree.ElementTree as ET import zipfile import zlib @@ -83,6 +84,7 @@ from tagstudio.qt.helpers.image_effects import replace_transparent_pixels from tagstudio.qt.helpers.text_wrapper import wrap_full_text from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color +from tagstudio.qt.previews.stl_renderer import StlRenderError, render_stl_thumbnail from tagstudio.qt.previews.vendored.blender_renderer import ( blend_thumb, # pyright: ignore[reportUnknownVariableType] ) @@ -101,6 +103,11 @@ Image.MAX_IMAGE_PIXELS = None register_heif_opener() +# TODO: Make these parameters configurable +_MAX_STL_FILE_SIZE = 20 * 1024 * 1024 # 20 MB +_MAX_STL_TRIANGLES = 100_000 +_pixmap_conversion_lock = threading.Lock() + try: import pillow_jxl # noqa: F401 # pyright: ignore except ImportError as e: @@ -1256,37 +1263,32 @@ def get_image(path: str) -> Image.Image | None: return im @staticmethod - def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: # pyright: ignore[reportUnusedParameter] + def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: """Render a thumbnail for an STL file. Args: filepath (Path): The path of the file. - size (tuple[int,int]): The size of the icon. + size (int): The size of the icon. """ - # TODO: Implement. - # The following commented code describes a method for rendering via - # matplotlib. - # This implementation did not play nice with multithreading. - im: Image.Image | None = None - # # Create a new plot - # matplotlib.use('agg') - # figure = plt.figure() - # axes = figure.add_subplot(projection='3d') - - # # Load the STL files and add the vectors to the plot - # your_mesh = mesh.Mesh.from_file(_filepath) - - # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) - # poly_collection.set_color((0,0,1)) # play with color - # scale = your_mesh.points.flatten() - # axes.auto_scale_xyz(scale, scale, scale) - # axes.add_collection3d(poly_collection) - # # plt.show() - # img_buf = io.BytesIO() - # plt.savefig(img_buf, format='png') - # im = Image.open(img_buf) + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + try: + return render_stl_thumbnail( + filepath=filepath, + size=size, + bg_color=bg_color, + max_file_size=_MAX_STL_FILE_SIZE, + max_triangles=_MAX_STL_TRIANGLES, + ) + except StlRenderError as e: + logger.info("Skipping STL thumbnail", filepath=filepath, error=str(e)) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im + return None @staticmethod def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None: @@ -1760,9 +1762,10 @@ def fetch_cached_image(file_name: Path): image = Image.new("RGBA", (128, 128), color="#FF00FF") # Convert the final image to a pixmap to emit. - qim = ImageQt.ImageQt(image) - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixel_ratio) + with _pixmap_conversion_lock: + qim = ImageQt.ImageQt(image) + pixmap = QPixmap.fromImage(qim) + pixmap.setDevicePixelRatio(pixel_ratio) self.updated_ratio.emit(image.size[0] / image.size[1]) if pixmap: self.updated.emit( @@ -1897,6 +1900,9 @@ def _render( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True ): image = self._blender(_filepath) + # 3D Models ==================================================== + elif ext == ".stl": + image = self._model_stl_thumb(_filepath, adj_size) # PDF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.PDF_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/previews/stl_renderer.py b/src/tagstudio/qt/previews/stl_renderer.py new file mode 100644 index 000000000..bfe27ead8 --- /dev/null +++ b/src/tagstudio/qt/previews/stl_renderer.py @@ -0,0 +1,312 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +from __future__ import annotations + +import math +import re +import struct +import threading +from pathlib import Path +from time import perf_counter + +import numpy as np +from PIL import Image, ImageDraw + +_BINARY_STL_HEADER_SIZE = 84 +_BINARY_STL_TRIANGLE_SIZE = 50 +_BINARY_STL_DTYPE = np.dtype( + [ + ("normal", " Image.Image: + """Render an STL file to a square thumbnail image.""" + file_size = filepath.stat().st_size + if file_size > max_file_size: + raise StlRenderError("STL file is too large") + + start_time = perf_counter() + header = _read_stl_header(filepath) + read_time = perf_counter() + triangles, source_triangle_count, stl_kind = _load_stl_triangles( + filepath, header, file_size, max_triangles + ) + load_time = perf_counter() + loaded_triangle_count = len(triangles) + triangles, normals = _prepare_triangles(triangles) + prepare_time = perf_counter() + if len(triangles) == 0: + raise StlRenderError("STL file contains no renderable triangles") + + projected, depths, normals = _project_triangles(triangles, normals, size) + project_time = perf_counter() + image, drawn_triangle_count = _rasterize(projected, depths, normals, size, bg_color) + raster_time = perf_counter() + + if _BENCHMARK_STL_RENDERER: + _print_benchmark( + filepath=filepath, + stl_kind=stl_kind, + file_size=file_size, + source_triangle_count=source_triangle_count, + loaded_triangle_count=loaded_triangle_count, + renderable_triangle_count=len(triangles), + drawn_triangle_count=drawn_triangle_count, + read_seconds=read_time - start_time, + load_seconds=load_time - read_time, + prepare_seconds=prepare_time - load_time, + project_seconds=project_time - prepare_time, + raster_seconds=raster_time - project_time, + total_seconds=raster_time - start_time, + ) + + return image + + +def _read_stl_header(filepath: Path) -> bytes: + with filepath.open("rb") as file: + return file.read(_BINARY_STL_HEADER_SIZE) + + +def _load_stl_triangles( + filepath: Path, header: bytes, file_size: int, max_triangles: int +) -> tuple[np.ndarray, int, str]: + if len(header) < _BINARY_STL_HEADER_SIZE: + raise StlRenderError("STL file is too small") + + triangle_count = struct.unpack_from(" max_triangles: + raise StlRenderError("STL file contains too many triangles") + triangles = _load_binary_stl_triangles(filepath, triangle_count) + return triangles, triangle_count, "binary" + + data = filepath.read_bytes() + trailing = data[expected_size:] if expected_size <= file_size else b"" + if expected_size < file_size and not trailing.strip(b"\x00\r\n\t "): + if triangle_count > max_triangles: + raise StlRenderError("STL file contains too many triangles") + triangles = _load_binary_stl_triangles(filepath, triangle_count) + return triangles, triangle_count, "binary" + + triangles, source_triangle_count = _load_ascii_stl_triangles(data, max_triangles) + return triangles, source_triangle_count, "ascii" + + +def _load_binary_stl_triangles(filepath: Path, triangle_count: int) -> np.ndarray: + records = np.memmap( + filepath, + dtype=_BINARY_STL_DTYPE, + mode="r", + offset=_BINARY_STL_HEADER_SIZE, + shape=(triangle_count,), + ) + vertices = records["vertices"] + triangles = vertices.astype(np.float32, copy=True) + del records + return triangles + + +def _load_ascii_stl_triangles(data: bytes, max_triangles: int) -> tuple[np.ndarray, int]: + vertex_lines = _ASCII_VERTEX_RE.findall(data) + source_triangle_count = len(vertex_lines) // 3 + + if len(vertex_lines) == 0 or len(vertex_lines) % 3: + raise StlRenderError("STL file contains no complete triangles") + if source_triangle_count > max_triangles: + raise StlRenderError("STL file contains too many triangles") + + vertex_text = b" ".join(vertex_lines).decode("ascii") + values = np.fromstring(vertex_text, dtype=np.float32, sep=" ") + if len(values) != len(vertex_lines) * 3: + raise StlRenderError("STL file contains an invalid vertex") + + triangles = values.reshape((-1, 3, 3)) + return triangles, source_triangle_count + + +def _print_benchmark( + filepath: Path, + stl_kind: str, + file_size: int, + source_triangle_count: int, + loaded_triangle_count: int, + renderable_triangle_count: int, + drawn_triangle_count: int, + read_seconds: float, + load_seconds: float, + prepare_seconds: float, + project_seconds: float, + raster_seconds: float, + total_seconds: float, +) -> None: + with _benchmark_print_lock: + print() # noqa: T201 + print("[STL Thumbnail Benchmark]") # noqa: T201 + print(f" file: {filepath}") # noqa: T201 + print(f" format: {stl_kind}") # noqa: T201 + print(f" size: {file_size / (1024 * 1024):.2f} MiB") # noqa: T201 + print(f" triangles: source={source_triangle_count:,}") # noqa: T201 + print(f" loaded={loaded_triangle_count:,}") # noqa: T201 + print(f" renderable={renderable_triangle_count:,}") # noqa: T201 + print(f" drawn={drawn_triangle_count:,}") # noqa: T201 + print(" timings:") # noqa: T201 + print(f" read: {read_seconds * 1000:8.2f} ms") # noqa: T201 + print(f" load: {load_seconds * 1000:8.2f} ms") # noqa: T201 + print(f" prepare: {prepare_seconds * 1000:8.2f} ms") # noqa: T201 + print(f" project: {project_seconds * 1000:8.2f} ms") # noqa: T201 + print(f" raster: {raster_seconds * 1000:8.2f} ms") # noqa: T201 + print(f" total: {total_seconds * 1000:8.2f} ms") # noqa: T201 + + +def _prepare_triangles(triangles: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + finite_mask = np.isfinite(triangles).all(axis=(1, 2)) + triangles = triangles[finite_mask] + if len(triangles) == 0: + return triangles, np.empty((0, 3), dtype=np.float32) + + edges_a = triangles[:, 1] - triangles[:, 0] + edges_b = triangles[:, 2] - triangles[:, 0] + normals = np.cross(edges_a, edges_b) + normal_lengths = np.linalg.norm(normals, axis=1) + valid_mask = normal_lengths > _MIN_TRIANGLE_AREA + triangles = triangles[valid_mask] + normals = normals[valid_mask] + normal_lengths = normal_lengths[valid_mask] + if len(triangles) == 0: + return triangles, np.empty((0, 3), dtype=np.float32) + + normals = normals / normal_lengths[:, np.newaxis] + + min_bounds = triangles.reshape((-1, 3)).min(axis=0) + max_bounds = triangles.reshape((-1, 3)).max(axis=0) + center = (min_bounds + max_bounds) * 0.5 + extent = float(np.max(max_bounds - min_bounds)) + if not math.isfinite(extent) or extent <= 0: + raise StlRenderError("STL mesh has zero extent") + + triangles = (triangles - center) / extent + return triangles.astype(np.float32, copy=False), normals.astype(np.float32, copy=False) + + +def _project_triangles( + triangles: np.ndarray, normals: np.ndarray, size: int +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + rotation = _thumbnail_rotation_matrix() + rotated = triangles @ rotation.T + rotated_normals = normals @ rotation.T + + points = rotated.reshape((-1, 3)) + min_xy = points[:, :2].min(axis=0) + max_xy = points[:, :2].max(axis=0) + center_xy = (min_xy + max_xy) * 0.5 + span = float(np.max(max_xy - min_xy)) + if not math.isfinite(span) or span <= 0: + raise StlRenderError("STL mesh has zero projected extent") + + scale = (size - 1) * _MODEL_PADDING / span + projected = np.empty((len(rotated), 3, 2), dtype=np.float32) + projected[:, :, 0] = ((rotated[:, :, 0] - center_xy[0]) * scale) + ((size - 1) * 0.5) + projected[:, :, 1] = ((center_xy[1] - rotated[:, :, 1]) * scale) + ((size - 1) * 0.5) + + return projected, rotated[:, :, 2].astype(np.float32), rotated_normals.astype(np.float32) + + +def _thumbnail_rotation_matrix() -> np.ndarray: + yaw = math.radians(35.0) + pitch = math.radians(-42.0) + cy = math.cos(yaw) + sy = math.sin(yaw) + cp = math.cos(pitch) + sp = math.sin(pitch) + + rotate_z = np.asarray([[cy, -sy, 0.0], [sy, cy, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32) + rotate_x = np.asarray([[1.0, 0.0, 0.0], [0.0, cp, -sp], [0.0, sp, cp]], dtype=np.float32) + return rotate_x @ rotate_z + + +def _rasterize( + projected: np.ndarray, + depths: np.ndarray, + normals: np.ndarray, + size: int, + bg_color: str, +) -> tuple[Image.Image, int]: + image = Image.new("RGB", (size, size), color=bg_color) + draw = ImageDraw.Draw(image) + base_color = np.asarray([150.0, 153.0, 163.0], dtype=np.float32) + light = np.asarray([0.35, -0.45, 0.82], dtype=np.float32) + light /= np.linalg.norm(light) + + intensities = 0.34 + (0.66 * np.abs(normals @ light)) + colors = np.clip(base_color * intensities[:, np.newaxis], 0, 255).astype(np.uint8) + triangle_indexes = _visible_triangle_indexes(normals, depths) + triangle_order = triangle_indexes[np.argsort(depths[triangle_indexes].mean(axis=1))] + rendered_any = False + drawn_triangle_count = 0 + + for index in triangle_order: + tri = projected[index] + if ( + tri[:, 0].max() < 0 + or tri[:, 0].min() >= size + or tri[:, 1].max() < 0 + or tri[:, 1].min() >= size + ): + continue + + color = tuple(int(channel) for channel in colors[index]) + draw.polygon([tuple(point) for point in tri], fill=color) + rendered_any = True + drawn_triangle_count += 1 + + if not rendered_any: + raise StlRenderError("STL mesh is outside the thumbnail frame") + + return image, drawn_triangle_count + + +def _visible_triangle_indexes(normals: np.ndarray, depths: np.ndarray) -> np.ndarray: + front_facing = normals[:, 2] > 0 + front_count = int(np.count_nonzero(front_facing)) + back_count = len(normals) - front_count + + if front_count > len(normals) * 0.25 and back_count > len(normals) * 0.25: + front_indexes = np.flatnonzero(front_facing) + back_indexes = np.flatnonzero(~front_facing) + front_depth = float(depths[front_indexes].mean()) + back_depth = float(depths[back_indexes].mean()) + return front_indexes if front_depth >= back_depth else back_indexes + + return np.arange(len(normals)) From 04b11e50f5bb3e62fe270a65a09c5defbd0ac369 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Mon, 6 Jul 2026 20:52:14 +0200 Subject: [PATCH 2/7] perf: optimize STL renderer by circumventing expensive PIL draw calls --- src/tagstudio/qt/previews/renderer.py | 4 +- src/tagstudio/qt/previews/stl_renderer.py | 59 +++++++++++++++++------ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 4d09edea2..5891c01b8 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -104,8 +104,8 @@ register_heif_opener() # TODO: Make these parameters configurable -_MAX_STL_FILE_SIZE = 20 * 1024 * 1024 # 20 MB -_MAX_STL_TRIANGLES = 100_000 +_MAX_STL_FILE_SIZE = 50 * 1024 * 1024 # 50 MB +_MAX_STL_TRIANGLES = 250_000 _pixmap_conversion_lock = threading.Lock() try: diff --git a/src/tagstudio/qt/previews/stl_renderer.py b/src/tagstudio/qt/previews/stl_renderer.py index bfe27ead8..7d1931bd1 100644 --- a/src/tagstudio/qt/previews/stl_renderer.py +++ b/src/tagstudio/qt/previews/stl_renderer.py @@ -11,7 +11,7 @@ from time import perf_counter import numpy as np -from PIL import Image, ImageDraw +from PIL import Image, ImageColor _BINARY_STL_HEADER_SIZE = 84 _BINARY_STL_TRIANGLE_SIZE = 50 @@ -263,8 +263,10 @@ def _rasterize( size: int, bg_color: str, ) -> tuple[Image.Image, int]: - image = Image.new("RGB", (size, size), color=bg_color) - draw = ImageDraw.Draw(image) + bg_rgb = ImageColor.getrgb(bg_color) + pixels = np.empty((size, size, 3), dtype=np.uint8) + pixels[:, :] = bg_rgb + base_color = np.asarray([150.0, 153.0, 163.0], dtype=np.float32) light = np.asarray([0.35, -0.45, 0.82], dtype=np.float32) light /= np.linalg.norm(light) @@ -273,28 +275,57 @@ def _rasterize( colors = np.clip(base_color * intensities[:, np.newaxis], 0, 255).astype(np.uint8) triangle_indexes = _visible_triangle_indexes(normals, depths) triangle_order = triangle_indexes[np.argsort(depths[triangle_indexes].mean(axis=1))] + + projected_list = projected.tolist() + colors_list = colors.tolist() rendered_any = False drawn_triangle_count = 0 - for index in triangle_order: - tri = projected[index] - if ( - tri[:, 0].max() < 0 - or tri[:, 0].min() >= size - or tri[:, 1].max() < 0 - or tri[:, 1].min() >= size - ): + for index in triangle_order.tolist(): + tri = projected_list[index] + xs = (tri[0][0], tri[1][0], tri[2][0]) + ys = (tri[0][1], tri[1][1], tri[2][1]) + if max(xs) < 0 or min(xs) >= size or max(ys) < 0 or min(ys) >= size: continue - color = tuple(int(channel) for channel in colors[index]) - draw.polygon([tuple(point) for point in tri], fill=color) + _fill_triangle(pixels, tri, size, colors_list[index]) rendered_any = True drawn_triangle_count += 1 if not rendered_any: raise StlRenderError("STL mesh is outside the thumbnail frame") - return image, drawn_triangle_count + return Image.fromarray(pixels, "RGB"), drawn_triangle_count + + +def _fill_triangle(pixels: np.ndarray, tri: list[list[float]], size: int, color: list[int]) -> None: + """Fill a single triangle directly into a pixel buffer via scanline conversion.""" + (ax, ay), (bx, by), (cx, cy) = tri + if ay > by: + ax, ay, bx, by = bx, by, ax, ay + if by > cy: + bx, by, cx, cy = cx, cy, bx, by + if ay > by: + ax, ay, bx, by = bx, by, ax, ay + + y_start = max(0, math.ceil(ay)) + y_end = min(size - 1, math.ceil(cy) - 1) + r, g, b = color + + for y in range(y_start, y_end + 1): + fy = float(y) + xa = ax if cy == ay else ax + (fy - ay) / (cy - ay) * (cx - ax) + if fy < by: + xb = ax if by == ay else ax + (fy - ay) / (by - ay) * (bx - ax) + else: + xb = bx if cy == by else bx + (fy - by) / (cy - by) * (cx - bx) + + x_start = max(0, math.ceil(min(xa, xb))) + x_end = min(size - 1, math.ceil(max(xa, xb)) - 1) + for x in range(x_start, x_end + 1): + pixels[y, x, 0] = r + pixels[y, x, 1] = g + pixels[y, x, 2] = b def _visible_triangle_indexes(normals: np.ndarray, depths: np.ndarray) -> np.ndarray: From 42bdf135348c872a6aa8e53643afb87a92c91602 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Mon, 6 Jul 2026 22:28:30 +0200 Subject: [PATCH 3/7] perf: optimize loading of ascii-formatted STL files --- src/tagstudio/qt/previews/stl_renderer.py | 80 ++++++++++++++--------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/src/tagstudio/qt/previews/stl_renderer.py b/src/tagstudio/qt/previews/stl_renderer.py index 7d1931bd1..a4aba33b8 100644 --- a/src/tagstudio/qt/previews/stl_renderer.py +++ b/src/tagstudio/qt/previews/stl_renderer.py @@ -4,7 +4,6 @@ from __future__ import annotations import math -import re import struct import threading from pathlib import Path @@ -14,7 +13,9 @@ from PIL import Image, ImageColor _BINARY_STL_HEADER_SIZE = 84 +_BINARY_STL_TRIANGLE_COUNT_OFFSET = 80 _BINARY_STL_TRIANGLE_SIZE = 50 +_BINARY_STL_TRAILING_CHARS_TO_IGNORE = b"\x00\r\n\t " _BINARY_STL_DTYPE = np.dtype( [ ("normal", " bytes: + """Reads the header of an STL file, avoiding a full file read.""" with filepath.open("rb") as file: return file.read(_BINARY_STL_HEADER_SIZE) @@ -100,31 +93,35 @@ def _read_stl_header(filepath: Path) -> bytes: def _load_stl_triangles( filepath: Path, header: bytes, file_size: int, max_triangles: int ) -> tuple[np.ndarray, int, str]: + """STL files come in either binary or ascii format. Figure out the format and parse the + triangles from the file.""" if len(header) < _BINARY_STL_HEADER_SIZE: raise StlRenderError("STL file is too small") - triangle_count = struct.unpack_from(" max_triangles: - raise StlRenderError("STL file contains too many triangles") - triangles = _load_binary_stl_triangles(filepath, triangle_count) + if file_size == expected_size_if_binary: + triangles = _load_binary_stl_triangles(filepath, triangle_count, max_triangles) return triangles, triangle_count, "binary" data = filepath.read_bytes() - trailing = data[expected_size:] if expected_size <= file_size else b"" - if expected_size < file_size and not trailing.strip(b"\x00\r\n\t "): - if triangle_count > max_triangles: - raise StlRenderError("STL file contains too many triangles") - triangles = _load_binary_stl_triangles(filepath, triangle_count) + rest = data[expected_size_if_binary:] if expected_size_if_binary <= file_size else b"" + rest_is_just_whitespaces = not rest.strip(_BINARY_STL_TRAILING_CHARS_TO_IGNORE) + if file_size > expected_size_if_binary and rest_is_just_whitespaces: + triangles = _load_binary_stl_triangles(filepath, triangle_count, max_triangles) return triangles, triangle_count, "binary" + # No sign of binary format found. Try parsing ascii-format instead. triangles, source_triangle_count = _load_ascii_stl_triangles(data, max_triangles) return triangles, source_triangle_count, "ascii" -def _load_binary_stl_triangles(filepath: Path, triangle_count: int) -> np.ndarray: +def _load_binary_stl_triangles(filepath: Path, triangle_count: int, max_triangles: int) -> np.ndarray: + if triangle_count > max_triangles: + raise StlRenderError("STL file contains too many triangles") + records = np.memmap( filepath, dtype=_BINARY_STL_DTYPE, @@ -138,19 +135,38 @@ def _load_binary_stl_triangles(filepath: Path, triangle_count: int) -> np.ndarra return triangles -def _load_ascii_stl_triangles(data: bytes, max_triangles: int) -> tuple[np.ndarray, int]: - vertex_lines = _ASCII_VERTEX_RE.findall(data) - source_triangle_count = len(vertex_lines) // 3 +def _split_on_vertex_marker(data: bytes) -> list[bytes]: + """Split on the "vertex" keyword, tolerating the upper/mixed case some exporters use.""" + for marker in _ASCII_VERTEX_MARKERS: + chunks = data.split(marker) + if len(chunks) > 1: + return chunks + return [data] + - if len(vertex_lines) == 0 or len(vertex_lines) % 3: - raise StlRenderError("STL file contains no complete triangles") +def _load_ascii_stl_triangles(data: bytes, max_triangles: int) -> tuple[np.ndarray, int]: + chunks = _split_on_vertex_marker(data) + vertex_count = len(chunks) - 1 + source_triangle_count = vertex_count // 3 + + if vertex_count == 0: + raise StlRenderError("STL file contains no triangles") + if vertex_count % 3: + raise StlRenderError("STL file contains incomplete triangles") if source_triangle_count > max_triangles: raise StlRenderError("STL file contains too many triangles") - vertex_text = b" ".join(vertex_lines).decode("ascii") - values = np.fromstring(vertex_text, dtype=np.float32, sep=" ") - if len(values) != len(vertex_lines) * 3: - raise StlRenderError("STL file contains an invalid vertex") + values = np.empty(vertex_count * 3, dtype=np.float32) + index = 0 + try: + for chunk in chunks[1:]: + x, y, z = chunk.split(None, 3)[:3] + values[index] = float(x) + values[index + 1] = float(y) + values[index + 2] = float(z) + index += 3 + except ValueError as error: + raise StlRenderError("STL file contains an invalid vertex") from error triangles = values.reshape((-1, 3, 3)) return triangles, source_triangle_count From c7bcfb6984ce2a72f387223b90f45d99ae0b72fa Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Mon, 6 Jul 2026 22:37:22 +0200 Subject: [PATCH 4/7] style: format stl_renderer.py with ruff --- src/tagstudio/qt/previews/stl_renderer.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/previews/stl_renderer.py b/src/tagstudio/qt/previews/stl_renderer.py index a4aba33b8..9c8e5cf4a 100644 --- a/src/tagstudio/qt/previews/stl_renderer.py +++ b/src/tagstudio/qt/previews/stl_renderer.py @@ -93,8 +93,10 @@ def _read_stl_header(filepath: Path) -> bytes: def _load_stl_triangles( filepath: Path, header: bytes, file_size: int, max_triangles: int ) -> tuple[np.ndarray, int, str]: - """STL files come in either binary or ascii format. Figure out the format and parse the - triangles from the file.""" + """STL files come in either binary or ascii format. + + Figure out the format and parse the triangles from the file. + """ if len(header) < _BINARY_STL_HEADER_SIZE: raise StlRenderError("STL file is too small") @@ -118,7 +120,9 @@ def _load_stl_triangles( return triangles, source_triangle_count, "ascii" -def _load_binary_stl_triangles(filepath: Path, triangle_count: int, max_triangles: int) -> np.ndarray: +def _load_binary_stl_triangles( + filepath: Path, triangle_count: int, max_triangles: int +) -> np.ndarray: if triangle_count > max_triangles: raise StlRenderError("STL file contains too many triangles") From 768625c2179d9a7be6f8dd7a2ec94ce748d1e0d5 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Sun, 20 Sep 2026 09:40:57 +0100 Subject: [PATCH 5/7] feat(STL renderer): fix visual artifacts + cleanup --- src/tagstudio/qt/previews/renderer.py | 8 +- src/tagstudio/qt/previews/stl_renderer.py | 220 +++++++++++----------- 2 files changed, 117 insertions(+), 111 deletions(-) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 5891c01b8..2494649b2 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -1284,11 +1284,11 @@ def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: max_triangles=_MAX_STL_TRIANGLES, ) except StlRenderError as e: - logger.info("Skipping STL thumbnail", filepath=filepath, error=str(e)) + logger.info("Skipping STL thumbnail", filename=filepath.name, error=str(e)) except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - - return None + logger.error( + "Couldn't render thumbnail", filename=filepath.name, error=type(e).__name__ + ) @staticmethod def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None: diff --git a/src/tagstudio/qt/previews/stl_renderer.py b/src/tagstudio/qt/previews/stl_renderer.py index 9c8e5cf4a..af12b1259 100644 --- a/src/tagstudio/qt/previews/stl_renderer.py +++ b/src/tagstudio/qt/previews/stl_renderer.py @@ -5,13 +5,15 @@ import math import struct -import threading from pathlib import Path from time import perf_counter import numpy as np +import structlog from PIL import Image, ImageColor +logger = structlog.get_logger(__name__) + _BINARY_STL_HEADER_SIZE = 84 _BINARY_STL_TRIANGLE_COUNT_OFFSET = 80 _BINARY_STL_TRIANGLE_SIZE = 50 @@ -26,14 +28,31 @@ _ASCII_VERTEX_MARKERS = (b"vertex", b"VERTEX", b"Vertex") _MODEL_PADDING = 0.86 _MIN_TRIANGLE_AREA = 1e-12 -_BENCHMARK_STL_RENDERER = True -_benchmark_print_lock = threading.Lock() +_MODEL_BASE_COLOR = np.asarray([150.0, 153.0, 163.0], dtype=np.float32) +_LIGHT_DIRECTION = np.asarray([0.35, -0.45, 0.82], dtype=np.float32) +_LIGHT_DIRECTION /= np.linalg.norm(_LIGHT_DIRECTION) +_AMBIENT_INTENSITY = 0.34 +_DIFFUSE_INTENSITY = 0.66 +_THUMBNAIL_YAW_DEGREES = 35.0 +_THUMBNAIL_PITCH_DEGREES = -42.0 class StlRenderError(ValueError): """Raised when an STL file cannot be loaded or rendered.""" +def _parse_bg_color(bg_color: str) -> tuple[int, int, int]: + """Parses `bg_color` into an RGB triple. + + Raises ValueError rather than StlRenderError: an invalid color is a + caller argument mistake, not a problem with the STL file being rendered. + """ + rgb = ImageColor.getrgb(bg_color) + if len(rgb) != 3: + raise ValueError(f"bg_color must resolve to an RGB triple, got {bg_color!r}") + return rgb + + def render_stl_thumbnail( filepath: Path, size: int, @@ -41,7 +60,9 @@ def render_stl_thumbnail( max_file_size: int, max_triangles: int, ) -> Image.Image: - """Render an STL file to a square thumbnail image.""" + """Render an STL file to a square thumbnail image, with orthographic projection.""" + bg_rgb = _parse_bg_color(bg_color) + file_size = filepath.stat().st_size if file_size > max_file_size: raise StlRenderError("STL file is too large") @@ -61,25 +82,25 @@ def render_stl_thumbnail( projected, depths, normals = _project_triangles(triangles, normals, size) project_time = perf_counter() - image, drawn_triangle_count = _rasterize(projected, depths, normals, size, bg_color) + image, drawn_triangle_count = _rasterize(projected, depths, normals, size, bg_rgb) raster_time = perf_counter() - if _BENCHMARK_STL_RENDERER: - _print_benchmark( - filepath=filepath, - stl_kind=stl_kind, - file_size=file_size, - source_triangle_count=source_triangle_count, - loaded_triangle_count=loaded_triangle_count, - renderable_triangle_count=len(triangles), - drawn_triangle_count=drawn_triangle_count, - read_seconds=read_time - start_time, - load_seconds=load_time - read_time, - prepare_seconds=prepare_time - load_time, - project_seconds=project_time - prepare_time, - raster_seconds=raster_time - project_time, - total_seconds=raster_time - start_time, - ) + logger.debug( + "[STL Renderer] Rendered thumbnail", + filename=filepath.name, + stl_kind=stl_kind, + file_size=file_size, + source_triangle_count=source_triangle_count, + loaded_triangle_count=loaded_triangle_count, + renderable_triangle_count=len(triangles), + drawn_triangle_count=drawn_triangle_count, + read_seconds=round(read_time - start_time, 4), + load_seconds=round(load_time - read_time, 4), + prepare_seconds=round(prepare_time - load_time, 4), + project_seconds=round(project_time - prepare_time, 4), + raster_seconds=round(raster_time - project_time, 4), + total_seconds=round(raster_time - start_time, 4), + ) return image @@ -108,14 +129,16 @@ def _load_stl_triangles( triangles = _load_binary_stl_triangles(filepath, triangle_count, max_triangles) return triangles, triangle_count, "binary" - data = filepath.read_bytes() - rest = data[expected_size_if_binary:] if expected_size_if_binary <= file_size else b"" - rest_is_just_whitespaces = not rest.strip(_BINARY_STL_TRAILING_CHARS_TO_IGNORE) - if file_size > expected_size_if_binary and rest_is_just_whitespaces: - triangles = _load_binary_stl_triangles(filepath, triangle_count, max_triangles) - return triangles, triangle_count, "binary" + if expected_size_if_binary < file_size: + with filepath.open("rb") as file: + file.seek(expected_size_if_binary) + trailing = file.read(file_size - expected_size_if_binary) + if not trailing.strip(_BINARY_STL_TRAILING_CHARS_TO_IGNORE): + triangles = _load_binary_stl_triangles(filepath, triangle_count, max_triangles) + return triangles, triangle_count, "binary" # No sign of binary format found. Try parsing ascii-format instead. + data = filepath.read_bytes() triangles, source_triangle_count = _load_ascii_stl_triangles(data, max_triangles) return triangles, source_triangle_count, "ascii" @@ -133,8 +156,7 @@ def _load_binary_stl_triangles( offset=_BINARY_STL_HEADER_SIZE, shape=(triangle_count,), ) - vertices = records["vertices"] - triangles = vertices.astype(np.float32, copy=True) + triangles = records["vertices"].astype(np.float32, copy=True) del records return triangles @@ -164,6 +186,8 @@ def _load_ascii_stl_triangles(data: bytes, max_triangles: int) -> tuple[np.ndarr index = 0 try: for chunk in chunks[1:]: + # maxsplit=3: chunk runs until the next vertex marker, so an unbounded + # split would rescan the rest of the file for every vertex. x, y, z = chunk.split(None, 3)[:3] values[index] = float(x) values[index + 1] = float(y) @@ -176,40 +200,6 @@ def _load_ascii_stl_triangles(data: bytes, max_triangles: int) -> tuple[np.ndarr return triangles, source_triangle_count -def _print_benchmark( - filepath: Path, - stl_kind: str, - file_size: int, - source_triangle_count: int, - loaded_triangle_count: int, - renderable_triangle_count: int, - drawn_triangle_count: int, - read_seconds: float, - load_seconds: float, - prepare_seconds: float, - project_seconds: float, - raster_seconds: float, - total_seconds: float, -) -> None: - with _benchmark_print_lock: - print() # noqa: T201 - print("[STL Thumbnail Benchmark]") # noqa: T201 - print(f" file: {filepath}") # noqa: T201 - print(f" format: {stl_kind}") # noqa: T201 - print(f" size: {file_size / (1024 * 1024):.2f} MiB") # noqa: T201 - print(f" triangles: source={source_triangle_count:,}") # noqa: T201 - print(f" loaded={loaded_triangle_count:,}") # noqa: T201 - print(f" renderable={renderable_triangle_count:,}") # noqa: T201 - print(f" drawn={drawn_triangle_count:,}") # noqa: T201 - print(" timings:") # noqa: T201 - print(f" read: {read_seconds * 1000:8.2f} ms") # noqa: T201 - print(f" load: {load_seconds * 1000:8.2f} ms") # noqa: T201 - print(f" prepare: {prepare_seconds * 1000:8.2f} ms") # noqa: T201 - print(f" project: {project_seconds * 1000:8.2f} ms") # noqa: T201 - print(f" raster: {raster_seconds * 1000:8.2f} ms") # noqa: T201 - print(f" total: {total_seconds * 1000:8.2f} ms") # noqa: T201 - - def _prepare_triangles(triangles: np.ndarray) -> tuple[np.ndarray, np.ndarray]: finite_mask = np.isfinite(triangles).all(axis=(1, 2)) triangles = triangles[finite_mask] @@ -264,8 +254,8 @@ def _project_triangles( def _thumbnail_rotation_matrix() -> np.ndarray: - yaw = math.radians(35.0) - pitch = math.radians(-42.0) + yaw = math.radians(_THUMBNAIL_YAW_DEGREES) + pitch = math.radians(_THUMBNAIL_PITCH_DEGREES) cy = math.cos(yaw) sy = math.sin(yaw) cp = math.cos(pitch) @@ -281,36 +271,37 @@ def _rasterize( depths: np.ndarray, normals: np.ndarray, size: int, - bg_color: str, + bg_rgb: tuple[int, int, int], ) -> tuple[Image.Image, int]: - bg_rgb = ImageColor.getrgb(bg_color) pixels = np.empty((size, size, 3), dtype=np.uint8) pixels[:, :] = bg_rgb + depth_buffer = np.full((size, size), -np.inf, dtype=np.float32) - base_color = np.asarray([150.0, 153.0, 163.0], dtype=np.float32) - light = np.asarray([0.35, -0.45, 0.82], dtype=np.float32) - light /= np.linalg.norm(light) - - intensities = 0.34 + (0.66 * np.abs(normals @ light)) - colors = np.clip(base_color * intensities[:, np.newaxis], 0, 255).astype(np.uint8) - triangle_indexes = _visible_triangle_indexes(normals, depths) - triangle_order = triangle_indexes[np.argsort(depths[triangle_indexes].mean(axis=1))] + intensities = _AMBIENT_INTENSITY + (_DIFFUSE_INTENSITY * np.abs(normals @ _LIGHT_DIRECTION)) + colors = np.clip(_MODEL_BASE_COLOR * intensities[:, np.newaxis], 0, 255).astype(np.uint8) projected_list = projected.tolist() + depths_list = depths.tolist() colors_list = colors.tolist() rendered_any = False drawn_triangle_count = 0 - for index in triangle_order.tolist(): - tri = projected_list[index] - xs = (tri[0][0], tri[1][0], tri[2][0]) - ys = (tri[0][1], tri[1][1], tri[2][1]) + for index in range(len(projected_list)): + xy = projected_list[index] + xs = (xy[0][0], xy[1][0], xy[2][0]) + ys = (xy[0][1], xy[1][1], xy[2][1]) if max(xs) < 0 or min(xs) >= size or max(ys) < 0 or min(ys) >= size: continue - _fill_triangle(pixels, tri, size, colors_list[index]) - rendered_any = True - drawn_triangle_count += 1 + tri_z = depths_list[index] + tri = ( + (xy[0][0], xy[0][1], tri_z[0]), + (xy[1][0], xy[1][1], tri_z[1]), + (xy[2][0], xy[2][1], tri_z[2]), + ) + if _fill_triangle(pixels, depth_buffer, tri, size, colors_list[index]): + rendered_any = True + drawn_triangle_count += 1 if not rendered_any: raise StlRenderError("STL mesh is outside the thumbnail frame") @@ -318,46 +309,61 @@ def _rasterize( return Image.fromarray(pixels, "RGB"), drawn_triangle_count -def _fill_triangle(pixels: np.ndarray, tri: list[list[float]], size: int, color: list[int]) -> None: - """Fill a single triangle directly into a pixel buffer via scanline conversion.""" - (ax, ay), (bx, by), (cx, cy) = tri +def _fill_triangle( + pixels: np.ndarray, + depth_buffer: np.ndarray, + tri: tuple[tuple[float, float, float], ...], + size: int, + color: list[int], +) -> bool: + """Fill a single triangle into a pixel buffer via scanline conversion.""" + (ax, ay, az), (bx, by, bz), (cx, cy, cz) = tri if ay > by: - ax, ay, bx, by = bx, by, ax, ay + ax, ay, az, bx, by, bz = bx, by, bz, ax, ay, az if by > cy: - bx, by, cx, cy = cx, cy, bx, by + bx, by, bz, cx, cy, cz = cx, cy, cz, bx, by, bz if ay > by: - ax, ay, bx, by = bx, by, ax, ay + ax, ay, az, bx, by, bz = bx, by, bz, ax, ay, az y_start = max(0, math.ceil(ay)) y_end = min(size - 1, math.ceil(cy) - 1) r, g, b = color + drew_any = False + # a/b/c are the triangle's vertices sorted by y; ta/tb interpolate the left/right + # edge x and z at each scanline. for y in range(y_start, y_end + 1): fy = float(y) - xa = ax if cy == ay else ax + (fy - ay) / (cy - ay) * (cx - ax) + ta = 0.0 if cy == ay else (fy - ay) / (cy - ay) + xa = ax + ta * (cx - ax) + za = az + ta * (cz - az) if fy < by: - xb = ax if by == ay else ax + (fy - ay) / (by - ay) * (bx - ax) + tb = 0.0 if by == ay else (fy - ay) / (by - ay) + xb = ax + tb * (bx - ax) + zb = az + tb * (bz - az) else: - xb = bx if cy == by else bx + (fy - by) / (cy - by) * (cx - bx) + tb = 0.0 if cy == by else (fy - by) / (cy - by) + xb = bx + tb * (cx - bx) + zb = bz + tb * (cz - bz) + + if xa > xb: + xa, xb = xb, xa + za, zb = zb, za - x_start = max(0, math.ceil(min(xa, xb))) - x_end = min(size - 1, math.ceil(max(xa, xb)) - 1) + x_start = max(0, math.ceil(xa)) + x_end = min(size - 1, math.ceil(xb) - 1) for x in range(x_start, x_end + 1): + tx = 0.0 if xb == xa else (x - xa) / (xb - xa) + # Linear z interpolation is only correct because the projection is + # orthographic; this test makes triangles occlude correctly regardless + # of draw order. + z = za + tx * (zb - za) + if z <= depth_buffer[y, x]: + continue + depth_buffer[y, x] = z pixels[y, x, 0] = r pixels[y, x, 1] = g pixels[y, x, 2] = b + drew_any = True - -def _visible_triangle_indexes(normals: np.ndarray, depths: np.ndarray) -> np.ndarray: - front_facing = normals[:, 2] > 0 - front_count = int(np.count_nonzero(front_facing)) - back_count = len(normals) - front_count - - if front_count > len(normals) * 0.25 and back_count > len(normals) * 0.25: - front_indexes = np.flatnonzero(front_facing) - back_indexes = np.flatnonzero(~front_facing) - front_depth = float(depths[front_indexes].mean()) - back_depth = float(depths[back_indexes].mean()) - return front_indexes if front_depth >= back_depth else back_indexes - - return np.arange(len(normals)) + return drew_any From 04ad86f4b027c46015b13b241a9457b58df1a562 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Sun, 20 Sep 2026 11:36:18 +0100 Subject: [PATCH 6/7] feat: move stl rendering code into the previews/renderers/stl.py --- src/tagstudio/previews/renderers/stl.py | 372 +++++++++++++++++++++++- src/tagstudio/previews/stl_renderer.py | 369 ----------------------- 2 files changed, 365 insertions(+), 376 deletions(-) delete mode 100644 src/tagstudio/previews/stl_renderer.py diff --git a/src/tagstudio/previews/renderers/stl.py b/src/tagstudio/previews/renderers/stl.py index 3146f8880..4cfed0dc2 100644 --- a/src/tagstudio/previews/renderers/stl.py +++ b/src/tagstudio/previews/renderers/stl.py @@ -1,17 +1,21 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: MIT +from __future__ import annotations +import math +import struct from pathlib import Path +from time import perf_counter from typing import override +import numpy as np import structlog -from PIL.Image import Image +from PIL import Image, ImageColor from tagstudio.core.enums import Theme from tagstudio.core.media_types import MediaTypes from tagstudio.previews.base_preview import RENDER, BasePreview -from tagstudio.previews.stl_renderer import StlRenderError, render_stl_thumbnail logger = structlog.get_logger(__name__) @@ -19,6 +23,32 @@ _MAX_STL_FILE_SIZE = 50 * 1024 * 1024 # 50 MB _MAX_STL_TRIANGLES = 250_000 +_BINARY_STL_HEADER_SIZE = 84 +_BINARY_STL_TRIANGLE_COUNT_OFFSET = 80 +_BINARY_STL_TRIANGLE_SIZE = 50 +_BINARY_STL_TRAILING_CHARS_TO_IGNORE = b"\x00\r\n\t " +_BINARY_STL_DTYPE = np.dtype( + [ + ("normal", " Image | None: + ) -> Image.Image | None: return _stl_thumb(filepath, theme, size) -def _stl_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> Image | None: +def _stl_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> Image.Image | None: """Render a thumbnail for an STL file. Args: @@ -51,17 +81,345 @@ def _stl_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> Image | N size (tuple[int, int]): The target size of the thumbnail. """ bg_color: str = "#1e1e1e" if theme == Theme.DARK else "#FFFFFF" - im: Image | None = None + im: Image.Image | None = None try: - im = render_stl_thumbnail( + im = _render_stl_thumbnail( filepath=filepath, size=max(size), bg_color=bg_color, max_file_size=_MAX_STL_FILE_SIZE, max_triangles=_MAX_STL_TRIANGLES, ) - except StlRenderError as e: + except STLRenderError as e: logger.info("Skipping STL thumbnail", filename=filepath.name, error=str(e)) except Exception as e: logger.error("Couldn't render thumbnail", filename=filepath.name, error=type(e).__name__) return im + + +def _parse_bg_color(bg_color: str) -> tuple[int, int, int]: + """Parses `bg_color` into an RGB triple. + + Raises ValueError rather than STLRenderError: an invalid color is a + caller argument mistake, not a problem with the STL file being rendered. + """ + rgb = ImageColor.getrgb(bg_color) + if len(rgb) != 3: + raise ValueError(f"bg_color must resolve to an RGB triple, got {bg_color!r}") + return rgb + + +def _render_stl_thumbnail( + filepath: Path, + size: int, + bg_color: str, + max_file_size: int, + max_triangles: int, +) -> Image.Image: + """Render an STL file to a square thumbnail image, with orthographic projection.""" + bg_rgb = _parse_bg_color(bg_color) + + file_size = filepath.stat().st_size + if file_size > max_file_size: + raise STLRenderError("STL file is too large") + + start_time = perf_counter() + header = _read_stl_header(filepath) + read_time = perf_counter() + triangles, source_triangle_count, stl_kind = _load_stl_triangles( + filepath, header, file_size, max_triangles + ) + load_time = perf_counter() + loaded_triangle_count = len(triangles) + triangles, normals = _prepare_triangles(triangles) + prepare_time = perf_counter() + if len(triangles) == 0: + raise STLRenderError("STL file contains no renderable triangles") + + projected, depths, normals = _project_triangles(triangles, normals, size) + project_time = perf_counter() + image, drawn_triangle_count = _rasterize(projected, depths, normals, size, bg_rgb) + raster_time = perf_counter() + + logger.debug( + "[STL Renderer] Rendered thumbnail", + filename=filepath.name, + stl_kind=stl_kind, + file_size=file_size, + source_triangle_count=source_triangle_count, + loaded_triangle_count=loaded_triangle_count, + renderable_triangle_count=len(triangles), + drawn_triangle_count=drawn_triangle_count, + read_seconds=round(read_time - start_time, 4), + load_seconds=round(load_time - read_time, 4), + prepare_seconds=round(prepare_time - load_time, 4), + project_seconds=round(project_time - prepare_time, 4), + raster_seconds=round(raster_time - project_time, 4), + total_seconds=round(raster_time - start_time, 4), + ) + + return image + + +def _read_stl_header(filepath: Path) -> bytes: + """Reads the header of an STL file, avoiding a full file read.""" + with filepath.open("rb") as file: + return file.read(_BINARY_STL_HEADER_SIZE) + + +def _load_stl_triangles( + filepath: Path, header: bytes, file_size: int, max_triangles: int +) -> tuple[np.ndarray, int, str]: + """STL files come in either binary or ascii format. + + Figure out the format and parse the triangles from the file. + """ + if len(header) < _BINARY_STL_HEADER_SIZE: + raise STLRenderError("STL file is too small") + + # Assume binary format. Validate by reading tri count and checking against file size. + triangle_count = struct.unpack_from(" np.ndarray: + if triangle_count > max_triangles: + raise STLRenderError("STL file contains too many triangles") + + records = np.memmap( + filepath, + dtype=_BINARY_STL_DTYPE, + mode="r", + offset=_BINARY_STL_HEADER_SIZE, + shape=(triangle_count,), + ) + triangles = records["vertices"].astype(np.float32, copy=True) + del records + return triangles + + +def _split_on_vertex_marker(data: bytes) -> list[bytes]: + """Split on the "vertex" keyword, tolerating the upper/mixed case some exporters use.""" + for marker in _ASCII_VERTEX_MARKERS: + chunks = data.split(marker) + if len(chunks) > 1: + return chunks + return [data] + + +def _load_ascii_stl_triangles(data: bytes, max_triangles: int) -> tuple[np.ndarray, int]: + chunks = _split_on_vertex_marker(data) + vertex_count = len(chunks) - 1 + source_triangle_count = vertex_count // 3 + + if vertex_count == 0: + raise STLRenderError("STL file contains no triangles") + if vertex_count % 3: + raise STLRenderError("STL file contains incomplete triangles") + if source_triangle_count > max_triangles: + raise STLRenderError("STL file contains too many triangles") + + values = np.empty(vertex_count * 3, dtype=np.float32) + index = 0 + try: + for chunk in chunks[1:]: + # maxsplit=3: chunk runs until the next vertex marker, so an unbounded + # split would rescan the rest of the file for every vertex. + x, y, z = chunk.split(None, 3)[:3] + values[index] = float(x) + values[index + 1] = float(y) + values[index + 2] = float(z) + index += 3 + except ValueError as error: + raise STLRenderError("STL file contains an invalid vertex") from error + + triangles = values.reshape((-1, 3, 3)) + return triangles, source_triangle_count + + +def _prepare_triangles(triangles: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + finite_mask = np.isfinite(triangles).all(axis=(1, 2)) + triangles = triangles[finite_mask] + if len(triangles) == 0: + return triangles, np.empty((0, 3), dtype=np.float32) + + edges_a = triangles[:, 1] - triangles[:, 0] + edges_b = triangles[:, 2] - triangles[:, 0] + normals = np.cross(edges_a, edges_b) + normal_lengths = np.linalg.norm(normals, axis=1) + valid_mask = normal_lengths > _MIN_TRIANGLE_AREA + triangles = triangles[valid_mask] + normals = normals[valid_mask] + normal_lengths = normal_lengths[valid_mask] + if len(triangles) == 0: + return triangles, np.empty((0, 3), dtype=np.float32) + + normals = normals / normal_lengths[:, np.newaxis] + + min_bounds = triangles.reshape((-1, 3)).min(axis=0) + max_bounds = triangles.reshape((-1, 3)).max(axis=0) + center = (min_bounds + max_bounds) * 0.5 + extent = float(np.max(max_bounds - min_bounds)) + if not math.isfinite(extent) or extent <= 0: + raise STLRenderError("STL mesh has zero extent") + + triangles = (triangles - center) / extent + return triangles.astype(np.float32, copy=False), normals.astype(np.float32, copy=False) + + +def _project_triangles( + triangles: np.ndarray, normals: np.ndarray, size: int +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + rotation = _thumbnail_rotation_matrix() + rotated = triangles @ rotation.T + rotated_normals = normals @ rotation.T + + points = rotated.reshape((-1, 3)) + min_xy = points[:, :2].min(axis=0) + max_xy = points[:, :2].max(axis=0) + center_xy = (min_xy + max_xy) * 0.5 + span = float(np.max(max_xy - min_xy)) + if not math.isfinite(span) or span <= 0: + raise STLRenderError("STL mesh has zero projected extent") + + scale = (size - 1) * _MODEL_PADDING / span + projected = np.empty((len(rotated), 3, 2), dtype=np.float32) + projected[:, :, 0] = ((rotated[:, :, 0] - center_xy[0]) * scale) + ((size - 1) * 0.5) + projected[:, :, 1] = ((center_xy[1] - rotated[:, :, 1]) * scale) + ((size - 1) * 0.5) + + return projected, rotated[:, :, 2].astype(np.float32), rotated_normals.astype(np.float32) + + +def _thumbnail_rotation_matrix() -> np.ndarray: + yaw = math.radians(_THUMBNAIL_YAW_DEGREES) + pitch = math.radians(_THUMBNAIL_PITCH_DEGREES) + cy = math.cos(yaw) + sy = math.sin(yaw) + cp = math.cos(pitch) + sp = math.sin(pitch) + + rotate_z = np.asarray([[cy, -sy, 0.0], [sy, cy, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32) + rotate_x = np.asarray([[1.0, 0.0, 0.0], [0.0, cp, -sp], [0.0, sp, cp]], dtype=np.float32) + return rotate_x @ rotate_z + + +def _rasterize( + projected: np.ndarray, + depths: np.ndarray, + normals: np.ndarray, + size: int, + bg_rgb: tuple[int, int, int], +) -> tuple[Image.Image, int]: + pixels = np.empty((size, size, 3), dtype=np.uint8) + pixels[:, :] = bg_rgb + depth_buffer = np.full((size, size), -np.inf, dtype=np.float32) + + intensities = _AMBIENT_INTENSITY + (_DIFFUSE_INTENSITY * np.abs(normals @ _LIGHT_DIRECTION)) + colors = np.clip(_MODEL_BASE_COLOR * intensities[:, np.newaxis], 0, 255).astype(np.uint8) + + projected_list = projected.tolist() + depths_list = depths.tolist() + colors_list = colors.tolist() + rendered_any = False + drawn_triangle_count = 0 + + for index in range(len(projected_list)): + xy = projected_list[index] + xs = (xy[0][0], xy[1][0], xy[2][0]) + ys = (xy[0][1], xy[1][1], xy[2][1]) + if max(xs) < 0 or min(xs) >= size or max(ys) < 0 or min(ys) >= size: + continue + + tri_z = depths_list[index] + tri = ( + (xy[0][0], xy[0][1], tri_z[0]), + (xy[1][0], xy[1][1], tri_z[1]), + (xy[2][0], xy[2][1], tri_z[2]), + ) + if _fill_triangle(pixels, depth_buffer, tri, size, colors_list[index]): + rendered_any = True + drawn_triangle_count += 1 + + if not rendered_any: + raise STLRenderError("STL mesh is outside the thumbnail frame") + + return Image.fromarray(pixels, "RGB"), drawn_triangle_count + + +def _fill_triangle( + pixels: np.ndarray, + depth_buffer: np.ndarray, + tri: tuple[tuple[float, float, float], ...], + size: int, + color: list[int], +) -> bool: + """Fill a single triangle into a pixel buffer via scanline conversion.""" + (ax, ay, az), (bx, by, bz), (cx, cy, cz) = tri + if ay > by: + ax, ay, az, bx, by, bz = bx, by, bz, ax, ay, az + if by > cy: + bx, by, bz, cx, cy, cz = cx, cy, cz, bx, by, bz + if ay > by: + ax, ay, az, bx, by, bz = bx, by, bz, ax, ay, az + + y_start = max(0, math.ceil(ay)) + y_end = min(size - 1, math.ceil(cy) - 1) + r, g, b = color + drew_any = False + + # a/b/c are the triangle's vertices sorted by y; ta/tb interpolate the left/right + # edge x and z at each scanline. + for y in range(y_start, y_end + 1): + fy = float(y) + ta = 0.0 if cy == ay else (fy - ay) / (cy - ay) + xa = ax + ta * (cx - ax) + za = az + ta * (cz - az) + if fy < by: + tb = 0.0 if by == ay else (fy - ay) / (by - ay) + xb = ax + tb * (bx - ax) + zb = az + tb * (bz - az) + else: + tb = 0.0 if cy == by else (fy - by) / (cy - by) + xb = bx + tb * (cx - bx) + zb = bz + tb * (cz - bz) + + if xa > xb: + xa, xb = xb, xa + za, zb = zb, za + + x_start = max(0, math.ceil(xa)) + x_end = min(size - 1, math.ceil(xb) - 1) + for x in range(x_start, x_end + 1): + tx = 0.0 if xb == xa else (x - xa) / (xb - xa) + # Linear z interpolation is only correct because the projection is + # orthographic; this test makes triangles occlude correctly regardless + # of draw order. + z = za + tx * (zb - za) + if z <= depth_buffer[y, x]: + continue + depth_buffer[y, x] = z + pixels[y, x, 0] = r + pixels[y, x, 1] = g + pixels[y, x, 2] = b + drew_any = True + + return drew_any diff --git a/src/tagstudio/previews/stl_renderer.py b/src/tagstudio/previews/stl_renderer.py deleted file mode 100644 index 1901b48e3..000000000 --- a/src/tagstudio/previews/stl_renderer.py +++ /dev/null @@ -1,369 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: MIT - -from __future__ import annotations - -import math -import struct -from pathlib import Path -from time import perf_counter - -import numpy as np -import structlog -from PIL import Image, ImageColor - -logger = structlog.get_logger(__name__) - -_BINARY_STL_HEADER_SIZE = 84 -_BINARY_STL_TRIANGLE_COUNT_OFFSET = 80 -_BINARY_STL_TRIANGLE_SIZE = 50 -_BINARY_STL_TRAILING_CHARS_TO_IGNORE = b"\x00\r\n\t " -_BINARY_STL_DTYPE = np.dtype( - [ - ("normal", " tuple[int, int, int]: - """Parses `bg_color` into an RGB triple. - - Raises ValueError rather than StlRenderError: an invalid color is a - caller argument mistake, not a problem with the STL file being rendered. - """ - rgb = ImageColor.getrgb(bg_color) - if len(rgb) != 3: - raise ValueError(f"bg_color must resolve to an RGB triple, got {bg_color!r}") - return rgb - - -def render_stl_thumbnail( - filepath: Path, - size: int, - bg_color: str, - max_file_size: int, - max_triangles: int, -) -> Image.Image: - """Render an STL file to a square thumbnail image, with orthographic projection.""" - bg_rgb = _parse_bg_color(bg_color) - - file_size = filepath.stat().st_size - if file_size > max_file_size: - raise StlRenderError("STL file is too large") - - start_time = perf_counter() - header = _read_stl_header(filepath) - read_time = perf_counter() - triangles, source_triangle_count, stl_kind = _load_stl_triangles( - filepath, header, file_size, max_triangles - ) - load_time = perf_counter() - loaded_triangle_count = len(triangles) - triangles, normals = _prepare_triangles(triangles) - prepare_time = perf_counter() - if len(triangles) == 0: - raise StlRenderError("STL file contains no renderable triangles") - - projected, depths, normals = _project_triangles(triangles, normals, size) - project_time = perf_counter() - image, drawn_triangle_count = _rasterize(projected, depths, normals, size, bg_rgb) - raster_time = perf_counter() - - logger.debug( - "[STL Renderer] Rendered thumbnail", - filename=filepath.name, - stl_kind=stl_kind, - file_size=file_size, - source_triangle_count=source_triangle_count, - loaded_triangle_count=loaded_triangle_count, - renderable_triangle_count=len(triangles), - drawn_triangle_count=drawn_triangle_count, - read_seconds=round(read_time - start_time, 4), - load_seconds=round(load_time - read_time, 4), - prepare_seconds=round(prepare_time - load_time, 4), - project_seconds=round(project_time - prepare_time, 4), - raster_seconds=round(raster_time - project_time, 4), - total_seconds=round(raster_time - start_time, 4), - ) - - return image - - -def _read_stl_header(filepath: Path) -> bytes: - """Reads the header of an STL file, avoiding a full file read.""" - with filepath.open("rb") as file: - return file.read(_BINARY_STL_HEADER_SIZE) - - -def _load_stl_triangles( - filepath: Path, header: bytes, file_size: int, max_triangles: int -) -> tuple[np.ndarray, int, str]: - """STL files come in either binary or ascii format. - - Figure out the format and parse the triangles from the file. - """ - if len(header) < _BINARY_STL_HEADER_SIZE: - raise StlRenderError("STL file is too small") - - # Assume binary format. Validate by reading tri count and checking against file size. - triangle_count = struct.unpack_from(" np.ndarray: - if triangle_count > max_triangles: - raise StlRenderError("STL file contains too many triangles") - - records = np.memmap( - filepath, - dtype=_BINARY_STL_DTYPE, - mode="r", - offset=_BINARY_STL_HEADER_SIZE, - shape=(triangle_count,), - ) - triangles = records["vertices"].astype(np.float32, copy=True) - del records - return triangles - - -def _split_on_vertex_marker(data: bytes) -> list[bytes]: - """Split on the "vertex" keyword, tolerating the upper/mixed case some exporters use.""" - for marker in _ASCII_VERTEX_MARKERS: - chunks = data.split(marker) - if len(chunks) > 1: - return chunks - return [data] - - -def _load_ascii_stl_triangles(data: bytes, max_triangles: int) -> tuple[np.ndarray, int]: - chunks = _split_on_vertex_marker(data) - vertex_count = len(chunks) - 1 - source_triangle_count = vertex_count // 3 - - if vertex_count == 0: - raise StlRenderError("STL file contains no triangles") - if vertex_count % 3: - raise StlRenderError("STL file contains incomplete triangles") - if source_triangle_count > max_triangles: - raise StlRenderError("STL file contains too many triangles") - - values = np.empty(vertex_count * 3, dtype=np.float32) - index = 0 - try: - for chunk in chunks[1:]: - # maxsplit=3: chunk runs until the next vertex marker, so an unbounded - # split would rescan the rest of the file for every vertex. - x, y, z = chunk.split(None, 3)[:3] - values[index] = float(x) - values[index + 1] = float(y) - values[index + 2] = float(z) - index += 3 - except ValueError as error: - raise StlRenderError("STL file contains an invalid vertex") from error - - triangles = values.reshape((-1, 3, 3)) - return triangles, source_triangle_count - - -def _prepare_triangles(triangles: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - finite_mask = np.isfinite(triangles).all(axis=(1, 2)) - triangles = triangles[finite_mask] - if len(triangles) == 0: - return triangles, np.empty((0, 3), dtype=np.float32) - - edges_a = triangles[:, 1] - triangles[:, 0] - edges_b = triangles[:, 2] - triangles[:, 0] - normals = np.cross(edges_a, edges_b) - normal_lengths = np.linalg.norm(normals, axis=1) - valid_mask = normal_lengths > _MIN_TRIANGLE_AREA - triangles = triangles[valid_mask] - normals = normals[valid_mask] - normal_lengths = normal_lengths[valid_mask] - if len(triangles) == 0: - return triangles, np.empty((0, 3), dtype=np.float32) - - normals = normals / normal_lengths[:, np.newaxis] - - min_bounds = triangles.reshape((-1, 3)).min(axis=0) - max_bounds = triangles.reshape((-1, 3)).max(axis=0) - center = (min_bounds + max_bounds) * 0.5 - extent = float(np.max(max_bounds - min_bounds)) - if not math.isfinite(extent) or extent <= 0: - raise StlRenderError("STL mesh has zero extent") - - triangles = (triangles - center) / extent - return triangles.astype(np.float32, copy=False), normals.astype(np.float32, copy=False) - - -def _project_triangles( - triangles: np.ndarray, normals: np.ndarray, size: int -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - rotation = _thumbnail_rotation_matrix() - rotated = triangles @ rotation.T - rotated_normals = normals @ rotation.T - - points = rotated.reshape((-1, 3)) - min_xy = points[:, :2].min(axis=0) - max_xy = points[:, :2].max(axis=0) - center_xy = (min_xy + max_xy) * 0.5 - span = float(np.max(max_xy - min_xy)) - if not math.isfinite(span) or span <= 0: - raise StlRenderError("STL mesh has zero projected extent") - - scale = (size - 1) * _MODEL_PADDING / span - projected = np.empty((len(rotated), 3, 2), dtype=np.float32) - projected[:, :, 0] = ((rotated[:, :, 0] - center_xy[0]) * scale) + ((size - 1) * 0.5) - projected[:, :, 1] = ((center_xy[1] - rotated[:, :, 1]) * scale) + ((size - 1) * 0.5) - - return projected, rotated[:, :, 2].astype(np.float32), rotated_normals.astype(np.float32) - - -def _thumbnail_rotation_matrix() -> np.ndarray: - yaw = math.radians(_THUMBNAIL_YAW_DEGREES) - pitch = math.radians(_THUMBNAIL_PITCH_DEGREES) - cy = math.cos(yaw) - sy = math.sin(yaw) - cp = math.cos(pitch) - sp = math.sin(pitch) - - rotate_z = np.asarray([[cy, -sy, 0.0], [sy, cy, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32) - rotate_x = np.asarray([[1.0, 0.0, 0.0], [0.0, cp, -sp], [0.0, sp, cp]], dtype=np.float32) - return rotate_x @ rotate_z - - -def _rasterize( - projected: np.ndarray, - depths: np.ndarray, - normals: np.ndarray, - size: int, - bg_rgb: tuple[int, int, int], -) -> tuple[Image.Image, int]: - pixels = np.empty((size, size, 3), dtype=np.uint8) - pixels[:, :] = bg_rgb - depth_buffer = np.full((size, size), -np.inf, dtype=np.float32) - - intensities = _AMBIENT_INTENSITY + (_DIFFUSE_INTENSITY * np.abs(normals @ _LIGHT_DIRECTION)) - colors = np.clip(_MODEL_BASE_COLOR * intensities[:, np.newaxis], 0, 255).astype(np.uint8) - - projected_list = projected.tolist() - depths_list = depths.tolist() - colors_list = colors.tolist() - rendered_any = False - drawn_triangle_count = 0 - - for index in range(len(projected_list)): - xy = projected_list[index] - xs = (xy[0][0], xy[1][0], xy[2][0]) - ys = (xy[0][1], xy[1][1], xy[2][1]) - if max(xs) < 0 or min(xs) >= size or max(ys) < 0 or min(ys) >= size: - continue - - tri_z = depths_list[index] - tri = ( - (xy[0][0], xy[0][1], tri_z[0]), - (xy[1][0], xy[1][1], tri_z[1]), - (xy[2][0], xy[2][1], tri_z[2]), - ) - if _fill_triangle(pixels, depth_buffer, tri, size, colors_list[index]): - rendered_any = True - drawn_triangle_count += 1 - - if not rendered_any: - raise StlRenderError("STL mesh is outside the thumbnail frame") - - return Image.fromarray(pixels, "RGB"), drawn_triangle_count - - -def _fill_triangle( - pixels: np.ndarray, - depth_buffer: np.ndarray, - tri: tuple[tuple[float, float, float], ...], - size: int, - color: list[int], -) -> bool: - """Fill a single triangle into a pixel buffer via scanline conversion.""" - (ax, ay, az), (bx, by, bz), (cx, cy, cz) = tri - if ay > by: - ax, ay, az, bx, by, bz = bx, by, bz, ax, ay, az - if by > cy: - bx, by, bz, cx, cy, cz = cx, cy, cz, bx, by, bz - if ay > by: - ax, ay, az, bx, by, bz = bx, by, bz, ax, ay, az - - y_start = max(0, math.ceil(ay)) - y_end = min(size - 1, math.ceil(cy) - 1) - r, g, b = color - drew_any = False - - # a/b/c are the triangle's vertices sorted by y; ta/tb interpolate the left/right - # edge x and z at each scanline. - for y in range(y_start, y_end + 1): - fy = float(y) - ta = 0.0 if cy == ay else (fy - ay) / (cy - ay) - xa = ax + ta * (cx - ax) - za = az + ta * (cz - az) - if fy < by: - tb = 0.0 if by == ay else (fy - ay) / (by - ay) - xb = ax + tb * (bx - ax) - zb = az + tb * (bz - az) - else: - tb = 0.0 if cy == by else (fy - by) / (cy - by) - xb = bx + tb * (cx - bx) - zb = bz + tb * (cz - bz) - - if xa > xb: - xa, xb = xb, xa - za, zb = zb, za - - x_start = max(0, math.ceil(xa)) - x_end = min(size - 1, math.ceil(xb) - 1) - for x in range(x_start, x_end + 1): - tx = 0.0 if xb == xa else (x - xa) / (xb - xa) - # Linear z interpolation is only correct because the projection is - # orthographic; this test makes triangles occlude correctly regardless - # of draw order. - z = za + tx * (zb - za) - if z <= depth_buffer[y, x]: - continue - depth_buffer[y, x] = z - pixels[y, x, 0] = r - pixels[y, x, 1] = g - pixels[y, x, 2] = b - drew_any = True - - return drew_any From 68fe4f0eec7d10c50ea6c41160101d6a78bd75c1 Mon Sep 17 00:00:00 2001 From: Ludvig Sandh Date: Sun, 20 Sep 2026 11:42:39 +0100 Subject: [PATCH 7/7] chore: retrigger CI