Skip to content

feat: implement custom STL preview renderer from scratch - #1429

Merged
CyanVoxel merged 11 commits into
TagStudioDev:devfrom
ludvig-sandh:custom-stl-renderer
Oct 3, 2026
Merged

CyanVoxel merged 11 commits into
TagStudioDev:devfrom
ludvig-sandh:custom-stl-renderer

Conversation

@ludvig-sandh

@ludvig-sandh ludvig-sandh commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR experiments with a custom renderer for STL file previews (#351).

STL files only contain triangle information. No shading, material or light, which makes them pretty easy to parse. STL files are pretty rigid and come in two main formats: binary and ASCII. I handle both, then parse the triangles, compute normals (for lighting), project onto 2d, then draw onto a PIL image.

Pros:

Cons:

  • Slow. The rasterization step is sort of a bottleneck since we need to loop through all triangles and draw a polygon() on the image with each of them.

For now it is still an experiment (hence draft), so I have included some benchmarking code for tracking rendering times. My investigation shows roughly:
small files (0-2000 triangles): <80ms
medium files (2000-100,000): <1000ms
large files (100,000+): several seconds

For now I set a tri count cap of 100,000 and don't render anything above. Eventually the cap should be configurable.
It is also possible to approximate the rendering by reducing the triangle counts (only use a subset of them), but this didn't look good as the objects had visible holes in them.

This is what it looks like (two of the bottom files are not rendered due to the cap):
image

From my very short and limited testing the renders looks good enough, and the rendering seems performant enough for users to benefit from the feature. Especially with the tri count cap, as well as concurrently rendering many files at a time. Obviously we may need some more rigorous testing to make sure it works well (eg. on low-end systems).

Although I haven't done any research, I can image most STL files being pretty small. Especially if you have many enough to use TagStudio to organize them. If that's true, a slower renderer that by default only renders smaller STL files might work well for now.

Tasks Completed

  • Platforms Tested:
    • Windows x86
    • Windows ARM
    • macOS x86
    • macOS ARM
    • Linux x86
    • Linux ARM
  • Tested For:
    • Basic functionality
    • PyInstaller executable

@ludvig-sandh ludvig-sandh mentioned this pull request Jul 5, 2026
2 of 8 tasks
@CyanVoxel CyanVoxel added Type: Feature New feature or request Type: UI/UX User interface and/or user experience TagStudio: Thumbs/Previews File thumbnails or previews labels Jul 5, 2026

@Computerdores Computerdores left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

read over the loading code out of curiosity and wrote down some thoughts I had
Note that I didn't think about it particularly long, so take these for the not-thought-through thoughts that they are :)

Comment thread src/tagstudio/qt/previews/stl_renderer.py Outdated
Comment on lines +95 to +97
def _read_stl_header(filepath: Path) -> bytes:
with filepath.open("rb") as file:
return file.read(_BINARY_STL_HEADER_SIZE)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems unnecessary to me

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is necessary to keep loading times small by avoiding expensive full file reads. In the common case for a correctly formatted binary STL file, the full file is never read into a python byte object, which is how loading times are kept below 1ms.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mb on the phrasing, what I meant was having it as a separate function and passing the result around

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see. Just thought it was clean. It's small but it has its very clear purpose. In general I like small functions bc they're easy to test. We can change it though. Curious, is there any downside to it that I'm not seeing perhaps?

Comment thread src/tagstudio/qt/previews/stl_renderer.py Outdated
Comment thread src/tagstudio/qt/previews/stl_renderer.py Outdated
@ludvig-sandh

Copy link
Copy Markdown
Contributor Author

@Computerdores thanks for the review! Good points :)

Well, do we want to move forward with this approach? There's no point in polishing experimental code until we aim to use it.

@CyanVoxel

Copy link
Copy Markdown
Member

Approach-wise this is the furthest anything has come by far, and is actually the first one to get some useful renders (and no crashes so far!). I think this is definitely worth refining as an approach, and I'm shocked that the performance seems decent (it's at least snappy with my 5-50 KB files).
image
There's definitely some visual quirks to iron out as well as what Computerdores has already mentioned, but I think this has my vote to continue on with. I'm having some fun playing with different angles and lighting in the meantime :)

@Computerdores

Computerdores commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

yeah my take is pretty much the same as Cyan's, only thing I am unsure about is the performance
80ms seems fairly good at first glance (and better then I would have expected), but these are also fairly small models and realistically in practice many if not most are going to be larger than 2k tris1 and especially during initial generation of the thumbnails we want the render times to be as small as possible
So imo this either needs significant optimisations (I've got no clue about numpy and co so maybe that is feasible?) or it would need to be written in faster (i.e. compiled) language which would have to be rust, because in the future the plan is to at least partially move to rust

Footnotes

  1. libraries likely either have no/very few 3D models or they have a ton because the person is using TS to manage their models. And in the latter case they are also likely to be larger so the effect would be amplified even more. (this is speculation of course, but I am fairly confident in my guess) ↩

@ludvig-sandh

ludvig-sandh commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor Author

I did three things:

  1. Polish the code, with the review comments in mind. I think it's much clearer now.
  2. Optimizations: (i) PIL .polygon() calls were expensive. Rasterizing ourselves was 3x faster, even in regular interpreted python. (ii) Removed regex parsing for ascii format to a custom solution. 1.5x faster ascii loading.
  3. Experimented with STL rendering in c++ (never used Rust lol) to investigate an upper bound on the speedup achievable by using bindings with a statically typed language.

Results for ~100k triangle render (edit: that's a 5MB file if binary format):

Approach Render time
PIL (original) ~500ms
Custom rasterizer (current) ~150ms
C++ (same alg as current) ~5ms

Two conclusions:

  1. With the recent optimizations, I think what we have now is much better (I'm thinking of @Computerdores perf concerns).
  2. C++ yielded another 30x on top of the optimizations I already made. So, if we wanted to use bindings with rust, we could expect another 10-30x speedup.

Thoughts?

@ludvig-sandh
ludvig-sandh marked this pull request as ready for review July 6, 2026 21:12
@ludvig-sandh
ludvig-sandh marked this pull request as draft July 6, 2026 21:19
@CyanVoxel

Copy link
Copy Markdown
Member

I did three things:
[...]
3. Experimented with STL rendering in c++ (never used Rust lol) to investigate an upper bound on the speedup achievable by using bindings with a statically typed language.

Two conclusions:
1. With the recent optimizations, I think what we have now is much better (I'm thinking of Computerdores perf concerns).
2. C++ yielded another 30x on top of the optimizations I already made. So, if we wanted to use bindings with rust, we could expect another 10-30x speedup.

Thoughts?

So just to be clear, these C++ experiments referencing bindings aren't present in the most recent commits? Or is this referencing the changes made in 04b11e5?

Regardless, I feel it's probably best to have something that works fine first, and then optimize from there in future PRs. On my machine at least, the render time of the STLs I have is extremely reasonable and is comparable to PDFs. I think otherwise the biggest concerns are the minor graphical glitches that occur with some of the faces, which appear to either render in the wrong order, with inverted normals, or otherwise strange stretched triangles. These models all appear normally when viewing in other programs. I have several examples of these:


Lastly, I apologize for the merge conflict created with the renaming of the renderer.py file to file_renderer.py, and a potential conflict with #1498 if that gets merged before this. The thumbnail system is one of the most frequently touched areas of the codebase and is receiving some long-overdue reworking, so it's been a little disruptive to open renderer PRs and I've tried to time it with a period of lower activity.

@CyanVoxel CyanVoxel moved this to 🚧 In progress in TagStudio Development Sep 3, 2026
@CyanVoxel CyanVoxel added this to the Alpha v9.6.x milestone Sep 3, 2026
@ludvig-sandh

Copy link
Copy Markdown
Contributor Author

Correct about the c++ experiments not being present here. iirc I just wrote a standalone cpp script with the same logic, without any intention of actually ever using that code. Never actually wrote any bindings

I'm glad you think the version we have now is sufficient. I will look into the merge conflicts. They can't be that bad right (famous last words...)

Also I should be able to fix the visual artifacts, and then we should be good to go!

May be a bit busy right now since I just started my first real software engineering job and moved countries, but I'm passionate enough about this that I will find the time somehow.

# Conflicts:
#	src/tagstudio/previews/stl_renderer.py
#	src/tagstudio/qt/previews/renderer.py
@ludvig-sandh
ludvig-sandh marked this pull request as ready for review September 20, 2026 10:22
@CyanVoxel CyanVoxel modified the milestones: Alpha v9.6.5, Alpha v9.7.0 Sep 28, 2026

@CyanVoxel CyanVoxel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved once the new review comments are addressed.
I'm also going to target this toward the dev branch which contains features meant for 9.7.0, which will give some additional time for me to make any subjective stylistic changes or additional tweaks before this hits main, plus any changes that may result from upstream thumbnail renderer changes (like alpha backgrounds for thumbnails).

Thank you again for your continued work on this!

Comment thread src/tagstudio/qt/qt_file_renderer.py Outdated
Comment on lines +4 to +60
@@ -12,6 +13,10 @@
from tagstudio.qt.app_settings import AppSettings, Theme
from tagstudio.qt.cache_manager import CacheManager

# QPixmap creation isn't safe to run concurrently across threads; rendering runs on a
# pool of worker threads, so this serializes just that conversion step.
_pixmap_conversion_lock = threading.Lock()


class QtFileRenderer(QObject):
"""A Qt-specific wrapper for rendering image previews and thumbnails from files."""
@@ -49,9 +54,10 @@ def render(
is_loading=is_loading,
is_thumb=is_thumb,
)
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've never had an issue with this before, and even commenting out these changes I have no problems with the QPixmaps being created on different worker threads on this PR. I tried finding any official Qt documentation on this and found some older documentation from Qt 5, but that restriction no longer appears to be the case for Qt 6, where it instead says "QPainter can be used in a thread to paint onto QImage, QPrinter, QPicture, and (for most platforms) QPixmap".

Is this an actual issue you somehow encountered?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually don't remember if I added it cause I ran into a problem, or just if I thought it'd be best practice. Seems fine without it as you say, so I remove the lock. Thanks for pointing it out!

Comment thread src/tagstudio/previews/renderers/stl.py Outdated
Comment on lines +208 to +217
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a single statement if using np.fromfile(), and would just read the records straight into a numpy array without requiring any memory mapping to an intermediate variable or an explicit del operation. Tested working on my machine

Suggested change
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
return np.fromfile(
filepath, dtype=_BINARY_STL_DTYPE, count=triangle_count, offset=_BINARY_STL_HEADER_SIZE
)["vertices"]

Comment thread src/tagstudio/previews/renderers/stl.py Outdated
Comment on lines +100 to +109
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bg_color is hardcoded above in _stl_thumb(), and could just be hardcoded as an RGB tuple there with no need for a conversion method when everything in this file uses the RGB tuple.
(#1e1e1e in RGB is (30, 30, 30) and #FFFFFF is (255, 255, 255), for quick reference)

@CyanVoxel
CyanVoxel changed the base branch from main to dev September 28, 2026 21:57
@CyanVoxel CyanVoxel linked an issue Sep 28, 2026 that may be closed by this pull request
3 tasks done
@ludvig-sandh

Copy link
Copy Markdown
Contributor Author

Comments addressed. Thanks for taking the time to code review

@CyanVoxel CyanVoxel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much for all your work on this!

@CyanVoxel
CyanVoxel merged commit bd8810e into TagStudioDev:dev Oct 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TagStudio: Thumbs/Previews File thumbnails or previews Type: Feature New feature or request Type: UI/UX User interface and/or user experience

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Render STL thumbnails

3 participants