Metadata-Version: 2.5
Name: boxglb
Version: 0.1.0
Summary: Write glTF 2.0 / GLB files from axis-aligned boxes. No dependencies.
Project-URL: Homepage, https://github.com/maxweb4u/boxglb
Project-URL: Changelog, https://github.com/maxweb4u/boxglb/blob/main/CHANGELOG.md
Author: Max Gornostayev
License: MIT
License-File: LICENSE
Keywords: 3d,glb,gltf,mesh,packaging,parametric,voxel
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: coverage; extra == 'dev'
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pygltflib; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# 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.

```bash
pip install boxglb
```

## Example

```python
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`](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:

```python
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

```python
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
