boxglb
Write glTF 2.0 / GLB files from axis-aligned boxes. Pure Python, no dependencies.
You already have the model — panel dimensions from a configurator, a pallet layout, the output of a greedy mesher. boxglb turns those numbers into a file that opens in Blender, three.js, Windows 3D Viewer, Google Scene Viewer or any browser, and stops there. It is a serializer, not a modeller.
pip install boxglb
Example
from boxglb import Box, Material, write_glb
materials = {
"oak": Material("oak", base_color=(0.76, 0.60, 0.42, 1.0), roughness=0.8),
"glass": Material("glass", base_color=(0.8, 0.9, 1.0, 0.25)),
}
shelf = [
Box("side_left", pos=(0.00, 0, 0), size=(0.018, 2.0, 0.30), material="oak"),
Box("side_right", pos=(0.98, 0, 0), size=(0.018, 2.0, 0.30), material="oak"),
Box("shelf_1", pos=(0.018, 0.4, 0), size=(0.964, 0.018, 0.30), material="oak"),
Box("door", pos=(0.018, 0, 0.282), size=(0.964, 2.0, 0.018), material="glass"),
]
stats = write_glb(shelf, materials, "shelf.glb")
# {'triangles': 48, 'nodes': 2, 'materials': 2,
# 'bbox_m': [0.998, 2.0, 0.3], 'origin_m': [0.0, 0.0, 0.0], 'bytes': 5572}
Everything is in metres, because that is what glTF defines. No scale negotiation,
which is the question FBX and OBJ leave open. pos is the minimum corner, not the
centre. Alpha below 1.0 sets alphaMode: BLEND automatically — no special material
names are involved.
Runnable version: examples/shelf.py.
Why this and not something else
| dependencies | boxes → GLB | UV generation | |
|---|---|---|---|
trimesh |
numpy, 54 MB installed, Python ≥ 3.10 | yes | no — creation.box() has uv = None; the GLB carries only POSITION |
pygltflib |
dataclasses-json, deprecated |
you write buffers and accessors | you |
gltflib |
dataclasses-json |
same | you |
boxglb |
none | yes | per face, at world scale |
Verified 2026-08-28 by installing each into a clean venv and running this shelf.
Textures work without you writing an unwrap. UVs are planar projections in world
coordinates scaled by texture_scale_m, so a wood texture tiles at physical size
instead of stretching to fit each panel:
Material("oak", texture="oak.jpg", texture_scale_m=0.5) # one tile per half metre
That is the reason to choose boxglb even where numpy is already installed. The second reason is narrower: zero dependencies matter when a graphics stack in the image costs more than the task — a backend returning previews, a lambda, CI, an air-gapped box with no wheels for its architecture.
What this package does not do
Deliberately, permanently:
- arbitrary meshes — axis-aligned rectangular volumes only
- rotation or transforms — there is no matrix anywhere in the code
- node hierarchy — every node sits flat in one scene
- animation, skinning, morph targets
- cameras and lights
- smooth shading — normals are per face by construction
- reading GLB — it writes only
If you need any of these, you need a different library, and this list is here so you find that out before installing rather than after.
Known behaviour worth knowing
Boxes sharing a material are merged. By default all boxes of one material become a
single mesh and node named by the material key — that is why the shelf above reports
nodes: 2 for four boxes. Box.name therefore does not reach the file; the names above
exist for the readability of your own code. Pass group_by_material=False to get one
node per box named by Box.name. It costs file size and draw calls: a hundred boxes
become a hundred meshes and four hundred accessors.
Texture direction depends on the face. X faces get U=y, V=-z; Z faces get
U=x, V=-y. Grain on a side panel runs at 90° to grain on a front panel. For wood this
is often what you want; it is stated here because it surprises people.
UVs are world coordinates. A scene translated hundreds of metres from the origin produces large UV values and loses float32 precision.
Vertices are not welded — 24 per box. That is the price of per-face normals, and it is what makes texturing possible at all.
API
def write_glb(
boxes: list[Box],
materials: dict[str, Material],
out_path: str | os.PathLike[str],
scene_name: str = "model",
texture_root: Path | None = None, # resolves relative texture paths
group_by_material: bool = True,
) -> Stats
Box(name, pos, size, material) — all in metres, size strictly positive.
Material(name, base_color=(0.8, 0.8, 0.8, 1.0), metallic=0.0, roughness=0.7, texture=None, texture_scale_m=1.0, double_sided=True)
The key of the materials dict is the identifier Box.material points at, and it
names the mesh and node. Material.name is the label written into the glTF material and
may differ: {"oak": Material("American walnut")} is a valid, deliberate choice.
Stats is a TypedDict — triangles, nodes, materials, bbox_m, origin_m,
bytes. Note that nodes counts glTF nodes, not boxes.
Bad input is rejected before anything is written, with a message that names the offender: non-positive dimensions, an unknown material key, a texture that does not resolve, a texture that is not PNG or JPEG, an empty box list, a missing output directory.
The package ships py.typed and passes mypy --strict.
Requirements
Python 3.9 or newer. Nothing else.
License
MIT
Metadata
Release files for boxglb 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| boxglb-0.1.0.tar.gz | 29.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| boxglb-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 39.6 kB
Release files / boxglb-0.1.0.tar.gz
| Download URL | boxglb-0.1.0.tar.gz |
|---|---|
| Size | 29.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
26d75c507df9722f9fcbb674ee7d8ac83e6c7adac8a3187ea2c6be5b19cd906c
|
|
BLAKE2b-256 checksum How to use checksums |
2e21142071dbce1cae2537e1440d1a135101feb947b8fe8fe7948d13b9cf0446
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.
Transparency logRelease files / boxglb-0.1.0-py3-none-any.whl
| Download URL | boxglb-0.1.0-py3-none-any.whl |
|---|---|
| Size | 10.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
624aad028a7246c62f6d35a7adb5af55560eadf6790a430b8e68a90d1f3e1267
|
|
BLAKE2b-256 checksum How to use checksums |
6ca4089b75874061cc6ff5166f6288878b45efaa6162dac0a8759c3e444a3c3b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.
Transparency log