This commit is contained in:
John Poole 2026-03-04 17:47:07 -08:00
commit 6a93950678
28 changed files with 482 additions and 106 deletions

71
create_glb.py Normal file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
# create_glb.py — Blender headless STL -> GLB
#
# Example:
# blender-bin-5.0.0 --background --python create_glb.py -- input.stl [output.glb]
import bpy
import sys
import os
from mathutils import Vector
def die(msg, rc=2):
print(f"ERROR: {msg}")
raise SystemExit(rc)
# args after "--"
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) == 1:
inp = argv[0]
base, _ = os.path.splitext(inp)
outp = base + ".glb"
elif len(argv) >= 2:
inp, outp = argv[0], argv[1]
else:
die("USAGE: blender --background --python create_glb.py -- input.stl [output.glb]")
if not os.path.exists(inp):
die(f"Input not found: {inp}")
# Empty scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import STL (Blender 4/5 operator)
res = bpy.ops.wm.stl_import(filepath=inp)
if 'FINISHED' not in res:
die(f"STL import failed for: {inp}")
# Gather imported mesh objects
objs = [o for o in bpy.context.scene.objects if o.type == 'MESH']
if not objs:
die("No mesh objects after import (unexpected)")
# Compute combined bounding box center in world space
min_v = Vector(( 1e30, 1e30, 1e30))
max_v = Vector((-1e30, -1e30, -1e30))
for o in objs:
# object bound_box is in local coords; transform to world
for corner in o.bound_box:
v = o.matrix_world @ Vector(corner)
min_v.x = min(min_v.x, v.x); min_v.y = min(min_v.y, v.y); min_v.z = min(min_v.z, v.z)
max_v.x = max(max_v.x, v.x); max_v.y = max(max_v.y, v.y); max_v.z = max(max_v.z, v.z)
center = (min_v + max_v) * 0.5
# Translate all meshes so center is at origin
for o in objs:
o.location -= center
# Export GLB
res = bpy.ops.export_scene.gltf(
filepath=outp,
export_format='GLB',
export_apply=True,
)
if 'FINISHED' not in res:
die(f"GLB export failed: {outp}")
print(f"Wrote: {outp}")

14
creating_glbs.md Normal file
View file

@ -0,0 +1,14 @@
Here is a command to create a single glb:
blender-bin-5.0.0 --background --python create_glb.py -- \
/usr/local/src/Voron-Stealthburner/STLs/Stealthburner/'[o]_stealthburner_LED_carrier.stl' \
/tmp/out.glb
The git repository for Voron-Stealthburner was staged under /usr/local/src. The above command
selects a specific STL and then places the STL under /tmp.
In a production mode, we want to be able to point to a directory tree of STLs and then
generate glb equivalents in the similar tree structure.

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -0,0 +1,260 @@
# Goal
Create a "profile", i.e. a JSON file, to be used by the script that mass converts *.glb to *.png
## Introduction
This exercise only requires that you launch a small HTTP server in a console. Otherwise, everything involved is handled through the HTML page lab.html. You will interact with lab.html's 3D rendering of a glb file that is included with this project.
## Steps
Open a browser or a new window of the browser (Ctrl-n in Firefox) and resize the browser to a small rectangle. You are reducing the size so as to mimic what the PNG cell in the manifest table will look like. For example, this reduced window
is 521 × 432 pixels.
![](20260304_101754_Wed.png)
In a console:
cd ~/work/Voron/voronstl/web
python3 -m http.server 8001
You should have a console that looks like this:
![](20260304_101254_Wed.png)
It is necessary to start the web server within the "web" directory as that directory
will be servers "root".
Visit:
http://localhost:8001/lab.html
You will see a zoomed-in image:
![](Screenshot_20260304_102153_AM_Wed-1.png)
Zoom out until the entire part fits within the window.
Click the Controls bar to collapse the sub menus.
![](Screenshot_20260304_102435_AM_Wed.png)
Move the object to center it in the window: Shift + left mouse button. You want to have the entire part fit within the view and be cenetered.
Click Controls bar to open the sub menus. Adjust the lighintensity to a high value, if not the maximum values. This will cause the image to go lighter allowing for contrast with shadows that help discern the part.
![](Screenshot_20260304_154311_PM_Wed.png)
Optional: Save the PNG for your own reference.
Click "Export Profile" and save your current settings.
![](20260304_102737_Wed.png)
You now have a specification on sizing and angles which may work well for all of the other parts. Note: I took mine and applied the specifications saved above for a mass PNG creation and all the others looked very good.
### Additional Information:
Here's what a JSON file looks like:
<table style="width:100%; table-layout:fixed;">
<tr>
<th>Image</th>
<th>Description</th>
</tr>
<tr>
<td style="width:50%; vertical-align:top;">
<img src="20260304_102919_Wed.png" >
</td>
<td style="width:50%; vertical-align:top;"><pre style="white-space:pre-wrap; overflow:auto; max-width:100%;">jlpoole@jp ~/work/Voron/voronstl/web $ jq . out/three_profile_20260304_102657.json
{
"provenance": "lab.html exportProfile Wed Mar 04 2026 10:26:57 GMT-0800 (Pacific Standard Time)",
"output": {
"width": 500,
"height": 315,
"pixelRatio": 1
},
"scene": {
"background": 16777215
},
"camera": {
"type": "PerspectiveCamera",
"fov": 50,
"near": 0.1,
"far": 1000,
"position": [
11.93432933230491,
11.71721921868296,
13.265804443912849
],
"up": [
0,
1,
0
]
},
"controls": {
"target": [
2.075876663103527,
-2.147814989328729,
0.7122034073683343
]
},
"renderParams": {
"wireframe": false,
"edgeAngle": 30,
"lightIntensity": 1.5
},
"lights": {
"directional": {
"position": [
5,
5,
5
],
"intensity": 1.5
},
"ambient": {
"intensity": 0.6
}
}
}
jlpoole@jp ~/work/Voron/voronstl/web $
</td>
</tr>
</table>
## ChatGPT's analysis of what is important for consistency
Here is a response from ChatGPT that summarizes what variables are captured and saved so that they can be used to repeat the same type of transformation of *.glb to PNG.
## Response
Good — now youre at the point where its worth making the capture **repeatable** across a whole directory of GLBs.
In practice, you want to “freeze” *everything that affects the pixels* and treat the GLB as the only variable.
## What to save (so every PNG is consistent)
### 1) Camera pose and projection
Save:
* `camera.position` (x,y,z)
* `camera.up` (x,y,z)
* `controls.target` (x,y,z) if youre using OrbitControls
* `camera.fov`, `camera.near`, `camera.far`
* (optional) whether you used perspective vs orthographic
These define “where the viewer is” and are the biggest determinant of repeatability.
### 2) Output framing rules (per-model normalization)
You need a deterministic rule for “how big the part appears in frame”, otherwise parts with different sizes will look random.
Two common strategies:
**A. Fit-to-frame (recommended for mass renders)**
For each GLB:
* compute `Box3` bounds
* compute `center` and `size`
* set `controls.target = center`
* set camera distance so the whole bounding box fits (using FOV + max dimension)
* optionally lock azimuth/elevation (so every part is viewed from the same angle)
This yields consistent “fill” and eliminates manual tuning.
**B. Fixed camera (only if all parts are same scale and you want exact same viewpoint)**
Works if your GLBs are all in the same coordinate system/scale and similarly sized.
### 3) Lighting rig
Save:
* light types (Directional/Hemisphere/Ambient)
* each lights `position`, `intensity`, `color`
* shadow settings if used
Your current screenshot shows a simple shaded render; keep lighting stable or youll get different highlight/shadow shapes across parts.
### 4) Renderer and capture settings
Save:
* `width`, `height`
* `devicePixelRatio` you used (often set explicitly for determinism)
* `renderer.toneMapping`, `outputColorSpace` (Three.js r152+ uses `renderer.outputColorSpace = THREE.SRGBColorSpace`)
* `renderer.setClearColor()` (white) and alpha behavior
* `preserveDrawingBuffer: true` (for browser capture)
* whether you use `physicallyCorrectLights` (if you do, keep it constant)
### 5) Material overrides / wireframe settings
If youre overriding materials for “clay render” or wireframe, save:
* your override material parameters (`MeshStandardMaterial` vs `MeshPhongMaterial`, roughness/metalness)
* edge/wire settings (`EdgesGeometry` threshold angle, line material linewidth/color)
### 6) Background / staging
Save:
* background color (white)
* whether you add a ground plane, axes, environment map, etc.
Even “invisible” helpers can affect auto-framing if theyre included in bounds—so exclude them from bounds computation.
---
## The best thing to save: a small “render profile” JSON
Put this in a file like `render_profile.json`:
* canvas size
* camera angle (azimuth/elevation) OR explicit camera position
* fit-to-frame padding factor (e.g., 1.15)
* light positions/intensities
* material mode (`source`, `clay`, `wireframe`, `edges`)
* background color
Then your batch renderer (browser or headless) loads profile + GLB and produces PNG.
Example shape (conceptually):
```json
{
"output": { "w": 1600, "h": 1200, "pixelRatio": 1 },
"background": "#ffffff",
"camera": { "fov": 35, "near": 0.01, "far": 1000, "azimuth": -35, "elevation": 20, "fitPadding": 1.15 },
"lights": [
{ "type": "dir", "pos": [3,5,2], "intensity": 2.8 },
{ "type": "hem", "sky": "#ffffff", "ground": "#666666", "intensity": 0.6 }
],
"materialMode": "clay"
}
```
---
## How to mass-create PNGs (two viable routes)
### Route 1: Headless batch (best)
Use **node + headless WebGL** (or Puppeteer driving Chrome) to render each GLB with the profile and write PNGs. This is what you want if youre doing hundreds/thousands.
### Route 2: Browser batch page (simpler)
A page that:
* loads a list of GLB URLs
* applies the profile
* renders each, downloads PNG automatically (or zips them)
This is simpler but less pleasant at scale.
---
## One key question that determines the “right” profile
Do you want your PNGs to be:
* **same camera angle + fit-to-frame** (parts all fill the image similarly), or
* **same absolute camera** (so relative size differences are visible)?
If you tell me which you want, Ill give you a concrete “profile JSON + camera fit math + batch driver” that matches it.

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

104
lab.html
View file

@ -1,104 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>RenderLab v1</title>
<style>
body { margin:0; background:white; }
canvas { display:block; }
</style>
</head>
<body>
<script type="module">
import * as THREE from './threejs/three.module.js';
import { GLTFLoader } from './threejs/GLTFLoader.js';
import { OrbitControls } from './threejs/OrbitControls.js';
import GUI from './threejs/lil-gui.module.min.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff);
const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 1000);
camera.position.set(3,3,3);
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
const light = new THREE.DirectionalLight(0xffffff, 1.5);
light.position.set(5,5,5);
scene.add(light);
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
let mesh, edgeLines;
const loader = new GLTFLoader();
loader.load('./glb/part.glb', gltf => {
mesh = gltf.scene.children[0];
mesh.material = new THREE.MeshStandardMaterial({
color: 0xdddddd,
roughness: 0.9,
metalness: 0
});
scene.add(mesh);
addEdges(30);
});
function addEdges(angle) {
if (!mesh) return;
if (edgeLines) scene.remove(edgeLines);
const edges = new THREE.EdgesGeometry(mesh.geometry, angle);
edgeLines = new THREE.LineSegments(
edges,
new THREE.LineBasicMaterial({ color: 0x000000 })
);
scene.add(edgeLines);
}
const params = {
wireframe: false,
edgeAngle: 30,
lightIntensity: 1.5,
savePNG: () => {
const link = document.createElement('a');
link.download = 'render.png';
link.href = renderer.domElement.toDataURL("image/png");
link.click();
}
};
const gui = new GUI();
gui.add(params, 'wireframe').onChange(v => {
if (mesh) mesh.material.wireframe = v;
});
gui.add(params, 'edgeAngle', 1, 90).onChange(v => addEdges(v));
gui.add(params, 'lightIntensity', 0, 3).onChange(v => light.intensity = v);
gui.add(params, 'savePNG');
window.addEventListener('resize', () => {
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>

38
mv_themes.js Normal file
View file

@ -0,0 +1,38 @@
// mv_theme.js
// Apply consistent colors for manifest viewing.
//
// Usage:
// <model-viewer class="mv" data-theme="primary" ...></model-viewer>
// <model-viewer class="mv" data-theme="accent" ...></model-viewer>
const THEMES = {
primary: { rgba: [0.10, 0.45, 1.00, 1.0] }, // diagnostic blue
accent: { rgba: [0.78, 0.33, 0.10, 1.0] }, // burnt orange-ish
};
function applyTheme(mv) {
const key = (mv.dataset.theme || "primary").toLowerCase();
const theme = THEMES[key] || THEMES.primary;
// Lighting-related material choices that improve face contrast
const metallic = 0.0;
const roughness = 0.35;
for (const mat of mv.model.materials) {
mat.pbrMetallicRoughness.setBaseColorFactor(theme.rgba);
mat.pbrMetallicRoughness.setMetallicFactor(metallic);
mat.pbrMetallicRoughness.setRoughnessFactor(roughness);
}
}
function hook(mv) {
if (mv.model) {
applyTheme(mv);
} else {
mv.addEventListener("load", () => applyTheme(mv), { once: true });
}
}
for (const mv of document.querySelectorAll("model-viewer.mv")) {
hook(mv);
}

6
simple_server.md Normal file
View file

@ -0,0 +1,6 @@
Use this command to run a simple little server for the HTML:
python3 -m http.server 8000
The directory the command above is run in is the root directory. Then
http://127.0.0.1:8000/[FILE NAME]

Binary file not shown.

73
web/assets/beaker.svg Normal file
View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
width="800px"
height="800px"
viewBox="0 -0.08 45 45"
version="1.1"
id="svg1"
sodipodi:docname="beaker.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.09625"
inkscape:cx="399.5439"
inkscape:cy="400"
inkscape:window-width="1920"
inkscape:window-height="1128"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
id="Path_74"
data-name="Path 74"
d="m 15.693226,14.498953 h 16.333 v 6.166 l 4.907,8.793 6.139,11 -5.379,1.71 H 3.6932258 l 3.5,-7 7.2280002,-13.417 z"
fill="#d1d3d4"
style="fill:#ff413f;fill-opacity:1" />
<path
id="Path_75"
data-name="Path 75"
d="M 43,42.832 31,21.332 V 2 H 14 v 19.335 l -12,21.5 z"
fill="none"
stroke="#231f20"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4" />
<line
id="Line_37"
data-name="Line 37"
x2="35.332001"
fill="#ffffff"
stroke="#231f20"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
x1="9.665"
y1="2"
y2="2" />
<path
id="Path_76"
data-name="Path 76"
d="M 18.5,34.16 A 2.333,2.333 0 1 1 16.166,31.827 2.334,2.334 0 0 1 18.5,34.16 Z"
fill="#ffffff" />
<path
id="Path_77"
data-name="Path 77"
d="m 29.907,29.701 a 3.7,3.7 0 1 1 -3.7,-3.7 3.7,3.7 0 0 1 3.7,3.7 z"
fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
web/assets/favicon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
web/assets/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

BIN
web/assets/favicon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
web/assets/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
web/assets/favicon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
web/assets/favicon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
web/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

3
web/batch_glb_png/glbs/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*
!.gitignore
!README.md

3
web/batch_glb_png/pngs/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*
!README.md
!.gitignore

BIN
web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View file

@ -1,8 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<!-- To run, start a local server:
python3 -m http.server 8001
Then open http://localhost:8001/lab.html in a modern browser.
-->
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>RenderLab v1</title> <title>RenderLab: PNG Profile Exporter</title>
<style> <style>
body { margin:0; background:white; } body { margin:0; background:white; }
canvas { display:block; } canvas { display:block; }
@ -54,7 +58,13 @@ scene.add(ambient);
let mesh, edgeLines; let mesh, edgeLines;
const loader = new GLTFLoader(); const loader = new GLTFLoader();
loader.load('./glb/part.glb', gltf => {
//
// This part is particularly tricky to view, so it makes an excellent test case.
//
// ./Voron-Stealthburner/STLs/Stealthburner/[o]_stealthburner_LED_carrier.stl.glb
//
loader.load('[o]_stealthburner_LED_carrier.stl.glb', gltf => {
mesh = gltf.scene.children[0]; mesh = gltf.scene.children[0];

2
web/out/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore