Metadata-Version: 2.4
Name: vcti-shader-transform
Version: 1.1.0
Summary: The transform shader feature: the vertex-stage lookup that places each submesh by a translation, rotation and scale read from a client-owned table.
Author: Visual Collaboration Technologies Inc.
License-Expression: LicenseRef-Proprietary
Project-URL: Repository, https://github.com/vcollab/vcti-python-shader-transform
Project-URL: Changelog, https://github.com/vcollab/vcti-python-shader-transform/blob/main/CHANGELOG.md
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: <3.15,>=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: vcti-shader-base>=2.0.0
Provides-Extra: gl
Requires-Dist: vcti-shader-compiler[gl]>=4.0.0; extra == "gl"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Requires-Dist: vcti-shader-compiler>=4.0.0; extra == "test"
Requires-Dist: numpy; extra == "test"
Provides-Extra: lint
Requires-Dist: ruff; extra == "lint"
Provides-Extra: typecheck
Requires-Dist: mypy; extra == "typecheck"
Dynamic: license-file

# vcti-shader-transform

The transform shader feature: the vertex-stage lookup that places each submesh by a translation, rotation and scale read from a client-owned table.

## Overview

A viewer needs to move mesh components around while the user works — explode an
assembly, drag a component aside, turn one to look behind it. Rebuilding
geometry for each of those is slow and gets slower as the model grows, so this
feature does it differently: the mesh is divided once into **submeshes** — any
subsets the client wants to address as units — every vertex carries the id of
the one it belongs to, and the client keeps a **row per submesh** holding a glTF
2.0 node's translation, rotation and per-axis scale. The shader fetches each
vertex's row and places the vertex and its normal by it.

Moving any submesh is then one row write. No geometry is rebuilt, no buffer
repacked, and the cost does not depend on how many vertices the submesh has.

Which id the table is indexed by is the client's choice. A transform is usually
a mesh component's, so a client usually binds its component-id buffer; a client
that places finer submeshes binds their id buffer instead, and the same shader
places by it.

`vcti-shader-transform` is the shader half of that arrangement. It ships the
Slang that addresses the table and applies the placement, a Python mirror of
the same row and the same math so a caller can build a row and predict what the
shader will do with it, the specs saying what the lookup needs bound, and the
`ShaderDefinition` saying what the feature is.

Everything here is a declaration or fixed shader source. Nothing compiles or
runs a shader; a build step does that, using what this package declares.

## Installation

```bash
pip install vcti-shader-transform
```

Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`. Nothing native
is built on that path, and nothing native is built by `[test]` either — only the
`[gl]` extra pulls a GL binding, and only on 3.14 does that compile from source
for want of a cp314 wheel.

### In `requirements.txt`

```
vcti-shader-transform>=1.1.0
```

### In `pyproject.toml` dependencies

```toml
dependencies = [
    "vcti-shader-transform>=1.1.0",
]
```

## Quick Start

### Build a row

The client owns the table, so building its rows is the first thing a caller
does. A row is eleven words: a presence word, then the transform as float bits:

```python
import math
from vcti.shader.transform import STRIDE, Transform, axis_angle, pack_row

moved = Transform(
    translation=(1.0, 2.0, 3.0),
    rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2),
    scale=(2.0, 2.0, 2.0),
)
row = pack_row(moved)
assert len(row) == STRIDE == 11
assert row[0] == 1

unmoved = pack_row()
assert unmoved == (0,) * 11
```

The presence word is derived, never passed: it is one exactly when a transform
was supplied that is not the identity. That is what keeps the word and the ten
slots from disagreeing, and the shader tests it before reading them — an
unmoved submesh costs one fetch, and a zero-filled table is a valid table of
identities rather than a table of collapsed geometry.

### Predict what the shader does

The same math the shader runs, in Python. Scale in the submesh's own frame,
then rotate, then translate, the order a glTF node applies its own:

```python
from vcti.shader.transform import unpack_row

placed = unpack_row(row).apply((1.0, 0.0, 0.0))
assert [round(component, 6) for component in placed] == [1.0, 4.0, 3.0]
assert unpack_row(unmoved) == Transform()
```

A normal goes through the same rotation and the *inverse* of the scale, which
the package mirrors too, because with a per-axis scale a normal that was only
rotated lights a stretched submesh wrongly:

```python
stretched = Transform(scale=(2.0, 1.0, 1.0))
turned = stretched.apply_normal((0.6, 0.8, 0.0))
assert [round(component, 4) for component in turned] == [0.3511, 0.9363, 0.0]
```

### Turn a component about its own centre

The scale and rotation in a row act about the **model origin** — there is no
pivot slot and the shader applies none — so a client turning a mesh component
about its own centre folds the pivot into the translation. `about_pivot` is that
fold, and the pivot is the point it leaves alone:

```python
component_centre = (10.0, 0.0, 0.0)
turned_in_place = Transform(rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2)).about_pivot(
    component_centre
)
placed_centre = turned_in_place.apply(component_centre)
assert [round(component, 6) for component in placed_centre] == [10.0, 0.0, 0.0]
```

Without the fold the same rotation would swing the component across the model:
`Transform(rotation=...).apply((10.0, 0.0, 0.0))` is `(0.0, 10.0, 0.0)`.

Scale components must be positive. Zero would flatten the submesh and negative
would mirror it, so `Transform` refuses both:

```python
try:
    Transform(scale=(1.0, 0.0, 1.0))
except ValueError as error:
    assert "not positive" in str(error)
```

The rotation must be a unit quaternion, refused on the same terms: a non-unit
one scales as well as rotating, and the shader trusts the row.

```python
try:
    Transform(rotation=(2.0, 0.0, 0.0, 0.0))
except ValueError as error:
    assert "not a unit quaternion" in str(error)
```

The scalar is **last** — `(x, y, z, w)`. A scalar-first quaternion is usually
unit, so it passes the check and rotates: `(1.0, 0.0, 0.0, 0.0)` written for the
identity is a half turn about x here. Build rotations with `axis_angle` and the
question does not arise.

### Upload it

The table is an `R32UI` texture, `NEAREST` filtered, with the rows consecutive
and each row `stride` words. The texture is two-dimensional, because a single
row would cap the submesh count at whatever `MAX_TEXTURE_SIZE` a device
reports; a caller picks a width and a word's address wraps across texture rows:

```python
from vcti.shader.transform import address, rows_needed, texel

word = address(1000, 8, STRIDE)          # submesh 1000, first scale slot
assert word == 11_008
assert texel(word, 2048) == (768, 5)
assert rows_needed(5000, STRIDE, 2048) == 27
```

`pack_table` lays every submesh's row out in that order and pads the last
texture row, so what it returns is the texture itself — word *a* is the texel
`texel(a, width)`, and a caller uploads it as-is at that width:

```python
from vcti.shader.transform import pack_table

table = pack_table([None, moved, None], width=8)
assert len(table) == rows_needed(3, STRIDE, 8) * 8 == 40
assert table[address(1, 0, STRIDE)] == 1          # submesh 1's presence word
assert table[address(0, 0, STRIDE)] == 0          # submesh 0 never moved
```

A `None` entry is an unmoved submesh, so a client with a sparse set of
placements passes `None` for the rest. The result is a tuple of ints, which a
caller with an array library wraps before upload — this package has no array
dependency to return something narrower.

Pass the width and stride as `u_transformLutWidth` and `u_transformLutStride`.
The stride is eleven today; it is a uniform so that a table a later release
widens still reads correctly in a shader built against this one.

### What a build step binds

```python
from vcti.shader.transform import vertex_attributes, vertex_uniforms

(attribute,) = vertex_attributes()
assert (attribute.name, attribute.type, attribute.semantic) == (
    "a_transformId", "int", "transform-id"
)
assert [u.name for u in vertex_uniforms()] == ["u_transformLutWidth", "u_transformLutStride"]
```

The attribute is named for the feature, not for what the id counts. The client
binds whichever id buffer it likes to it, and where another feature is keyed by
the same id, binds the same buffer to that feature's attribute too.

The table is declared too, as a `TableSpec`, which carries the format it has to
arrive in:

```python
from vcti.shader.transform import vertex_tables

(table,) = vertex_tables()
assert (table.name, table.type) == ("u_transformLut", "Texture2D<uint4>")
assert (table.format, table.semantic) == ("r32ui", "transform")
```

A caller binds it by the **semantic**, not the name: cross-compiling to GLSL ES
pairs the texture with a dummy sampler and names the combination itself, so the
declared name reaches the shader only as a fragment of a generated identifier.
The width and the stride are not part of it — addressing is this feature's own
business. See [docs/design.md](docs/design.md).

### Select a pipeline

One tag for the behaviour and one naming the table encoding:

```python
from vcti.shader.transform import DEFINITION

assert DEFINITION.id == "transform"
assert DEFINITION.role.value == "vertex"
assert set(DEFINITION.capabilities) == {"transform", "transform-lut-r32ui"}
assert DEFINITION.slang_modules == ("transform.slang", "transform_r32ui.slang")
```

## The row

| Slot | Holds |
|---|---|
| 0 | present — 0 for the identity, 1 for a transform in slots 1-10 |
| 1-3 | translation `x, y, z` as `float32` bits |
| 4-7 | rotation quaternion `x, y, z, w` as `float32` bits, scalar last |
| 8-10 | per-axis scale `x, y, z` as `float32` bits, every component positive |

Applied as `rotate(q, scale ⊙ p) + t` to a position and
`normalize(rotate(q, n / scale))` to a normal. Floats are recovered in the
shader by bit reinterpretation, which is exact: what a row stores, the shader
reads back unchanged.

Getting a value *into* a row is a narrowing conversion, though — Python floats
are `float64` and a slot is `float32`, so a row holds the nearest `float32` to
what it was given. Every component must be a finite one: `Transform` refuses an
infinity, a NaN, or a value past `FLOAT32_MAX`, because a row has no bits for
the last and a vertex placed by either of the first two has an undefined
position at rasterization.

A scale is checked as the row *stores* it. A positive `float64` such as `1e-50`
narrows to zero, and a scale of zero is what the positivity rule exists to
refuse, so the floor is `FLOAT32_MIN_NORMAL` — the smallest *normal* `float32`,
because a subnormal one may be flushed to zero by the device even though the
mirror keeps it.

## API surface

| Name | What it is |
|---|---|
| `DEFINITION` | the `ShaderDefinition` a build step imports to compose this feature |
| `SLANG_DIR`, `SLANG_MODULES` | the installed Slang directory, and the modules in it |
| `ACCESSOR_MODULE`, `FETCH_MODULE` | the two module names, the second derived from the encoding |
| `Transform`, `axis_angle`, `rotate` | the placement, and the math the shader mirrors |
| `Vector3`, `Quaternion` | the tuple aliases the placement is written in |
| `IDENTITY_ROTATION`, `IDENTITY_SCALE` | what `is_identity` compares against |
| `Transform.about_pivot` | the same placement with its scale and rotation about a pivot |
| `ROTATION_TOLERANCE` | how far a rotation may sit from unit before `Transform` refuses it |
| `pack_row`, `unpack_row`, `is_present` | build a row, read one the way the shader does |
| `pack_table` | every row laid out in address order, padded to fill the texture |
| `PRESENT`, `TRANSLATION`, `ROTATION`, `SCALE`, `STRIDE` | the slot assignment |
| `address`, `texel`, `rows_needed` | the addressing arithmetic |
| `float_to_bits`, `bits_to_float` | the bit reinterpretation a client in another language reproduces |
| `FLOAT32_MAX`, `FLOAT32_MIN_NORMAL` | the largest value a slot holds, and the floor on a scale |
| `vertex_attributes`, `vertex_uniforms`, `vertex_tables` | what a build step binds |
| `ATTRIBUTE`, `WIDTH`, `STRIDE_UNIFORM`, `TABLE` | the specs themselves |
| `CAPABILITY`, `ENCODING`, `ENCODING_CAPABILITY` | the tags, and the shipped texture format |

## Dependencies

`vcti-shader-base` is the only runtime dependency — declaring a feature is pure
data. `vcti-shader-compiler>=4.0.0` and `numpy` are test-only, and a separate
`gl` extra adds the GL binding the shader tests need to execute rather than
skip.

## Documentation

| If you want to… | Read |
|---|---|
| Get started using the package | Quick Start above |
| Size, upload and mutate a table | [docs/patterns.md](docs/patterns.md) |
| Understand the table contract and the decisions behind it | [docs/design.md](docs/design.md) |
| Navigate or modify the source, including the Slang | [docs/source-guide.md](docs/source-guide.md) |

The full API reference is generated from the source docstrings and published in
the unified VCollab docs.
