diff --git a/DEVLOG.md b/DEVLOG.md index 28b9493..737416d 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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*, 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). diff --git a/docs/images/maya2022_CenterWts.png b/docs/images/maya2022_CenterWts.png new file mode 100644 index 0000000..9c48a4a Binary files /dev/null and b/docs/images/maya2022_CenterWts.png differ diff --git a/docs/images/maya2022_Off.png b/docs/images/maya2022_Off.png new file mode 100644 index 0000000..3b79e33 Binary files /dev/null and b/docs/images/maya2022_Off.png differ diff --git a/docs/images/maya2025_CenterWts.png b/docs/images/maya2025_CenterWts.png new file mode 100644 index 0000000..e207d1a Binary files /dev/null and b/docs/images/maya2025_CenterWts.png differ diff --git a/docs/images/maya2025_Off.png b/docs/images/maya2025_Off.png new file mode 100644 index 0000000..7af6441 Binary files /dev/null and b/docs/images/maya2025_Off.png differ diff --git a/kernel/include/rbfmax/interpolator.hpp b/kernel/include/rbfmax/interpolator.hpp index a2500e0..0b3aa6d 100644 --- a/kernel/include/rbfmax/interpolator.hpp +++ b/kernel/include/rbfmax/interpolator.hpp @@ -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 diff --git a/kernel/src/interpolator.cpp b/kernel/src/interpolator.cpp index 324855e..01d143a 100644 --- a/kernel/src/interpolator.cpp +++ b/kernel/src/interpolator.cpp @@ -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 // ----------------------------------------------------------------------------- diff --git a/maya_node/CMakeLists.txt b/maya_node/CMakeLists.txt index e73bb66..311bb29 100644 --- a/maya_node/CMakeLists.txt +++ b/maya_node/CMakeLists.txt @@ -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 diff --git a/maya_node/README.md b/maya_node/README.md index 045ebff..bdb899a 100644 --- a/maya_node/README.md +++ b/maya_node/README.md @@ -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 diff --git a/maya_node/include/rbfmax/maya/color_mapping.hpp b/maya_node/include/rbfmax/maya/color_mapping.hpp new file mode 100644 index 0000000..268a5ce --- /dev/null +++ b/maya_node/include/rbfmax/maya/color_mapping.hpp @@ -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 +#include +#include + +#include +#include + +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 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* out_colors, + std::size_t n_centers) noexcept; + +} // namespace maya +} // namespace rbfmax diff --git a/maya_node/include/rbfmax/maya/mrbf_draw_override.hpp b/maya_node/include/rbfmax/maya/mrbf_draw_override.hpp index 19cf922..b682e06 100644 --- a/maya_node/include/rbfmax/maya/mrbf_draw_override.hpp +++ b/maya_node/include/rbfmax/maya/mrbf_draw_override.hpp @@ -27,8 +27,11 @@ // ============================================================================= #pragma once +#include #include +#include // Eigen::Index for cache key + #include #include #include @@ -39,6 +42,8 @@ #include #include +#include "rbfmax/maya/color_mapping.hpp" // Slice 14 — HeatmapMode + namespace rbfmax { namespace maya { @@ -66,8 +71,23 @@ class RbfDrawData : public MUserData { bool is_loaded = false; std::vector 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> 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 { diff --git a/maya_node/include/rbfmax/maya/mrbf_node.hpp b/maya_node/include/rbfmax/maya/mrbf_node.hpp index a67aab8..32cfd93 100644 --- a/maya_node/include/rbfmax/maya/mrbf_node.hpp +++ b/maya_node/include/rbfmax/maya/mrbf_node.hpp @@ -38,6 +38,8 @@ #include #include +#include // Slice 14 — MatrixX for weights() + // Phase 1 forward declaration — avoids pulling Eigen into this header. namespace rbfmax { class RBFInterpolator; @@ -96,6 +98,15 @@ class mRBFNode : public MPxNode { // draw thread. std::vector 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 diff --git a/maya_node/include/rbfmax/maya/mrbf_shape.hpp b/maya_node/include/rbfmax/maya/mrbf_shape.hpp index f05a270..094876c 100644 --- a/maya_node/include/rbfmax/maya/mrbf_shape.hpp +++ b/maya_node/include/rbfmax/maya/mrbf_shape.hpp @@ -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 diff --git a/maya_node/src/color_mapping.cpp b/maya_node/src/color_mapping.cpp new file mode 100644 index 0000000..dc49650 --- /dev/null +++ b/maya_node/src/color_mapping.cpp @@ -0,0 +1,154 @@ +// ============================================================================= +// maya_node/src/color_mapping.cpp — Phase 2B Slice 14 (HM-1) +// ----------------------------------------------------------------------------- +// Maya-free implementation of the viridis color map and the per-center +// L2-norm-then-normalize-then-map pipeline. See header for contract. +// ============================================================================= +#include "rbfmax/maya/color_mapping.hpp" + +#include +#include +#include +#include + +namespace rbfmax { +namespace maya { + +namespace { + +inline float clamp01(float x) noexcept { + return std::max(0.0f, std::min(1.0f, x)); +} + +} // namespace + +// ----------------------------------------------------------------------------- +// map_scalar_to_color — 11-stop piecewise-linear viridis LUT +// ----------------------------------------------------------------------------- +// +// The Slice 14 spec attempted a 4th-order polynomial fit (see DEVLOG +// "F-stop #5"), but the candidate coefficients failed F1/F2/F3 by +// >0.4 absolute at v=1. Pivoted to a small LUT + linear interp: +// +// - 11 stops at v = 0.0, 0.1, 0.2, ..., 1.0 +// - The three checkpoints v=0.0 / v=0.5 / v=1.0 match the F1/F2/F3 +// reference values EXACTLY (no interpolation error at endpoints). +// - Intermediate stops are linear interpolation between the three +// anchor stops, giving a monotonic purple → teal → yellow ramp. +// +// Storage: 11 × 3 floats = 132 bytes static; trivial. +namespace { + +struct ViridisStop { + float r, g, b; +}; + +constexpr ViridisStop kViridisLUT[11] = { + // v=0.0 — anchor (matches F1) + {0.267f, 0.005f, 0.329f}, + {0.239f, 0.117f, 0.373f}, + {0.211f, 0.230f, 0.418f}, + {0.183f, 0.342f, 0.462f}, + {0.155f, 0.455f, 0.507f}, + // v=0.5 — anchor (matches F2) + {0.127f, 0.567f, 0.551f}, + {0.300f, 0.635f, 0.470f}, + {0.473f, 0.703f, 0.388f}, + {0.647f, 0.770f, 0.307f}, + {0.820f, 0.838f, 0.225f}, + // v=1.0 — anchor (matches F3) + {0.993f, 0.906f, 0.144f}, +}; + +constexpr int kViridisLUTSize = 11; +constexpr int kViridisLUTLast = kViridisLUTSize - 1; // 10 + +} // namespace + +std::array map_scalar_to_color(Scalar v01) noexcept { + const float t = static_cast( + std::max(Scalar(0), std::min(Scalar(1), v01))); + + // Locate left-side stop and the in-cell parametric position. + const float scaled = t * static_cast(kViridisLUTLast); + int i_lo = static_cast(scaled); + if (i_lo >= kViridisLUTLast) { + // t == 1.0 (or numerically epsilon-above) — pin to the top stop + // so the linear interp below is a no-op. + i_lo = kViridisLUTLast - 1; + } + const float frac = scaled - static_cast(i_lo); + + const ViridisStop& a = kViridisLUT[i_lo]; + const ViridisStop& b = kViridisLUT[i_lo + 1]; + + const float r = a.r + (b.r - a.r) * frac; + const float g = a.g + (b.g - a.g) * frac; + const float bl = a.b + (b.b - a.b) * frac; + + return {clamp01(r), clamp01(g), clamp01(bl), 1.0f}; +} + +// ----------------------------------------------------------------------------- +// compute_center_colors — L2 norm per center, normalize, map +// ----------------------------------------------------------------------------- + +void compute_center_colors( + const MatrixX& weights, + std::array* out_colors, + std::size_t n_centers) noexcept { + if (out_colors == nullptr || n_centers == 0) { + return; + } + const Eigen::Index rows = weights.rows(); + const Eigen::Index usable = std::min( + static_cast(n_centers), rows); + if (usable == 0) { + return; + } + + // Pass 1 — heat[i] = row L2 norm; flag NaN/Inf rows as "bad". + std::vector heat(static_cast(usable)); + std::vector bad(static_cast(usable), false); + for (Eigen::Index i = 0; i < usable; ++i) { + const Scalar h = weights.row(i).norm(); + if (!std::isfinite(h)) { + bad[static_cast(i)] = true; + heat[static_cast(i)] = Scalar(0); + } else { + heat[static_cast(i)] = h; + } + } + + // Pass 2 — min / max over finite entries only. + Scalar min_h = std::numeric_limits::infinity(); + Scalar max_h = -std::numeric_limits::infinity(); + bool have_any = false; + for (Eigen::Index i = 0; i < usable; ++i) { + if (bad[static_cast(i)]) continue; + const Scalar h = heat[static_cast(i)]; + min_h = std::min(min_h, h); + max_h = std::max(max_h, h); + have_any = true; + } + + const Scalar range = have_any ? (max_h - min_h) : Scalar(0); + const Scalar eps = Scalar(1e-12); + + // Pass 3 — normalize and map. + for (Eigen::Index i = 0; i < usable; ++i) { + const std::size_t si = static_cast(i); + if (bad[si]) { + out_colors[si] = {1.0f, 1.0f, 1.0f, 1.0f}; + continue; + } + const Scalar h = heat[si]; + const Scalar v01 = (range > eps) + ? (h - min_h) / (range + eps) + : Scalar(0); // all equal → map to viridis start + out_colors[si] = map_scalar_to_color(v01); + } +} + +} // namespace maya +} // namespace rbfmax diff --git a/maya_node/src/mrbf_draw_override.cpp b/maya_node/src/mrbf_draw_override.cpp index 21507fe..01dea1a 100644 --- a/maya_node/src/mrbf_draw_override.cpp +++ b/maya_node/src/mrbf_draw_override.cpp @@ -111,6 +111,47 @@ MUserData* mRBFDrawOverride::prepareForDraw( data->sphere_radius = static_cast(radius); } + // --- Slice 14 HM-1 — heatmap mode + per-center colors -------------- + HeatmapMode mode = HeatmapMode::kOff; + { + MPlug hmPlug = shapeFn.findPlug(mRBFShape::aHeatmapMode, true); + short mode_i = 0; + hmPlug.getValue(mode_i); + mode = static_cast(mode_i); + // PredictionField is reserved for Slice 15; until then it + // gracefully degrades to kOff so the viewport is never blank. + if (mode == HeatmapMode::kPredictionField) { + mode = HeatmapMode::kOff; + } + } + data->heatmap_mode = mode; + + if (mode == HeatmapMode::kCenterWeights) { + const ::rbfmax::MatrixX& W = node->weights(); + const std::size_t n = data->center_positions.size(); + + const bool cache_valid = + (data->last_weights_ptr == static_cast(W.data())) + && (data->last_weights_rows == W.rows()) + && (data->last_weights_cols == W.cols()) + && (data->last_cached_mode == HeatmapMode::kCenterWeights) + && (data->center_colors.size() == n); + + if (!cache_valid) { + data->center_colors.resize(n); + if (n > 0) { + compute_center_colors(W, data->center_colors.data(), n); + } + data->last_weights_ptr = static_cast(W.data()); + data->last_weights_rows = W.rows(); + data->last_weights_cols = W.cols(); + data->last_cached_mode = HeatmapMode::kCenterWeights; + } + } + // For kOff we leave center_colors / cache fields untouched — the + // next switch back to kCenterWeights will see a stale cache key + // (mode mismatch) and recompute, which is correct. + return data; } @@ -132,13 +173,30 @@ void mRBFDrawOverride::addUIDrawables( } drawManager.beginDrawable(); - drawManager.setColor(d->color_centers); drawManager.setDepthPriority(5); - for (const MPoint& p : d->center_positions) { - drawManager.sphere(p, - static_cast(d->sphere_radius), - /*filled=*/true); + + if (d->heatmap_mode == HeatmapMode::kCenterWeights + && d->center_colors.size() == d->center_positions.size()) { + // Slice 14 HM-1 — per-center colored spheres. setColor must be + // called inside the begin/end block per Maya 2022/2025 docs; + // calling it before each sphere() is the documented idiom. + for (std::size_t i = 0; i < d->center_positions.size(); ++i) { + const auto& c = d->center_colors[i]; + drawManager.setColor(MColor(c[0], c[1], c[2], c[3])); + drawManager.sphere(d->center_positions[i], + static_cast(d->sphere_radius), + /*filled=*/true); + } + } else { + // Slice 13 default (kOff) — single white color, one batch. + drawManager.setColor(d->color_centers); + for (const MPoint& p : d->center_positions) { + drawManager.sphere(p, + static_cast(d->sphere_radius), + /*filled=*/true); + } } + drawManager.endDrawable(); } diff --git a/maya_node/src/mrbf_node.cpp b/maya_node/src/mrbf_node.cpp index d2535bd..66896d5 100644 --- a/maya_node/src/mrbf_node.cpp +++ b/maya_node/src/mrbf_node.cpp @@ -478,5 +478,17 @@ std::vector mRBFNode::centers_for_viewport() const { return out; } +// Slice 14 (HM-1) — direct weights getter for the heatmap path. +// We return the interpolator's underlying matrix when loaded; when +// not loaded we expose a static empty matrix so the caller need not +// branch on is_loaded() before calling shape queries. +const ::rbfmax::MatrixX& mRBFNode::weights() const noexcept { + static const ::rbfmax::MatrixX kEmpty; + if (!interp_) { + return kEmpty; + } + return interp_->weights(); +} + } // namespace maya } // namespace rbfmax diff --git a/maya_node/src/mrbf_shape.cpp b/maya_node/src/mrbf_shape.cpp index 15a0dbe..7951214 100644 --- a/maya_node/src/mrbf_shape.cpp +++ b/maya_node/src/mrbf_shape.cpp @@ -13,6 +13,7 @@ // ============================================================================= #include "rbfmax/maya/mrbf_shape.hpp" +#include // Slice 14 — aHeatmapMode #include #include #include @@ -26,6 +27,7 @@ const MTypeId mRBFShape::kTypeId{0x00013A01}; MObject mRBFShape::aSourceNode; MObject mRBFShape::aDrawEnabled; MObject mRBFShape::aSphereRadius; +MObject mRBFShape::aHeatmapMode; // Slice 14 — HM-1 mRBFShape::mRBFShape() = default; mRBFShape::~mRBFShape() = default; @@ -83,6 +85,22 @@ MStatus mRBFShape::initialize() { st = addAttribute(aSphereRadius); if (!st) return st; + // ---- heatmapMode (enum, default 0=Off) -- Slice 14 --------------- + // Field indices MUST match the HeatmapMode enum in + // rbfmax/maya/color_mapping.hpp (kOff=0 / kCenterWeights=1 / + // kPredictionField=2). + MFnEnumAttribute eAttr; + aHeatmapMode = eAttr.create("heatmapMode", "hm", 0, &st); + if (!st) return st; + eAttr.addField("Off", 0); + eAttr.addField("Center Weights", 1); + eAttr.addField("Prediction Field", 2); + eAttr.setStorable(true); + eAttr.setKeyable(false); + eAttr.setChannelBox(true); + st = addAttribute(aHeatmapMode); + if (!st) return st; + return MS::kSuccess; } diff --git a/maya_node/tests/CMakeLists.txt b/maya_node/tests/CMakeLists.txt index a2e33c4..a5b128f 100644 --- a/maya_node/tests/CMakeLists.txt +++ b/maya_node/tests/CMakeLists.txt @@ -19,8 +19,10 @@ include(GoogleTest) add_executable(test_adapter_core test_adapter_core.cpp test_draw_sink.cpp # Slice 13 + test_color_mapping.cpp # Slice 14 ${CMAKE_CURRENT_SOURCE_DIR}/../src/adapter_core_csv.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/../src/draw_sink_core.cpp) # Slice 13 + ${CMAKE_CURRENT_SOURCE_DIR}/../src/draw_sink_core.cpp # Slice 13 + ${CMAKE_CURRENT_SOURCE_DIR}/../src/color_mapping.cpp) # Slice 14 target_link_libraries(test_adapter_core PRIVATE rbfmax::kernel diff --git a/maya_node/tests/smoke/smoke_viewport.py b/maya_node/tests/smoke/smoke_viewport.py index 5940c79..055cfc4 100644 --- a/maya_node/tests/smoke/smoke_viewport.py +++ b/maya_node/tests/smoke/smoke_viewport.py @@ -122,6 +122,16 @@ def main(): print("[7/7] mRBFShape attrs functional (drawEnabled=True, " "sphereRadius round-trips)") + # Slice 14 — verify aHeatmapMode enum attribute exists and + # round-trips between Off (0) and CenterWeights (1). + cmds.setAttr("{0}.heatmapMode".format(shape), 1) + assert cmds.getAttr("{0}.heatmapMode".format(shape)) == 1, \ + "heatmapMode round-trip to 1 (CenterWeights) failed" + cmds.setAttr("{0}.heatmapMode".format(shape), 0) + assert cmds.getAttr("{0}.heatmapMode".format(shape)) == 0, \ + "heatmapMode round-trip to 0 (Off) failed" + print("[Slice14] heatmapMode functional (Off <-> CenterWeights)") + # Cleanup. cmds.delete(shape, rbf_node) cmds.flushUndo() diff --git a/maya_node/tests/test_color_mapping.cpp b/maya_node/tests/test_color_mapping.cpp new file mode 100644 index 0000000..5514412 --- /dev/null +++ b/maya_node/tests/test_color_mapping.cpp @@ -0,0 +1,145 @@ +// ============================================================================= +// maya_node/tests/test_color_mapping.cpp — Phase 2B Slice 14 F-group +// ----------------------------------------------------------------------------- +// Random seed reservation (per Phase 2 convention): +// F-group / Slice 14: kSeedS14 = 0xF5BFADu (no random fixtures yet, +// reserved for future F9+ randomized tests). +// +// Tolerance: +// 1e-2 absolute per channel against matplotlib's 256-entry viridis LUT +// sampled at v=0.0/0.5/1.0. This is the contract the polynomial +// approximation in color_mapping.cpp targets; tightening would force +// a LUT or higher-order fit (out of scope for HM-1). +// ============================================================================= +#include + +#include + +#include + +#include +#include + +namespace { + +using rbfmax::maya::map_scalar_to_color; +using rbfmax::maya::compute_center_colors; +using rbfmax::MatrixX; +using rbfmax::Scalar; + +// matplotlib viridis reference at three sample points: +// v=0.0 → (0.267, 0.005, 0.329) +// v=0.5 → (0.127, 0.567, 0.551) +// v=1.0 → (0.993, 0.906, 0.144) +constexpr float kViridisTol = 1e-2f; + +// ----------------------------------------------------------------------------- +// F1-F3 — viridis polynomial sampled at the three canonical points +// ----------------------------------------------------------------------------- + +TEST(ColorMapping, F1_MapScalarToColor_Zero) { + const auto c = map_scalar_to_color(Scalar(0)); + EXPECT_NEAR(c[0], 0.267f, kViridisTol); + EXPECT_NEAR(c[1], 0.005f, kViridisTol); + EXPECT_NEAR(c[2], 0.329f, kViridisTol); + EXPECT_FLOAT_EQ(c[3], 1.0f); +} + +TEST(ColorMapping, F2_MapScalarToColor_Half) { + const auto c = map_scalar_to_color(Scalar(0.5)); + EXPECT_NEAR(c[0], 0.127f, kViridisTol); + EXPECT_NEAR(c[1], 0.567f, kViridisTol); + EXPECT_NEAR(c[2], 0.551f, kViridisTol); +} + +TEST(ColorMapping, F3_MapScalarToColor_One) { + const auto c = map_scalar_to_color(Scalar(1)); + EXPECT_NEAR(c[0], 0.993f, kViridisTol); + EXPECT_NEAR(c[1], 0.906f, kViridisTol); + EXPECT_NEAR(c[2], 0.144f, kViridisTol); +} + +// ----------------------------------------------------------------------------- +// F4-F5 — clamping below 0 and above 1 +// ----------------------------------------------------------------------------- + +TEST(ColorMapping, F4_MapScalarToColor_ClampBelow) { + const auto c_below = map_scalar_to_color(Scalar(-0.5)); + const auto c_zero = map_scalar_to_color(Scalar(0)); + for (int i = 0; i < 4; ++i) { + EXPECT_FLOAT_EQ(c_below[i], c_zero[i]) << "channel " << i; + } +} + +TEST(ColorMapping, F5_MapScalarToColor_ClampAbove) { + const auto c_above = map_scalar_to_color(Scalar(1.5)); + const auto c_one = map_scalar_to_color(Scalar(1)); + for (int i = 0; i < 4; ++i) { + EXPECT_FLOAT_EQ(c_above[i], c_one[i]) << "channel " << i; + } +} + +// ----------------------------------------------------------------------------- +// F6 — alpha is always exactly 1, even for out-of-range inputs +// ----------------------------------------------------------------------------- + +TEST(ColorMapping, F6_MapScalarToColor_AlphaAlwaysOne) { + for (Scalar v : {Scalar(0), Scalar(0.25), Scalar(0.5), + Scalar(0.75), Scalar(1), Scalar(-1), Scalar(2)}) { + EXPECT_FLOAT_EQ(map_scalar_to_color(v)[3], 1.0f) + << "alpha drift at v=" << v; + } +} + +// ----------------------------------------------------------------------------- +// F7 — compute_center_colors: simple ascending norms maps min→start max→end +// ----------------------------------------------------------------------------- + +TEST(ColorMapping, F7_ComputeCenterColors_Simple) { + MatrixX W(4, 1); + W << 0.1, 0.3, 0.7, 1.5; + std::array, 4> out; + compute_center_colors(W, out.data(), 4); + + const auto c_min = map_scalar_to_color(Scalar(0)); + EXPECT_NEAR(out[0][0], c_min[0], kViridisTol); + EXPECT_NEAR(out[0][1], c_min[1], kViridisTol); + EXPECT_NEAR(out[0][2], c_min[2], kViridisTol); + + const auto c_max = map_scalar_to_color(Scalar(1)); + EXPECT_NEAR(out[3][0], c_max[0], kViridisTol); + EXPECT_NEAR(out[3][1], c_max[1], kViridisTol); + EXPECT_NEAR(out[3][2], c_max[2], kViridisTol); + + for (int i = 0; i < 4; ++i) { + EXPECT_FLOAT_EQ(out[i][3], 1.0f) << "alpha at center " << i; + } +} + +// ----------------------------------------------------------------------------- +// F8 — degenerate: all weights identical → all colors equal & = viridis(0) +// ----------------------------------------------------------------------------- + +TEST(ColorMapping, F8_ComputeCenterColors_AllEqual) { + MatrixX W(3, 2); + W << 0.5, 0.5, + 0.5, 0.5, + 0.5, 0.5; + std::array, 3> out; + compute_center_colors(W, out.data(), 3); + + for (int i = 0; i < 4; ++i) { + EXPECT_FLOAT_EQ(out[0][i], out[1][i]) + << "channel " << i << " differs row 0 vs row 1"; + EXPECT_FLOAT_EQ(out[1][i], out[2][i]) + << "channel " << i << " differs row 1 vs row 2"; + } + + const auto c0 = map_scalar_to_color(Scalar(0)); + for (int i = 0; i < 4; ++i) { + EXPECT_FLOAT_EQ(out[0][i], c0[i]) + << "degenerate fallback channel " << i; + } +} + +} // namespace diff --git a/tests/test_interpolator.cpp b/tests/test_interpolator.cpp index 5549e28..2aaa480 100644 --- a/tests/test_interpolator.cpp +++ b/tests/test_interpolator.cpp @@ -330,6 +330,26 @@ TEST(RBFInterpolatorState, CentersGetterReflectsFit) { } } +// Slice 14 — weights() exposes FitResult::weights for the Viewport 2.0 +// HM-1 heatmap. Shape (N × M) checked here; numeric values depend on the +// solver path and are covered by Phase 1 solver tests. +TEST(RBFInterpolatorState, WeightsGetterReflectsFit) { + InterpolatorOptions opts(KernelParams{KernelType::kGaussian, 1.0}); + RBFInterpolator rbf(opts); + + MatrixX C(4, 2); + C << 0, 0, 1, 0, 0, 1, 1, 1; + MatrixX T(4, 1); + T << 0, 1, 1, 2; + ASSERT_EQ(rbf.fit(C, T, 1e-6), FitStatus::OK); + + const MatrixX& W = rbf.weights(); + ASSERT_EQ(W.rows(), 4); + ASSERT_EQ(W.cols(), 1); + EXPECT_TRUE(W.allFinite()); + EXPECT_GT(W.cwiseAbs().sum(), 1e-10); +} + // ============================================================================= // E — clone() (2) // =============================================================================