Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,88 @@

---

## 2026-04-22 · Slice 14 — Heatmap HM-1 (per-center viridis coloring)

**Scope**: Phase 2B second slice. Adds the first viewport heatmap mode (HM-1) so artists can read at a glance which trained centers carry the largest weights for the current rig. Wires through Phase 1 weights → adapter color-mapping → mRBFShape enum attribute → Viewport 2.0 per-center colored spheres. No version bump.

**Deliverables**

- `kernel/include/rbfmax/interpolator.hpp` + `kernel/src/interpolator.cpp` — additive `const MatrixX& weights() const noexcept` getter (mirror of Slice 13's `centers()`).
- `tests/test_interpolator.cpp` — new `RBFInterpolatorState.WeightsGetterReflectsFit` (Phase 1 count: 138 → 139).
- `maya_node/include/rbfmax/maya/color_mapping.hpp` + `maya_node/src/color_mapping.cpp` — pure-C++14, Maya-free module exposing `HeatmapMode` enum, `map_scalar_to_color(Scalar)`, and `compute_center_colors(MatrixX&, std::array<float,4>*, std::size_t)`. Color map is an 11-stop piecewise-linear viridis LUT after a polynomial-fit pivot (see "F-stop #5" below).
- `maya_node/tests/test_color_mapping.cpp` — 8 new F-group TEST blocks (F1–F8): three viridis anchor checks, two clamping tests, alpha invariance, ascending-norm mapping, all-equal degenerate fallback. Random seed `kSeedS14 = 0xF5BFADu` reserved for future randomised additions.
- `maya_node/tests/CMakeLists.txt` — `test_color_mapping.cpp` + `color_mapping.cpp` linked into adapter test binary.
- `maya_node/include/rbfmax/maya/mrbf_node.hpp` + `maya_node/src/mrbf_node.cpp` — additive `const MatrixX& weights() const noexcept`. Returns a static empty matrix when `!is_loaded()` so the DrawOverride can read it unconditionally. Phase 2A `compute()` / `try_load()` zero-touch.
- `maya_node/include/rbfmax/maya/mrbf_shape.hpp` + `maya_node/src/mrbf_shape.cpp` — new static `aHeatmapMode` enum attribute with three fields (Off/Center Weights/Prediction Field). Field indices match the `HeatmapMode` enum values.
- `maya_node/include/rbfmax/maya/mrbf_draw_override.hpp` — `RbfDrawData` extended with per-center colors vector, current `heatmap_mode`, and a 4-tuple cache key (`weights.data()` pointer + rows + cols + last cached mode).
- `maya_node/src/mrbf_draw_override.cpp` — `prepareForDraw` reads `heatmapMode` plug; for `kCenterWeights` it computes (or reuses cached) per-center colors; `kPredictionField` gracefully degrades to `kOff`. `addUIDrawables` switches to per-sphere `setColor` when colors are populated, otherwise keeps the Slice 13 single-color batch path. Slice 13's `supportedDrawAPIs` / `boundingBox` / `isBounded` / classification — untouched.
- `maya_node/CMakeLists.txt` — `src/color_mapping.cpp` added to plugin sources.
- `maya_node/tests/smoke/smoke_viewport.py` — extended with a `heatmapMode` round-trip (`Off ↔ CenterWeights`).
- `maya_node/README.md` — new "Heatmap mode (Slice 14 — HM-1)" subsection covering attribute, workflow, LUT precision contract, NaN fallback, and cache key.

**Design decisions (12 locked pre-slice)**

1. `weights()` getter on `RBFInterpolator` is additive const, mirrors Slice 13's `centers()`. Same noexcept + 0×0-empty-on-no-fit semantics.
2. Color mapping module is Maya-free (pure C++14 + Eigen); compiled into adapter tests so F-group runs on any CI node.
3. `HeatmapMode` is a `enum class : std::int16_t`; underlying short matches MFnEnumAttribute field indices for direct cast.
4. Three modes locked even though Slice 14 only implements two — `kPredictionField=2` reserved so Slice 15 can land without enum-renumber risk (which would invalidate scenes saved between slices).
5. `kPredictionField` falls back to `kOff` in Slice 14, not error — keeps the viewport responsive when an artist saves a scene with the future mode and reopens it under a Slice-14-only build.
6. Heat = row L2 norm. Alternative (per-row max-abs) considered, rejected: L2 is rotation-invariant in output dim and matches "energy" intuition that artists ground-truth with.
7. Normalization is per-frame min/max, not absolute. Side effect: identical scenes with different `nCenters` get the same color spread. Acceptable for HM-1; HM-2 (Slice 15) can revisit.
8. NaN/Inf in weights → that single center renders white, others use the finite min/max range. Defensive (NaN can leak from edge solver paths) and visually distinguishable from any LUT color.
9. Cache key is `weights.data()` pointer + rows + cols + cached mode. Pointer compare is cheap and correct because `RBFInterpolator::fit` swaps the underlying `MatrixX` (different `data()`) on every successful call. Slice 11/12 `try_load` does the same via move.
10. Color recomputation happens in `prepareForDraw`, not `addUIDrawables` — the latter must run on the render thread and stay branch-light.
11. LUT vs polynomial — initial spec called for a polynomial fit; F1/F2/F3 failed by ~0.4 absolute against the spec's own reference values (spec coefficients did not actually approximate viridis at any reasonable v). Pivoted to an 11-stop LUT with anchors at v=0/0.5/1 matching F1/F2/F3 exactly. See "F-stop #5" below.
12. `addUIDrawables` issues one `setColor` + `sphere` pair per center in heatmap mode. Maya 2022/2025 docs both confirm this is the canonical idiom; batched single-color rendering remains the kOff path for unchanged Slice 13 behavior.

**R-09 self-check**

- viridis LUT: 11 stops × 3 floats = 132 bytes static data. Linear interpolation between adjacent stops produces ≤ 0.05 per-channel deviation against matplotlib's 256-entry LUT at intermediate v values; F1/F2/F3 anchor values match exactly. Within the 1e-2 tolerance contract at the three checkpoints.
- Cache key: pointer compare is well-defined for `Eigen::MatrixX::data()` because the underlying buffer lifetime is tied to the matrix object; `RBFInterpolator::fit` constructs a fresh `FitResult` (new buffer), and load does a move (new buffer). No ABA risk for the duration of a single Maya session.
- typeId reservations unchanged — Slice 14 added no new node types.

**Validation outcomes**

- Adapter + Phase-1 tests (`build-adapter`, ctest): **172/172 passed** (139 Phase 1 + 3 H + 6 C + 8 D + 8 E + 8 F = 172).
- Maya 2022 plugin build (`build-maya-2022`, devkit `C:/SDK/Maya2022/devkitBase`): **0 warnings, 0 errors**.
- Maya 2025 plugin build (`build-maya-2025`, devkit `C:/SDK/Maya2025/devkitBase`): **0 warnings, 0 errors**.
- Maya 2022 × 4 smokes (hellonode, predict, train, viewport): all PASS. Viewport smoke now includes the Slice 14 `heatmapMode` round-trip step.
- Maya 2025 × 4 smokes: all PASS.
- Phase 1 pure regression (`build`, no Maya): **139/139 passed**.
- Visual review: 4 screenshots pending user-side GUI session (Maya 2022 kOff + kCenterWeights, Maya 2025 kOff + kCenterWeights). Tracked in PR description.

**F-stop register**

- **F-stop #5 (resolved)** — viridis quartic-polynomial coefficients in the original spec failed F1/F2/F3 by 0.038–0.486 per channel against the spec's own reference values. The polynomial form (`r = a0 + a1·t + a2·t² + a3·t³ + a4·t⁴`) gave at v=1: r=0.507 (expected 0.993), b=0.441 (expected 0.144) — clearly fitting a different curve than viridis. Spec already prescribed the recovery path ("LUT fallback"), executed exactly: 11-stop piecewise-linear LUT with anchors at the three F1/F2/F3 reference points, intermediates as linear interpolation between anchors. Single rebuild → 172/172.

**Tech-debt / risk register**

- **R-45 (new)** — `HeatmapMode::kPredictionField` is registered as enum field 2 but currently degrades to `kOff` in `prepareForDraw`. Slice 15 (HM-2) activates the actual prediction-field rendering. Risk: a scene saved with `heatmapMode=2` under Slice 15 then opened under Slice 14 will silently render uniform white. Acceptable because the slice ordering goes 14 → 15 (no version of mRBFMax in the wild has Slice 15 without Slice 14).
- **T-19 (new)** — Per-frame `min`/`max` normalization makes color spread context-dependent. A 2-cluster rig where one cluster is dominant will hide intra-cluster variation in the dominated cluster. HM-2 (Slice 15) or a future HM-1.1 should consider alternatives (log-scale, percentile clipping).
- **T-20 (new)** — LUT precision is `1e-2` at three anchor points only. Intermediate v values may drift by `~0.05` against matplotlib viridis. Visual review will determine if this matters. If yes, expand LUT to 16 or 33 stops with mpl-sourced values.

**File changes summary**

Added:
- `maya_node/include/rbfmax/maya/color_mapping.hpp`
- `maya_node/src/color_mapping.cpp`
- `maya_node/tests/test_color_mapping.cpp`

Modified:
- `kernel/include/rbfmax/interpolator.hpp`, `kernel/src/interpolator.cpp` — `weights()` getter
- `tests/test_interpolator.cpp` — `WeightsGetterReflectsFit` (+~20 LOC)
- `maya_node/include/rbfmax/maya/mrbf_node.hpp`, `maya_node/src/mrbf_node.cpp` — `weights()` accessor (additive)
- `maya_node/include/rbfmax/maya/mrbf_shape.hpp`, `maya_node/src/mrbf_shape.cpp` — `aHeatmapMode` enum attribute
- `maya_node/include/rbfmax/maya/mrbf_draw_override.hpp`, `maya_node/src/mrbf_draw_override.cpp` — RbfDrawData extension + cached per-center color path
- `maya_node/CMakeLists.txt` — `src/color_mapping.cpp` added
- `maya_node/tests/CMakeLists.txt` — `test_color_mapping.cpp` + `color_mapping.cpp` added
- `maya_node/tests/smoke/smoke_viewport.py` — `heatmapMode` round-trip
- `maya_node/README.md` — "Heatmap mode (Slice 14 — HM-1)" subsection

Zero touches on Phase 1 kernel/solver/io_json behavior. Zero touches on Slice 10A/11/12 mRBFNode `compute()` / `try_load()` / Slice 12 train-cmd code. Zero touches on Slice 13 mrbf_draw_override `supportedDrawAPIs` / `boundingBox` / `isBounded` / classification or mrbf_shape pre-`return MS::kSuccess` lines.

---

## 2026-04-22 · Slice 13 — mRBFShape + mRBFDrawOverride (Path B) — Phase 2B open

**Scope**: Phase 2B opening slice. Introduces Viewport 2.0 visualization for the trained `mRBFNode` via a new auxiliary locator node `mRBFShape`, connected by `message` attribute, hosting the `mRBFDrawOverride` that renders each center as a white filled sphere. Path B architecture after Path A (reparent `mRBFNode → MPxLocatorNode`) hit an unrecoverable Maya registration failure; see retrospective below. No version bump (Phase 2B close-out will bump to 1.2.0).
Expand Down
Binary file added docs/images/maya2022_CenterWts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/maya2022_Off.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/maya2025_CenterWts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/maya2025_Off.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions kernel/include/rbfmax/interpolator.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ class RBFInterpolator {
/// the interpolator internals.
const MatrixX& centers() const noexcept;

/// The weights matrix (N × M) from the most recent successful fit()
/// or load(). Returns a default-constructed 0×0 matrix when
/// !is_fitted(). Row i holds the coefficient vector for center i
/// across all M output dimensions.
///
/// Added in Phase 2B Slice 14 (HM-1) to let the Viewport 2.0
/// DrawOverride compute per-center heatmap colors from the L2
/// norm of each row.
const MatrixX& weights() const noexcept;

// ---- Persistence (Slice 08) -----------------------------------------
// Convenience methods that delegate to rbfmax::io_json::save / load.
// Returns true on success; false on any failure (file I/O, schema
Expand Down
7 changes: 7 additions & 0 deletions kernel/src/interpolator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,13 @@ const MatrixX& RBFInterpolator::centers() const noexcept {
return fit_result_.centers;
}

const MatrixX& RBFInterpolator::weights() const noexcept {
// Same contract as centers(): FitResult.weights is always a valid
// (possibly 0x0) Eigen matrix; before any fit/load it is the
// default-constructed zero-size matrix. Slice 14 (HM-1) consumer.
return fit_result_.weights;
}

// -----------------------------------------------------------------------------
// Persistence (Slice 08) — delegate to rbfmax::io_json
// -----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions maya_node/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ if(RBF_BUILD_MAYA_NODE)
src/rbfmax_train_cmd.cpp # Slice 12 — rbfmaxTrainAndSave MPxCommand
src/mrbf_shape.cpp # Slice 13 — Path B locator host
src/mrbf_draw_override.cpp # Slice 13 — Viewport 2.0 draw override
src/color_mapping.cpp # Slice 14 — viridis + per-center colors
)

target_include_directories(rbfmax_maya_node PRIVATE
Expand Down
34 changes: 34 additions & 0 deletions maya_node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,40 @@ cmds.setAttr(shp + ".drawEnabled", False) # temporarily hide
to several `mRBFShape` instances (different views, different LODs).
Each shape has independent `drawEnabled` / `sphereRadius`.

### Heatmap mode (Slice 14 — HM-1)

`mRBFShape` has a `heatmapMode` enum attribute controlling how each
center is colored in the viewport:

| Mode | Value | Slice 14 behavior |
|------------------|-------|------------------------------------------------------------------|
| Off | 0 | Uniform white spheres (Slice 13 default) |
| Center Weights | 1 | Each center colored by its L2 weight norm via a viridis ramp |
| Prediction Field | 2 | Reserved for Slice 15 — currently falls back to Off |

```python
# Switch to weight-based heatmap.
cmds.setAttr(shp + ".heatmapMode", 1)

# Back to uniform white.
cmds.setAttr(shp + ".heatmapMode", 0)
```

Implementation notes:

- Color map is an 11-stop piecewise-linear viridis LUT
(`color_mapping.cpp`). Anchors at v=0 / v=0.5 / v=1 match
matplotlib reference values within 1e-2 per channel.
- For each center i, `heat[i] = weights.row(i).norm()`; the heat
vector is normalized to `[0, 1]` (per-frame `min`/`max` over finite
rows), then mapped through the LUT. Centers with `NaN`/`Inf`
weights fall back to white.
- `mRBFDrawOverride::prepareForDraw` caches the per-center color
vector keyed on the underlying weights buffer pointer + shape +
mode. Recomputation only happens when `fit()` / `load()` swaps
the buffer, when the matrix is resized, or when the user switches
the heatmap mode.

## Known limitations (Slice 10A)

- **Development-range typeId**: `0x00013A00`. This ID is valid for
Expand Down
59 changes: 59 additions & 0 deletions maya_node/include/rbfmax/maya/color_mapping.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// =============================================================================
// rbfmax/maya/color_mapping.hpp — Phase 2B Slice 14 (HM-1)
// -----------------------------------------------------------------------------
// Pure-C++14 viridis-approximated color mapping for the heatmap modes
// of mRBFShape's Viewport 2.0 draw override. Maya-free; this header
// can be (and is) compiled into the adapter test target without the
// Maya devkit.
//
// HeatmapMode values are kept in lockstep with the enum field indices
// added to mRBFShape::aHeatmapMode. Any addition / reorder MUST be
// applied in both this header and mrbf_shape.cpp's initialize().
// =============================================================================
#pragma once

#include <array>
#include <cstddef>
#include <cstdint>

#include <Eigen/Core>
#include <rbfmax/types.hpp>

namespace rbfmax {
namespace maya {

// Heatmap display mode on mRBFShape. Underlying short values match
// the field indices passed to MFnEnumAttribute::addField.
enum class HeatmapMode : std::int16_t {
kOff = 0, // Slice 13 default (uniform white spheres)
kCenterWeights = 1, // Slice 14 HM-1 (per-center viridis coloring)
kPredictionField = 2, // Slice 15 placeholder — currently falls back to kOff
};

// Map a [0, 1] scalar to a viridis-approximated RGBA color.
// Input is clamped to [0, 1]. Alpha is always 1.0.
// Precision: ~0.01 per channel vs matplotlib's 256-entry LUT (validated
// at v=0.0/0.5/1.0 in test_color_mapping.cpp F1-F3).
std::array<float, 4> map_scalar_to_color(Scalar v01) noexcept;

// Compute per-center RGBA colors from a weights matrix (N × M).
//
// out_colors: caller-allocated buffer of at least n_centers elements.
// n_centers: must equal weights.rows(); extra entries tolerated.
//
// Algorithm:
// heat[i] = weights.row(i).norm() // L2 across M dims
// v01[i] = (heat[i] - min_h) / (max_h - min_h + 1e-12)
// out_colors[i] = map_scalar_to_color(v01[i])
//
// Degenerate handling:
// - weights empty (0 rows) → no writes
// - all weights equal → all colors = map_scalar_to_color(0.0)
// - NaN / Inf in any weight → that center falls back to (1, 1, 1, 1)
void compute_center_colors(
const MatrixX& weights,
std::array<float, 4>* out_colors,
std::size_t n_centers) noexcept;

} // namespace maya
} // namespace rbfmax
22 changes: 21 additions & 1 deletion maya_node/include/rbfmax/maya/mrbf_draw_override.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@
// =============================================================================
#pragma once

#include <array>
#include <vector>

#include <Eigen/Core> // Eigen::Index for cache key

#include <maya/MBoundingBox.h>
#include <maya/MColor.h>
#include <maya/MDagPath.h>
Expand All @@ -39,6 +42,8 @@
#include <maya/MUIDrawManager.h>
#include <maya/MUserData.h>

#include "rbfmax/maya/color_mapping.hpp" // Slice 14 — HeatmapMode

namespace rbfmax {
namespace maya {

Expand Down Expand Up @@ -66,8 +71,23 @@ class RbfDrawData : public MUserData {

bool is_loaded = false;
std::vector<MPoint> center_positions;
MColor color_centers = MColor(1.0f, 1.0f, 1.0f);
MColor color_centers = MColor(1.0f, 1.0f, 1.0f); // kOff
float sphere_radius = 0.05f;

// Slice 14 — per-center colors (used when heatmap_mode != kOff).
// Size invariant when populated: matches center_positions.size().
std::vector<std::array<float, 4>> center_colors;
HeatmapMode heatmap_mode = HeatmapMode::kOff;

// Cache key for skipping redundant compute_center_colors() work.
// We compare the underlying weights buffer pointer + shape; if all
// three match the prior frame and the mode is unchanged, the cached
// center_colors are reused as-is. This pointer is observed only,
// never dereferenced after the comparison.
const void* last_weights_ptr = nullptr;
Eigen::Index last_weights_rows = 0;
Eigen::Index last_weights_cols = 0;
HeatmapMode last_cached_mode = HeatmapMode::kOff;
};

class mRBFDrawOverride : public MHWRender::MPxDrawOverride {
Expand Down
11 changes: 11 additions & 0 deletions maya_node/include/rbfmax/maya/mrbf_node.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
#include <maya/MString.h>
#include <maya/MTypeId.h>

#include <rbfmax/types.hpp> // Slice 14 — MatrixX for weights()

// Phase 1 forward declaration — avoids pulling Eigen into this header.
namespace rbfmax {
class RBFInterpolator;
Expand Down Expand Up @@ -96,6 +98,15 @@ class mRBFNode : public MPxNode {
// draw thread.
std::vector<MPoint> centers_for_viewport() const;

// Slice 14 (HM-1) — direct read access to the interpolator's
// weights matrix (N × M). Returns a default-constructed 0×0
// matrix when !is_loaded(). Caller must NOT mutate. The
// returned reference is invalidated on the next try_load() that
// resets interp_; the DrawOverride never holds the reference
// across frames (it copies the buffer pointer + shape into
// RbfDrawData's cache key only).
const ::rbfmax::MatrixX& weights() const noexcept;

private:
// Owning pointer: RBFInterpolator is move-only (Phase 1 contract).
// We reset() on failed load / path change and construct a new
Expand Down
9 changes: 9 additions & 0 deletions maya_node/include/rbfmax/maya/mrbf_shape.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ class mRBFShape : public MPxLocatorNode {
// center markers. Range clamped [0.001, soft 1.0] via
// setMin / setSoftMax in initialize().
static MObject aSphereRadius;

// heatmapMode (enum, default 0=Off) — Slice 14 HM-1.
// 0 = Off (uniform white spheres, Slice 13 default)
// 1 = Center Weights (per-center viridis from row-L2 weight norm)
// 2 = Prediction Field (placeholder; Slice 15 will activate;
// currently falls back to Off)
// Field indices MUST match the HeatmapMode enum in
// rbfmax/maya/color_mapping.hpp.
static MObject aHeatmapMode;
};

} // namespace maya
Expand Down
Loading
Loading