indicatrix 0.3.3

A physically-based spectral gemstone renderer that turns GemCAD-style cutting schedules into rendered output.
Documentation

indicatrix

A physically-based spectral gemstone renderer. It turns GemCAD-style cutting schedules — a table of facet angles and index positions, as used by faceting diagrams — into rendered output: full spectral analytical / Monte-Carlo raytracing through faceted gemstone geometry, plus the gemological brilliance / fire / scintillation metrics derived along the way.

indicatrix has no dependency on any particular UI toolkit or data source: callers supply plain facet-plane geometry and a material, and get back rendered pixels and/or optical metrics. It is used by apps/indicatrix-cut (interactive viewer), apps/indicatrix-worker (headless CLI / remote render server), and crates/indicatrix-net (the wire protocol between the two).

What it physically models

  • 8-channel stratified hero-wavelength spectral sampling (HWSS) — each ray carries 8 wavelengths at once (one "hero" plus 7 rotated companions), avoiding both the color-banding of naive RGB rendering and the noise of fully independent per-wavelength sampling.
  • Full Stokes–Mueller polarized light transport — 4D Stokes vectors and Mueller matrices for Fresnel reflection/transmission, total-internal-reflection phase retardation, and Brewster-angle extinction, not a scalar reflectance approximation.
  • Sellmeier / Cauchy dispersion — continuous, per-wavelength refractive index from real dispersion equations, not three fixed RGB indices.
  • Birefringence — uniaxial materials split into ordinary/extraordinary rays with spatial walk-off. Uniaxial and biaxial birefringence both run on CPU and GPU (equivalence-checked, see below) — the BiaxialIndicatrix machinery is ported to WGSL, so GemMaterial::gpu_supported is true for every material, Alexandrite/Topaz/Tanzanite included.
  • Beer–Lambert absorption with pleochroism — directional absorption tensors (isotropic / uniaxial o-ray+e-ray / biaxial alpha+beta+gamma) so a pleochroic stone's color genuinely depends on the electric-field direction relative to the crystal's optical axes, not just on wavelength.

13 built-in materials ship with heavily cited Sellmeier/absorption data (GemMaterial::all_materials(), GemMaterial::by_name(...)): Diamond, Sapphire, Ruby, Emerald, Zircon, Alexandrite, Topaz, Spinel, Quartz, Tourmaline, Tanzanite, Moissanite, Cubic Zirconia.

Documentation map

This file is the entry point: the public API, feature flags, and dependency policy. Two companion documents hold the deeper material:

  • docs/physics.md — deliberate deviations from physical truth (do not "fix" these), known simplifications, and the bit-exact golden tests that guard against unintended drift.
  • docs/gpu.md — the GPU port of the spectral transport physics and its tiered equivalence harness against the CPU tracer.

Public API tour

The pipeline is: facet planes + material → trace_spectral_ray (per sample) → CIE XYZ → tone-map/encode (per pixel), and separately: facet planes + material → evaluate_gem_optical_metrics → brilliance / fire / scintillation.

Rendering a sample

use indicatrix::{
    geometry::cuts::StandardGemCuts,
    optics::{
        materials::GemMaterial,
        raytracer::{Ray, LightingPreset, trace_spectral_ray, xyz_to_srgb_gamma, hash_u32},
    },
};
use glam::Vec3;

let planes = StandardGemCuts::standard_round_brilliant(); // or StandardGemCuts::from_asc_schedule(&schedule)
let material = GemMaterial::diamond();                    // or GemMaterial::by_name("Sapphire").unwrap()

let ray = Ray {
    origin: Vec3::new(0.0, 2.5, 0.0),
    dir: Vec3::new(0.18, -1.0, 0.07).normalize(),
};
let seed = 12345u32;

let xyz = trace_spectral_ray(
    ray,
    &planes,
    &material,
    12,                                                    // max_bounces
    LightingPreset::RingLights.studio(1.0, 0.85, 0.95),    // exposure, light_yaw, light_pitch
    seed,
    (hash_u32(seed) as f32) / 4_294_967_295.0,             // hero_rand
    None,                                                  // primary_hit_out
);
let rgba: [u8; 4] = xyz_to_srgb_gamma(xyz);

Real rendering accumulates many samples per pixel across Camera::generate_ray's jittered rays and sums the resulting Vec3 radiance — never averages inline; see indicatrix-net's README for why the sum, not the average, is the thing that composes across a distributed render.

Optical metrics (brilliance / fire / scintillation)

use indicatrix::color::metrics::evaluate_gem_optical_metrics;

let metrics = evaluate_gem_optical_metrics(&planes, &material, cam_yaw, cam_pitch, light_yaw, light_pitch);
println!(
    "brilliance {:.1}%  fire {:.2}  scintillation {:.1}%  windowing {:.1}%  extinction {:.1}%",
    metrics.brilliance_pct, metrics.fire_index, metrics.scintillation_pct,
    metrics.windowing_pct, metrics.extinction_pct,
);

This fires an analytic ray grid (5-point sub-aperture bundles, the standard GIA 0–6° eye cone) through the real facet geometry at the d/F/C Fraunhofer lines — it does not require running the Monte-Carlo path tracer at all.

From a real .asc cutting schedule

use indicatrix_formats::asc::parse_asc;
use indicatrix::geometry::cuts::StandardGemCuts;

let text = std::fs::read_to_string("design.asc")?;
let schedule = parse_asc(&text)?;
let planes = StandardGemCuts::from_asc_schedule(&schedule); // real mast-derived planes, not fabricated
# Ok::<(), String>(())

from_asc_schedule is the non-fabricated path: it uses each tier's real mast (depth) value from the .asc file as a plane offset. from_database_angles is the fallback used when only angle/index data is available (e.g. scraped from a catalog page with no attached .asc) — it fabricates proportional offsets and is honest about that in its own name.

Key types and invariants

  • geometry::GpuFacetPlane { normal: [f32;3], d: f32 } — the core half-space plane type (#[repr(C)], bytemuck::Pod), used for both CPU intersection and GPU buffer upload. GpuFacetPlane::new normalizes normal.
  • geometry::GemPolyhedron — a validated boundary representation (vertices, facet polygons, triangle indices) built from a plane set via polar duality and a 3D convex hull (chull). Vertex welding uses a fixed tolerance (VERTEX_WELD_EPS); designs whose crease points aren't computed exactly (as opposed to rounded to a few decimal places) can end up with an intended-coincident vertex silently split into two nearby ones — see StandardGemCuts::emerald_cut for how a profile-derived cut avoids this by computing crease points exactly.
  • optics::materials::GemMaterial — crystal system, optical character, dispersion model, absorption tensors, birefringence delta, and (for biaxial materials) biaxial_delta_beta_alpha. new_custom(...) builds a material from a mean RI + dispersion/birefringence deltas + RGB absorption for cases with no cited spectroscopic data.
  • optics::raytracer::trace_spectral_ray — the single entry point for one sample's radiance. hero_rand is caller-supplied, not derived internally, so callers can drive stratified sampling across pixels/samples themselves.
  • renderer::pipeline::IndicatrixtracerPipeline and renderer::env_map_gpu::GpuEnvironmentMap are separate, older dead scaffolding that predates the real GPU port described below, and is unrelated to its state. IndicatrixtracerPipeline::new always panics — its own doc comment says so — because the WGSL shader it would load is quarantined and no longer contains a valid compute entry point. Do not read this as evidence about whether GPU physics exists — it doesn't govern that; see The GPU port below for what actually does.

Feature flags

All three are off by default — the base dependency set (glam, bytemuck, chull, tracing, indicatrix-formats) is deliberately small so the crate stays lean and publishable standalone (see below).

Feature Pulls in Unlocks
gpu wgpu, pollster renderer::gpu: a verified GPU port of the spectral transport physics (shaders/spectral_transport.wgsl's transport_main compute kernel) plus the equivalence harness that checks it against the CPU path — see below. Also compiles renderer::env_map_gpu and renderer::pipeline, which are unrelated, older dead scaffolding (renderer::pipeline::IndicatrixtracerPipeline::new panics if constructed — see Key types).
hdr image (default-features off, hdr format only) Radiance .hdr equirectangular environment-map decoding for renderer::env_map::EnvironmentMap. The CPU-side importance-sampling machinery itself (Distribution2D, radiance_at, sample/pdf) needs no extra dependency and is always available even without this feature — only file decoding is gated.
serde serde, glam/serde Serialize/Deserialize on the scene-description types indicatrix-net's wire protocol needs: GemMaterial and its nested types, GpuFacetPlane, LightingPreset.

Near-zero-dependency policy

This crate is meant to be publishable standalone, and its Cargo.toml says so at every optional dependency: indicatrix-formats is called out as "a zero-dependency format crate — adds nothing to indicatrix's own dependency tree beyond itself"; the hdr feature's image dependency is pulled in with default-features = false and only the hdr format feature, specifically to avoid dragging in png/gif/webp/avif/exr decoders indicatrix has no use for; serde is feature-gated off by default "so the base indicatrix dependency count... stays publishable." Even a dev-only example (examples/meet_solver_validation.rs) uses std::thread::scope instead of pulling in rayon, citing this same policy. If you're adding a dependency here, ask whether it can be feature-gated, and whether a zero-dependency alternative exists, before adding it unconditionally.

BUILD_ID

pub const indicatrix::BUILD_ID: &str = env!("INDICATRIX_BUILD_ID");

A deterministic content hash (16 lowercase hex chars, 64-bit FNV-1a) of every file under src/ ending in .rs or .wgsl, plus Cargo.toml, computed at build time in build.rs. Files are visited in sorted, POSIX-normalized relative-path order and line endings are normalized (\r\n → \n) before hashing, so the same source produces the same BUILD_ID regardless of host OS or checkout line endings; the relative path is hashed alongside each file's contents, so a rename alone changes the id.

.wgsl is included alongside .rs deliberately: a worker running a GPU backend executes WGSL as its physics, not as decoration on top of it, and two workers with byte-identical Rust but divergent WGSL would otherwise handshake as identical when they aren't.

Why this exists: indicatrix-net's wire protocol handshake refuses to pair a viewer and a remote render worker whose indicatrix builds disagree. Mixing samples traced by two different physics implementations produces a silently, plausibly wrong image — no crash, no error, just numbers that look like a converged render and aren't. indicatrix's physics has changed many times in rapid succession during development (the intersection routine, the Fresnel PDF, the CMFs, wavelength construction, birefringent splitting, absorption, dispersion, spectral MIS), which is exactly the situation a hand-maintained version number can't catch, and this repository has no commit history for a git-based identity to fall back on either. See indicatrix-net's README for the handshake itself.

The GPU port and its equivalence harness

There is a real, verified GPU port of the spectral transport physics. renderer/shaders/spectral_transport.wgsl's transport_main compute kernel is a complete megakernel covering camera-ray generation, polyhedron intersection, Sellmeier dispersion, Fresnel/TIR, full Stokes–Mueller polarized transport, pleochroic Beer–Lambert absorption, Russian roulette, spectral MIS, and uniaxial birefringence — this is not a stub or a partial port. It renders real frames today, checked against the CPU tracer at max genuine ULP = 0 by a tiered equivalence harness (bit-exact integer checks, per-function ULP budgets, an analytically-computable furnace anchor, and statistical comparison of real rendered images). gpu is off by default everywhere, but the port itself is complete: isotropic, uniaxial and biaxial materials all render on it, so GemMaterial::gpu_supported() is unconditionally true.

Full detail — what's wired in where, how to run the harness, and what each of its four tiers actually checks — is in docs/gpu.md.

The physics's deliberate deviations from strict physical truth, its known simplifications, and the bit-exact golden tests that guard render output against unintended drift are in docs/physics.md.

Dependency policy

Base (no-feature) dependencies: glam, bytemuck, chull, tracing, indicatrix-formats — five crates. Adding a sixth, unconditionally, should be treated as a deliberate decision to be documented in Cargo.toml, in keeping with the near-zero-dependency policy above; prefer a feature gate if the functionality is not needed by every caller.

Testing

cargo test -p indicatrix                    # default feature set
cargo test -p indicatrix --features gpu     # additionally compiles gpu-feature unit tests

cargo test -p indicatrix alone does not exercise the gpu feature at all — a real bug once hid in exactly that gap for a long time. If you touch anything under renderer::gpu, renderer::env_map_gpu, renderer::pipeline, or anything gated #[cfg(feature = "gpu")], you must additionally run cargo test -p indicatrix --features gpu to know whether it still compiles and passes; cargo test -p indicatrix will report success while never having looked at that code. (See the workspace root README for the same warning at the workspace-verification level.)

No #[test]-attributed function in this crate requires a live GPU adapter — the --features gpu unit tests (in renderer/gpu/furnace_check.rs and renderer/gpu/ulp.rs) are pure-logic tests of the comparison/tolerance helpers themselves. All GPU-adapter-dependent equivalence checking lives exclusively in examples/gpu_equivalence_harness.rs (above), which cargo test never runs on its own.

examples/meet_solver_validation.rs is corpus-scale validation tooling for geometry::meet_solver — the module that derives a facet's mast (depth) distance from its angle, index position, gear, and meet constraints alone, the inverse of the forward plane-construction path described above.

It re-derives every mast in a real catalogue from angles and meets alone and compares against the file's own recorded values, which is perfect ground truth since every .asc already carries the answer. That needs thousands of real designs, so it reads a facet_diagrams.sqlite catalogue directly rather than living in the test suite — no such catalogue ships with this repository, so the harness only does anything if you already have one. Several constants in the solver cite its full-corpus runs as their provenance.

cargo run --profile probe -p indicatrix --example meet_solver_validation

It reads a database and writes nothing. Don't treat it as an example of this crate's public API; it exists to measure, not to demonstrate.

The solver's own logic is covered by 23 unit tests under geometry::meet_solver, which cargo test runs normally. See that module's doc comment for the solver's current accuracy rather than relying on this README.