vcti-shader-transform 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. vcti_shader_transform-1.0.0/LICENSE +8 -0
  2. vcti_shader_transform-1.0.0/PKG-INFO +320 -0
  3. vcti_shader_transform-1.0.0/README.md +291 -0
  4. vcti_shader_transform-1.0.0/pyproject.toml +94 -0
  5. vcti_shader_transform-1.0.0/setup.cfg +4 -0
  6. vcti_shader_transform-1.0.0/src/vcti/shader/transform/__init__.py +147 -0
  7. vcti_shader_transform-1.0.0/src/vcti/shader/transform/py.typed +0 -0
  8. vcti_shader_transform-1.0.0/src/vcti/shader/transform/slang/transform.slang +119 -0
  9. vcti_shader_transform-1.0.0/src/vcti/shader/transform/slang/transform_r32ui.slang +48 -0
  10. vcti_shader_transform-1.0.0/src/vcti/shader/transform/specs.py +108 -0
  11. vcti_shader_transform-1.0.0/src/vcti/shader/transform/table.py +233 -0
  12. vcti_shader_transform-1.0.0/src/vcti/shader/transform/transform.py +438 -0
  13. vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/PKG-INFO +320 -0
  14. vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/SOURCES.txt +24 -0
  15. vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/dependency_links.txt +1 -0
  16. vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/requires.txt +16 -0
  17. vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/top_level.txt +1 -0
  18. vcti_shader_transform-1.0.0/tests/test_definition.py +50 -0
  19. vcti_shader_transform-1.0.0/tests/test_docs.py +237 -0
  20. vcti_shader_transform-1.0.0/tests/test_examples.py +268 -0
  21. vcti_shader_transform-1.0.0/tests/test_interface.py +351 -0
  22. vcti_shader_transform-1.0.0/tests/test_specs.py +97 -0
  23. vcti_shader_transform-1.0.0/tests/test_table.py +363 -0
  24. vcti_shader_transform-1.0.0/tests/test_transform.py +672 -0
  25. vcti_shader_transform-1.0.0/tests/test_transform_math.py +361 -0
  26. vcti_shader_transform-1.0.0/tests/test_version.py +15 -0
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
2
+ All Rights Reserved.
3
+
4
+ This software is proprietary and confidential. Unauthorized copying,
5
+ distribution, or use of this software, via any medium, is strictly
6
+ prohibited. Access is granted only to authorized VCollab developers
7
+ and individuals explicitly authorized by Visual Collaboration
8
+ Technologies Inc.
@@ -0,0 +1,320 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcti-shader-transform
3
+ Version: 1.0.0
4
+ 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.
5
+ Author: Visual Collaboration Technologies Inc.
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Repository, https://github.com/vcollab/vcti-python-shader-transform
8
+ Project-URL: Changelog, https://github.com/vcollab/vcti-python-shader-transform/blob/main/CHANGELOG.md
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Python: <3.15,>=3.12
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: vcti-shader-base>=1.0.0
17
+ Provides-Extra: gl
18
+ Requires-Dist: vcti-shader-compiler[gl]>=4.0.0; extra == "gl"
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == "test"
21
+ Requires-Dist: pytest-cov; extra == "test"
22
+ Requires-Dist: vcti-shader-compiler>=4.0.0; extra == "test"
23
+ Requires-Dist: numpy; extra == "test"
24
+ Provides-Extra: lint
25
+ Requires-Dist: ruff; extra == "lint"
26
+ Provides-Extra: typecheck
27
+ Requires-Dist: mypy; extra == "typecheck"
28
+ Dynamic: license-file
29
+
30
+ # vcti-shader-transform
31
+
32
+ The transform shader feature: the vertex-stage lookup that places each submesh by a translation, rotation and scale read from a client-owned table.
33
+
34
+ ## Overview
35
+
36
+ A viewer needs to move mesh components around while the user works — explode an
37
+ assembly, drag a component aside, turn one to look behind it. Rebuilding
38
+ geometry for each of those is slow and gets slower as the model grows, so this
39
+ feature does it differently: the mesh is divided once into **submeshes** — any
40
+ subsets the client wants to address as units — every vertex carries the id of
41
+ the one it belongs to, and the client keeps a **row per submesh** holding a glTF
42
+ 2.0 node's translation, rotation and per-axis scale. The shader fetches each
43
+ vertex's row and places the vertex and its normal by it.
44
+
45
+ Moving any submesh is then one row write. No geometry is rebuilt, no buffer
46
+ repacked, and the cost does not depend on how many vertices the submesh has.
47
+
48
+ Which id the table is indexed by is the client's choice. A transform is usually
49
+ a mesh component's, so a client usually binds its component-id buffer; a client
50
+ that places finer submeshes binds their id buffer instead, and the same shader
51
+ places by it.
52
+
53
+ `vcti-shader-transform` is the shader half of that arrangement. It ships the
54
+ Slang that addresses the table and applies the placement, a Python mirror of
55
+ the same row and the same math so a caller can build a row and predict what the
56
+ shader will do with it, the specs saying what the lookup needs bound, and the
57
+ `ShaderDefinition` saying what the feature is.
58
+
59
+ Everything here is a declaration or fixed shader source. Nothing compiles or
60
+ runs a shader; a build step does that, using what this package declares.
61
+
62
+ ## Installation
63
+
64
+ ```bash
65
+ pip install vcti-shader-transform
66
+ ```
67
+
68
+ Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`. Nothing native
69
+ is built on that path, and nothing native is built by `[test]` either — only the
70
+ `[gl]` extra pulls a GL binding, and only on 3.14 does that compile from source
71
+ for want of a cp314 wheel.
72
+
73
+ ### In `requirements.txt`
74
+
75
+ ```
76
+ vcti-shader-transform>=1.0.0
77
+ ```
78
+
79
+ ### In `pyproject.toml` dependencies
80
+
81
+ ```toml
82
+ dependencies = [
83
+ "vcti-shader-transform>=1.0.0",
84
+ ]
85
+ ```
86
+
87
+ ## Quick Start
88
+
89
+ ### Build a row
90
+
91
+ The client owns the table, so building its rows is the first thing a caller
92
+ does. A row is eleven words: a presence word, then the transform as float bits:
93
+
94
+ ```python
95
+ import math
96
+ from vcti.shader.transform import STRIDE, Transform, axis_angle, pack_row
97
+
98
+ moved = Transform(
99
+ translation=(1.0, 2.0, 3.0),
100
+ rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2),
101
+ scale=(2.0, 2.0, 2.0),
102
+ )
103
+ row = pack_row(moved)
104
+ assert len(row) == STRIDE == 11
105
+ assert row[0] == 1
106
+
107
+ unmoved = pack_row()
108
+ assert unmoved == (0,) * 11
109
+ ```
110
+
111
+ The presence word is derived, never passed: it is one exactly when a transform
112
+ was supplied that is not the identity. That is what keeps the word and the ten
113
+ slots from disagreeing, and the shader tests it before reading them — an
114
+ unmoved submesh costs one fetch, and a zero-filled table is a valid table of
115
+ identities rather than a table of collapsed geometry.
116
+
117
+ ### Predict what the shader does
118
+
119
+ The same math the shader runs, in Python. Scale in the submesh's own frame,
120
+ then rotate, then translate, the order a glTF node applies its own:
121
+
122
+ ```python
123
+ from vcti.shader.transform import unpack_row
124
+
125
+ placed = unpack_row(row).apply((1.0, 0.0, 0.0))
126
+ assert [round(component, 6) for component in placed] == [1.0, 4.0, 3.0]
127
+ assert unpack_row(unmoved) == Transform()
128
+ ```
129
+
130
+ A normal goes through the same rotation and the *inverse* of the scale, which
131
+ the package mirrors too, because with a per-axis scale a normal that was only
132
+ rotated lights a stretched submesh wrongly:
133
+
134
+ ```python
135
+ stretched = Transform(scale=(2.0, 1.0, 1.0))
136
+ turned = stretched.apply_normal((0.6, 0.8, 0.0))
137
+ assert [round(component, 4) for component in turned] == [0.3511, 0.9363, 0.0]
138
+ ```
139
+
140
+ ### Turn a component about its own centre
141
+
142
+ The scale and rotation in a row act about the **model origin** — there is no
143
+ pivot slot and the shader applies none — so a client turning a mesh component
144
+ about its own centre folds the pivot into the translation. `about_pivot` is that
145
+ fold, and the pivot is the point it leaves alone:
146
+
147
+ ```python
148
+ component_centre = (10.0, 0.0, 0.0)
149
+ turned_in_place = Transform(rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2)).about_pivot(
150
+ component_centre
151
+ )
152
+ placed_centre = turned_in_place.apply(component_centre)
153
+ assert [round(component, 6) for component in placed_centre] == [10.0, 0.0, 0.0]
154
+ ```
155
+
156
+ Without the fold the same rotation would swing the component across the model:
157
+ `Transform(rotation=...).apply((10.0, 0.0, 0.0))` is `(0.0, 10.0, 0.0)`.
158
+
159
+ Scale components must be positive. Zero would flatten the submesh and negative
160
+ would mirror it, so `Transform` refuses both:
161
+
162
+ ```python
163
+ try:
164
+ Transform(scale=(1.0, 0.0, 1.0))
165
+ except ValueError as error:
166
+ assert "not positive" in str(error)
167
+ ```
168
+
169
+ The rotation must be a unit quaternion, refused on the same terms: a non-unit
170
+ one scales as well as rotating, and the shader trusts the row.
171
+
172
+ ```python
173
+ try:
174
+ Transform(rotation=(2.0, 0.0, 0.0, 0.0))
175
+ except ValueError as error:
176
+ assert "not a unit quaternion" in str(error)
177
+ ```
178
+
179
+ The scalar is **last** — `(x, y, z, w)`. A scalar-first quaternion is usually
180
+ unit, so it passes the check and rotates: `(1.0, 0.0, 0.0, 0.0)` written for the
181
+ identity is a half turn about x here. Build rotations with `axis_angle` and the
182
+ question does not arise.
183
+
184
+ ### Upload it
185
+
186
+ The table is an `R32UI` texture, `NEAREST` filtered, with the rows consecutive
187
+ and each row `stride` words. The texture is two-dimensional, because a single
188
+ row would cap the submesh count at whatever `MAX_TEXTURE_SIZE` a device
189
+ reports; a caller picks a width and a word's address wraps across texture rows:
190
+
191
+ ```python
192
+ from vcti.shader.transform import address, rows_needed, texel
193
+
194
+ word = address(1000, 8, STRIDE) # submesh 1000, first scale slot
195
+ assert word == 11_008
196
+ assert texel(word, 2048) == (768, 5)
197
+ assert rows_needed(5000, STRIDE, 2048) == 27
198
+ ```
199
+
200
+ `pack_table` lays every submesh's row out in that order and pads the last
201
+ texture row, so what it returns is the texture itself — word *a* is the texel
202
+ `texel(a, width)`, and a caller uploads it as-is at that width:
203
+
204
+ ```python
205
+ from vcti.shader.transform import pack_table
206
+
207
+ table = pack_table([None, moved, None], width=8)
208
+ assert len(table) == rows_needed(3, STRIDE, 8) * 8 == 40
209
+ assert table[address(1, 0, STRIDE)] == 1 # submesh 1's presence word
210
+ assert table[address(0, 0, STRIDE)] == 0 # submesh 0 never moved
211
+ ```
212
+
213
+ A `None` entry is an unmoved submesh, so a client with a sparse set of
214
+ placements passes `None` for the rest. The result is a tuple of ints, which a
215
+ caller with an array library wraps before upload — this package has no array
216
+ dependency to return something narrower.
217
+
218
+ Pass the width and stride as `u_transformLutWidth` and `u_transformLutStride`.
219
+ The stride is eleven today; it is a uniform so that a table a later release
220
+ widens still reads correctly in a shader built against this one.
221
+
222
+ ### What a build step binds
223
+
224
+ ```python
225
+ from vcti.shader.transform import vertex_attributes, vertex_uniforms
226
+
227
+ (attribute,) = vertex_attributes()
228
+ assert (attribute.name, attribute.type, attribute.semantic) == (
229
+ "a_transformId", "int", "transform-id"
230
+ )
231
+ assert [u.name for u in vertex_uniforms()] == ["u_transformLutWidth", "u_transformLutStride"]
232
+ ```
233
+
234
+ The attribute is named for the feature, not for what the id counts. The client
235
+ binds whichever id buffer it likes to it, and where another feature is keyed by
236
+ the same id, binds the same buffer to that feature's attribute too.
237
+
238
+ The table sampler itself is not in there — a texture input cannot yet be
239
+ expressed in `vcti-shader-base`, so it is contract rather than spec. See
240
+ [docs/design.md](docs/design.md).
241
+
242
+ ### Select a pipeline
243
+
244
+ One tag for the behaviour and one naming the table encoding:
245
+
246
+ ```python
247
+ from vcti.shader.transform import DEFINITION
248
+
249
+ assert DEFINITION.id == "transform"
250
+ assert DEFINITION.role.value == "vertex"
251
+ assert set(DEFINITION.capabilities) == {"transform", "transform-lut-r32ui"}
252
+ assert DEFINITION.slang_modules == ("transform.slang", "transform_r32ui.slang")
253
+ ```
254
+
255
+ ## The row
256
+
257
+ | Slot | Holds |
258
+ |---|---|
259
+ | 0 | present — 0 for the identity, 1 for a transform in slots 1-10 |
260
+ | 1-3 | translation `x, y, z` as `float32` bits |
261
+ | 4-7 | rotation quaternion `x, y, z, w` as `float32` bits, scalar last |
262
+ | 8-10 | per-axis scale `x, y, z` as `float32` bits, every component positive |
263
+
264
+ Applied as `rotate(q, scale ⊙ p) + t` to a position and
265
+ `normalize(rotate(q, n / scale))` to a normal. Floats are recovered in the
266
+ shader by bit reinterpretation, which is exact: what a row stores, the shader
267
+ reads back unchanged.
268
+
269
+ Getting a value *into* a row is a narrowing conversion, though — Python floats
270
+ are `float64` and a slot is `float32`, so a row holds the nearest `float32` to
271
+ what it was given. Every component must be a finite one: `Transform` refuses an
272
+ infinity, a NaN, or a value past `FLOAT32_MAX`, because a row has no bits for
273
+ the last and a vertex placed by either of the first two has an undefined
274
+ position at rasterization.
275
+
276
+ A scale is checked as the row *stores* it. A positive `float64` such as `1e-50`
277
+ narrows to zero, and a scale of zero is what the positivity rule exists to
278
+ refuse, so the floor is `FLOAT32_MIN_NORMAL` — the smallest *normal* `float32`,
279
+ because a subnormal one may be flushed to zero by the device even though the
280
+ mirror keeps it.
281
+
282
+ ## API surface
283
+
284
+ | Name | What it is |
285
+ |---|---|
286
+ | `DEFINITION` | the `ShaderDefinition` a build step imports to compose this feature |
287
+ | `SLANG_DIR`, `SLANG_MODULES` | the installed Slang directory, and the modules in it |
288
+ | `ACCESSOR_MODULE`, `FETCH_MODULE` | the two module names, the second derived from the encoding |
289
+ | `Transform`, `axis_angle`, `rotate` | the placement, and the math the shader mirrors |
290
+ | `Vector3`, `Quaternion` | the tuple aliases the placement is written in |
291
+ | `IDENTITY_ROTATION`, `IDENTITY_SCALE` | what `is_identity` compares against |
292
+ | `Transform.about_pivot` | the same placement with its scale and rotation about a pivot |
293
+ | `ROTATION_TOLERANCE` | how far a rotation may sit from unit before `Transform` refuses it |
294
+ | `pack_row`, `unpack_row`, `is_present` | build a row, read one the way the shader does |
295
+ | `pack_table` | every row laid out in address order, padded to fill the texture |
296
+ | `PRESENT`, `TRANSLATION`, `ROTATION`, `SCALE`, `STRIDE` | the slot assignment |
297
+ | `address`, `texel`, `rows_needed` | the addressing arithmetic |
298
+ | `float_to_bits`, `bits_to_float` | the bit reinterpretation a client in another language reproduces |
299
+ | `FLOAT32_MAX`, `FLOAT32_MIN_NORMAL` | the largest value a slot holds, and the floor on a scale |
300
+ | `vertex_attributes`, `vertex_uniforms`, `ATTRIBUTE`, `WIDTH`, `STRIDE_UNIFORM` | what a build step binds |
301
+ | `CAPABILITY`, `ENCODING`, `ENCODING_CAPABILITY` | the tags, and the shipped texture format |
302
+
303
+ ## Dependencies
304
+
305
+ `vcti-shader-base` is the only runtime dependency — declaring a feature is pure
306
+ data. `vcti-shader-compiler>=4.0.0` and `numpy` are test-only, and a separate
307
+ `gl` extra adds the GL binding the shader tests need to execute rather than
308
+ skip.
309
+
310
+ ## Documentation
311
+
312
+ | If you want to… | Read |
313
+ |---|---|
314
+ | Get started using the package | Quick Start above |
315
+ | Size, upload and mutate a table | [docs/patterns.md](docs/patterns.md) |
316
+ | Understand the table contract and the decisions behind it | [docs/design.md](docs/design.md) |
317
+ | Navigate or modify the source, including the Slang | [docs/source-guide.md](docs/source-guide.md) |
318
+
319
+ The full API reference is generated from the source docstrings and published in
320
+ the unified VCollab docs.
@@ -0,0 +1,291 @@
1
+ # vcti-shader-transform
2
+
3
+ The transform shader feature: the vertex-stage lookup that places each submesh by a translation, rotation and scale read from a client-owned table.
4
+
5
+ ## Overview
6
+
7
+ A viewer needs to move mesh components around while the user works — explode an
8
+ assembly, drag a component aside, turn one to look behind it. Rebuilding
9
+ geometry for each of those is slow and gets slower as the model grows, so this
10
+ feature does it differently: the mesh is divided once into **submeshes** — any
11
+ subsets the client wants to address as units — every vertex carries the id of
12
+ the one it belongs to, and the client keeps a **row per submesh** holding a glTF
13
+ 2.0 node's translation, rotation and per-axis scale. The shader fetches each
14
+ vertex's row and places the vertex and its normal by it.
15
+
16
+ Moving any submesh is then one row write. No geometry is rebuilt, no buffer
17
+ repacked, and the cost does not depend on how many vertices the submesh has.
18
+
19
+ Which id the table is indexed by is the client's choice. A transform is usually
20
+ a mesh component's, so a client usually binds its component-id buffer; a client
21
+ that places finer submeshes binds their id buffer instead, and the same shader
22
+ places by it.
23
+
24
+ `vcti-shader-transform` is the shader half of that arrangement. It ships the
25
+ Slang that addresses the table and applies the placement, a Python mirror of
26
+ the same row and the same math so a caller can build a row and predict what the
27
+ shader will do with it, the specs saying what the lookup needs bound, and the
28
+ `ShaderDefinition` saying what the feature is.
29
+
30
+ Everything here is a declaration or fixed shader source. Nothing compiles or
31
+ runs a shader; a build step does that, using what this package declares.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install vcti-shader-transform
37
+ ```
38
+
39
+ Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`. Nothing native
40
+ is built on that path, and nothing native is built by `[test]` either — only the
41
+ `[gl]` extra pulls a GL binding, and only on 3.14 does that compile from source
42
+ for want of a cp314 wheel.
43
+
44
+ ### In `requirements.txt`
45
+
46
+ ```
47
+ vcti-shader-transform>=1.0.0
48
+ ```
49
+
50
+ ### In `pyproject.toml` dependencies
51
+
52
+ ```toml
53
+ dependencies = [
54
+ "vcti-shader-transform>=1.0.0",
55
+ ]
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ### Build a row
61
+
62
+ The client owns the table, so building its rows is the first thing a caller
63
+ does. A row is eleven words: a presence word, then the transform as float bits:
64
+
65
+ ```python
66
+ import math
67
+ from vcti.shader.transform import STRIDE, Transform, axis_angle, pack_row
68
+
69
+ moved = Transform(
70
+ translation=(1.0, 2.0, 3.0),
71
+ rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2),
72
+ scale=(2.0, 2.0, 2.0),
73
+ )
74
+ row = pack_row(moved)
75
+ assert len(row) == STRIDE == 11
76
+ assert row[0] == 1
77
+
78
+ unmoved = pack_row()
79
+ assert unmoved == (0,) * 11
80
+ ```
81
+
82
+ The presence word is derived, never passed: it is one exactly when a transform
83
+ was supplied that is not the identity. That is what keeps the word and the ten
84
+ slots from disagreeing, and the shader tests it before reading them — an
85
+ unmoved submesh costs one fetch, and a zero-filled table is a valid table of
86
+ identities rather than a table of collapsed geometry.
87
+
88
+ ### Predict what the shader does
89
+
90
+ The same math the shader runs, in Python. Scale in the submesh's own frame,
91
+ then rotate, then translate, the order a glTF node applies its own:
92
+
93
+ ```python
94
+ from vcti.shader.transform import unpack_row
95
+
96
+ placed = unpack_row(row).apply((1.0, 0.0, 0.0))
97
+ assert [round(component, 6) for component in placed] == [1.0, 4.0, 3.0]
98
+ assert unpack_row(unmoved) == Transform()
99
+ ```
100
+
101
+ A normal goes through the same rotation and the *inverse* of the scale, which
102
+ the package mirrors too, because with a per-axis scale a normal that was only
103
+ rotated lights a stretched submesh wrongly:
104
+
105
+ ```python
106
+ stretched = Transform(scale=(2.0, 1.0, 1.0))
107
+ turned = stretched.apply_normal((0.6, 0.8, 0.0))
108
+ assert [round(component, 4) for component in turned] == [0.3511, 0.9363, 0.0]
109
+ ```
110
+
111
+ ### Turn a component about its own centre
112
+
113
+ The scale and rotation in a row act about the **model origin** — there is no
114
+ pivot slot and the shader applies none — so a client turning a mesh component
115
+ about its own centre folds the pivot into the translation. `about_pivot` is that
116
+ fold, and the pivot is the point it leaves alone:
117
+
118
+ ```python
119
+ component_centre = (10.0, 0.0, 0.0)
120
+ turned_in_place = Transform(rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2)).about_pivot(
121
+ component_centre
122
+ )
123
+ placed_centre = turned_in_place.apply(component_centre)
124
+ assert [round(component, 6) for component in placed_centre] == [10.0, 0.0, 0.0]
125
+ ```
126
+
127
+ Without the fold the same rotation would swing the component across the model:
128
+ `Transform(rotation=...).apply((10.0, 0.0, 0.0))` is `(0.0, 10.0, 0.0)`.
129
+
130
+ Scale components must be positive. Zero would flatten the submesh and negative
131
+ would mirror it, so `Transform` refuses both:
132
+
133
+ ```python
134
+ try:
135
+ Transform(scale=(1.0, 0.0, 1.0))
136
+ except ValueError as error:
137
+ assert "not positive" in str(error)
138
+ ```
139
+
140
+ The rotation must be a unit quaternion, refused on the same terms: a non-unit
141
+ one scales as well as rotating, and the shader trusts the row.
142
+
143
+ ```python
144
+ try:
145
+ Transform(rotation=(2.0, 0.0, 0.0, 0.0))
146
+ except ValueError as error:
147
+ assert "not a unit quaternion" in str(error)
148
+ ```
149
+
150
+ The scalar is **last** — `(x, y, z, w)`. A scalar-first quaternion is usually
151
+ unit, so it passes the check and rotates: `(1.0, 0.0, 0.0, 0.0)` written for the
152
+ identity is a half turn about x here. Build rotations with `axis_angle` and the
153
+ question does not arise.
154
+
155
+ ### Upload it
156
+
157
+ The table is an `R32UI` texture, `NEAREST` filtered, with the rows consecutive
158
+ and each row `stride` words. The texture is two-dimensional, because a single
159
+ row would cap the submesh count at whatever `MAX_TEXTURE_SIZE` a device
160
+ reports; a caller picks a width and a word's address wraps across texture rows:
161
+
162
+ ```python
163
+ from vcti.shader.transform import address, rows_needed, texel
164
+
165
+ word = address(1000, 8, STRIDE) # submesh 1000, first scale slot
166
+ assert word == 11_008
167
+ assert texel(word, 2048) == (768, 5)
168
+ assert rows_needed(5000, STRIDE, 2048) == 27
169
+ ```
170
+
171
+ `pack_table` lays every submesh's row out in that order and pads the last
172
+ texture row, so what it returns is the texture itself — word *a* is the texel
173
+ `texel(a, width)`, and a caller uploads it as-is at that width:
174
+
175
+ ```python
176
+ from vcti.shader.transform import pack_table
177
+
178
+ table = pack_table([None, moved, None], width=8)
179
+ assert len(table) == rows_needed(3, STRIDE, 8) * 8 == 40
180
+ assert table[address(1, 0, STRIDE)] == 1 # submesh 1's presence word
181
+ assert table[address(0, 0, STRIDE)] == 0 # submesh 0 never moved
182
+ ```
183
+
184
+ A `None` entry is an unmoved submesh, so a client with a sparse set of
185
+ placements passes `None` for the rest. The result is a tuple of ints, which a
186
+ caller with an array library wraps before upload — this package has no array
187
+ dependency to return something narrower.
188
+
189
+ Pass the width and stride as `u_transformLutWidth` and `u_transformLutStride`.
190
+ The stride is eleven today; it is a uniform so that a table a later release
191
+ widens still reads correctly in a shader built against this one.
192
+
193
+ ### What a build step binds
194
+
195
+ ```python
196
+ from vcti.shader.transform import vertex_attributes, vertex_uniforms
197
+
198
+ (attribute,) = vertex_attributes()
199
+ assert (attribute.name, attribute.type, attribute.semantic) == (
200
+ "a_transformId", "int", "transform-id"
201
+ )
202
+ assert [u.name for u in vertex_uniforms()] == ["u_transformLutWidth", "u_transformLutStride"]
203
+ ```
204
+
205
+ The attribute is named for the feature, not for what the id counts. The client
206
+ binds whichever id buffer it likes to it, and where another feature is keyed by
207
+ the same id, binds the same buffer to that feature's attribute too.
208
+
209
+ The table sampler itself is not in there — a texture input cannot yet be
210
+ expressed in `vcti-shader-base`, so it is contract rather than spec. See
211
+ [docs/design.md](docs/design.md).
212
+
213
+ ### Select a pipeline
214
+
215
+ One tag for the behaviour and one naming the table encoding:
216
+
217
+ ```python
218
+ from vcti.shader.transform import DEFINITION
219
+
220
+ assert DEFINITION.id == "transform"
221
+ assert DEFINITION.role.value == "vertex"
222
+ assert set(DEFINITION.capabilities) == {"transform", "transform-lut-r32ui"}
223
+ assert DEFINITION.slang_modules == ("transform.slang", "transform_r32ui.slang")
224
+ ```
225
+
226
+ ## The row
227
+
228
+ | Slot | Holds |
229
+ |---|---|
230
+ | 0 | present — 0 for the identity, 1 for a transform in slots 1-10 |
231
+ | 1-3 | translation `x, y, z` as `float32` bits |
232
+ | 4-7 | rotation quaternion `x, y, z, w` as `float32` bits, scalar last |
233
+ | 8-10 | per-axis scale `x, y, z` as `float32` bits, every component positive |
234
+
235
+ Applied as `rotate(q, scale ⊙ p) + t` to a position and
236
+ `normalize(rotate(q, n / scale))` to a normal. Floats are recovered in the
237
+ shader by bit reinterpretation, which is exact: what a row stores, the shader
238
+ reads back unchanged.
239
+
240
+ Getting a value *into* a row is a narrowing conversion, though — Python floats
241
+ are `float64` and a slot is `float32`, so a row holds the nearest `float32` to
242
+ what it was given. Every component must be a finite one: `Transform` refuses an
243
+ infinity, a NaN, or a value past `FLOAT32_MAX`, because a row has no bits for
244
+ the last and a vertex placed by either of the first two has an undefined
245
+ position at rasterization.
246
+
247
+ A scale is checked as the row *stores* it. A positive `float64` such as `1e-50`
248
+ narrows to zero, and a scale of zero is what the positivity rule exists to
249
+ refuse, so the floor is `FLOAT32_MIN_NORMAL` — the smallest *normal* `float32`,
250
+ because a subnormal one may be flushed to zero by the device even though the
251
+ mirror keeps it.
252
+
253
+ ## API surface
254
+
255
+ | Name | What it is |
256
+ |---|---|
257
+ | `DEFINITION` | the `ShaderDefinition` a build step imports to compose this feature |
258
+ | `SLANG_DIR`, `SLANG_MODULES` | the installed Slang directory, and the modules in it |
259
+ | `ACCESSOR_MODULE`, `FETCH_MODULE` | the two module names, the second derived from the encoding |
260
+ | `Transform`, `axis_angle`, `rotate` | the placement, and the math the shader mirrors |
261
+ | `Vector3`, `Quaternion` | the tuple aliases the placement is written in |
262
+ | `IDENTITY_ROTATION`, `IDENTITY_SCALE` | what `is_identity` compares against |
263
+ | `Transform.about_pivot` | the same placement with its scale and rotation about a pivot |
264
+ | `ROTATION_TOLERANCE` | how far a rotation may sit from unit before `Transform` refuses it |
265
+ | `pack_row`, `unpack_row`, `is_present` | build a row, read one the way the shader does |
266
+ | `pack_table` | every row laid out in address order, padded to fill the texture |
267
+ | `PRESENT`, `TRANSLATION`, `ROTATION`, `SCALE`, `STRIDE` | the slot assignment |
268
+ | `address`, `texel`, `rows_needed` | the addressing arithmetic |
269
+ | `float_to_bits`, `bits_to_float` | the bit reinterpretation a client in another language reproduces |
270
+ | `FLOAT32_MAX`, `FLOAT32_MIN_NORMAL` | the largest value a slot holds, and the floor on a scale |
271
+ | `vertex_attributes`, `vertex_uniforms`, `ATTRIBUTE`, `WIDTH`, `STRIDE_UNIFORM` | what a build step binds |
272
+ | `CAPABILITY`, `ENCODING`, `ENCODING_CAPABILITY` | the tags, and the shipped texture format |
273
+
274
+ ## Dependencies
275
+
276
+ `vcti-shader-base` is the only runtime dependency — declaring a feature is pure
277
+ data. `vcti-shader-compiler>=4.0.0` and `numpy` are test-only, and a separate
278
+ `gl` extra adds the GL binding the shader tests need to execute rather than
279
+ skip.
280
+
281
+ ## Documentation
282
+
283
+ | If you want to… | Read |
284
+ |---|---|
285
+ | Get started using the package | Quick Start above |
286
+ | Size, upload and mutate a table | [docs/patterns.md](docs/patterns.md) |
287
+ | Understand the table contract and the decisions behind it | [docs/design.md](docs/design.md) |
288
+ | Navigate or modify the source, including the Slang | [docs/source-guide.md](docs/source-guide.md) |
289
+
290
+ The full API reference is generated from the source docstrings and published in
291
+ the unified VCollab docs.