Metadata-Version: 2.4
Name: vcti-shader-fringe
Version: 1.0.1
Summary: The fringe colormap shader feature: the fragment-stage math that turns a value into a colour.
Author: Visual Collaboration Technologies Inc.
License-Expression: LicenseRef-Proprietary
Project-URL: Repository, https://github.com/vcollab/vcti-python-shader-fringe
Project-URL: Changelog, https://github.com/vcollab/vcti-python-shader-fringe/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>=1.0.0
Provides-Extra: gl
Requires-Dist: vcti-shader-compiler[gl]>=3.0.0; extra == "gl"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Requires-Dist: vcti-shader-compiler>=3.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-fringe

The fringe colormap shader feature: the fragment-stage math that turns a value into a colour.

## Overview

A fringe plot colours a 3D model by the values at its nodes and elements. Drawing
one means mapping each fragment's value to an RGBA colour — and that last step is
what `vcti-shader-fringe` is.

A colormap here is a list of **bands**. Each band covers a half-open value range
`[lower, upper)` and carries the colours across it. Two ideas fall out of that
and keep the lookup small:

- **A band whose two colours are equal is a constant band.** There is no separate
  discrete mode — a ramp between one colour and itself is that colour.
- **Below-range, above-range and no-value are ordinary bands too**, appended to
  cover the rest of the number line, so the lookup has no special case for them.

A colormap also carries fallback colours, for the ways a value can have no band
to come from: it is NaN, no band covers it, or the band that does is
misconfigured. Each points at a different fix, so each gets its own colour.

There are **two variants**, and the data chooses which. A continuous result — a
displacement magnitude, a von Mises stress — is a float, interpolates across a
band, and uses `Colormap`. A discrete category — a material id, a part number —
is an exact integer, takes one colour per band, and uses `DiscreteColormap`.
Colouring an id through the float path works until it passes 2²⁴, where
neighbouring ids collapse onto the same number and take the same colour; the
integer path is exact to 2³¹.

Around that math the package declares two things — the **specs** saying what the
math needs supplied, and the **`ShaderDefinition`** saying what the feature is —
and ships **the same lookup written in Python**. Nothing here compiles or runs a
shader; a build step does that, using what this package declares.

The Python version is there for two reasons. The tests run it and the compiled
shader over the same values and check the colours come out identical — that is
how the shader is verified. And whatever draws the legend can call it directly,
so the colour blocks in the legend always match the colours on the model.

## Installation

```bash
pip install vcti-shader-fringe
```

Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`, and so does
the `test` extra. Only the `gl` extra — the GL binding the shader tests need to
execute anything — is narrower in practice: on 3.14 it builds moderngl's
glcontext from source for want of a cp314 wheel, so those tests are run on 3.12
or 3.13.

### In `requirements.txt`

```
vcti-shader-fringe>=1.0.0
```

### In `pyproject.toml` dependencies

```toml
dependencies = [
    "vcti-shader-fringe>=1.0.0",
]
```

---

## Quick Start

### What the feature is

```python
from vcti.shader.fringe import DEFINITION, SLANG_DIR, fragment_uniforms

DEFINITION.id            # 'fringe'
DEFINITION.role          # StageRole.FRAGMENT
DEFINITION.slang_modules # ('colormap.slang',) — a shader does `import colormap;`
SLANG_DIR                # pass to the compiler as an import search path

[u.name for u in fragment_uniforms()]
# ['u_bandBounds', 'u_numBands', 'u_lowerColors', 'u_upperColors',
#  'u_interpModes', 'u_interpSteps', 'u_nanColor']
```

### A continuous colormap

`linear_bands()` turns bounds and a palette into ramped bands;
`with_edge_bands()` frames them so the list covers the whole number line:

```python
from vcti.shader.fringe import Colormap, linear_bands, with_edge_bands

PALETTE = [(0.0, 0.26, 0.62, 1.0), (0.65, 0.84, 0.85, 1.0), (0.6, 0.0, 0.0, 1.0)]
GREY = (0.83, 0.83, 0.83, 1.0)

colormap = Colormap(
    with_edge_bands(
        linear_bands([0.0, 1.0, 2.0], PALETTE),   # one colour per bound
        below_color=PALETTE[0],
        above_color=PALETTE[-1],
        no_value_color=GREY,
        no_value_lower=1e30,
    )
)
colormap.validate()   # raises ValueError on a gap, overlap, or bad log bound
```

`constant_bands()` gives banded contours instead — one colour per band rather
than per bound. A constant band is simply one whose two colours are equal, so
there is no separate mode to select.

### A discrete colormap

```python
from vcti.shader.fringe import DiscreteBand, DiscreteColormap, category_bands

materials = DiscreteColormap(category_bands([STEEL, ALUMINIUM, COPPER]))

by_id = DiscreteColormap((                 # raw solver ids, in ranges
    DiscreteBand(1_000_000, 2_000_000, STEEL),
    DiscreteBand(2_000_000, 3_000_000, ALUMINIUM),
))
```

### Looking up a colour

`fringe_color()` is the Python version of the shipped Slang lookup:

```python
from vcti.shader.fringe import fringe_color

fringe_color(5.0, colormap)             # halfway through the linear band
fringe_color(31.6, colormap)            # the geometric midpoint of the log band
fringe_color(-1.0, colormap)            # below the range -> BLUE
fringe_color(3.402823466e38, colormap)  # the no-value sentinel -> GREY
fringe_color(float("nan"), colormap)    # not a number -> the NaN colour
```

### Handing it to the GPU

```python
colormap.uniforms()
# {'u_bandBounds': [(-inf, 0.0), (0.0, 10.0), ...], 'u_numBands': 5, ...}
```

Arrays are padded to `MAX_BANDS`; the shader reads only the first `u_numBands`.

---

## Key API

| Name | What it is |
|---|---|
| `DEFINITION` | the `ShaderDefinition` the feature declares itself with |
| `SLANG_DIR` | the installed `slang/` directory — an `import` search path |
| `fragment_uniforms(kind)` | the uniforms a shader declares, per variant |
| `fragment_inputs(kind)` | the vertex attribute the integer variant reads |
| `fragment_outputs()` | the `fragColor` output the feature writes |
| `ValueType` | `FLOAT` or `INT` — which variant a shader is built for |
| `Band` | one half-open range, its two colours, and its interpolation mode |
| `Colormap` | a band list, three fallback colours, and an optional step count |
| `DiscreteBand` | one half-open range of integer categories and its colour |
| `DiscreteColormap` | a band list and the colour for a category none holds |
| `Colormap.validate()` | raises on a gap, an overlap, or a log band reaching zero |
| `Colormap.uniforms()` | the `{uniform: value}` mapping, padded to `MAX_BANDS` |
| `linear_bands()` | bounds + one colour each → ramped bands |
| `constant_bands()` | bounds + one colour per band → flat bands |
| `category_bands()` | one band per consecutive integer category |
| `with_edge_bands()` | frames authored bands with below/above/no-value |
| `fringe_color(value, colormap)` | the colour a fragment takes — same result as the shader |
| `fringe_color_int(value, colormap)` | the same, for integer categories |
| `band_color()`, `band_parameter()` | the same, for a single band already in hand |
| `InterpMode` | `LINEAR` or `LOG` |
| `INTERP_MODES` | the same as a name-to-number map, as published on the spec |
| `MAX_BANDS` | band-array capacity, shared with the Slang module |

`colormap.slang` publishes `applyFringe`, `applyFringeInt` and
`fringeBandColor`. All take every value as an argument — nothing in the module
reads a uniform, so a shipped shader and the test probes run the same code from
different sources.

---

## Dependencies

- [`vcti-shader-base`](https://github.com/vcollab/vcti-python-shader-base) — the
  `ShaderDefinition` record and the spec types, itself zero-dependency.

Nothing else at runtime. `vcti-shader-compiler` and `numpy` are **test-only**:
the tests run the shader on a GPU and compare it against the Python version,
but declaring the feature needs neither.

---

## Documentation

| If you want to… | Read |
|---|---|
| Get started using the package | Quick Start above |
| Build the colormaps a viewer actually ships | [docs/patterns.md](docs/patterns.md) |
| Understand the colour model and the decisions behind it | [docs/design.md](docs/design.md) |
| Navigate or modify the source | [docs/source-guide.md](docs/source-guide.md) |
