diff --git a/docs/ARIADNE.md b/docs/ARIADNE.md index 9616729fd..bdeec4cdf 100644 --- a/docs/ARIADNE.md +++ b/docs/ARIADNE.md @@ -617,10 +617,22 @@ down), centres it in x and y, sits it on z = 0 and scales its largest extent to | `decimate.target_faces` | triangle target for `cvc::simplify` | `target_faces` | | `repair.weld`, `repair.remove_degenerate`, `repair.remove_unreferenced`, `repair.orient` | `cvc::repair_params` | `weld`, `remove_degenerate`, `remove_unreferenced`, `orient` | | `color.mode` | `none`, `mean`, `gaussian`, `k1`, `k2`, `geodesic`, `modes` — followed live | `color_mode` | -| `color.colormap` | `viridis`, `magma`, `plasma`, `inferno`, `turbo`, `jet`, `parula`, `gray` | `colormap` | +| `color.colormap` | `viridis`, `magma`, `plasma`, `inferno`, `turbo`, `jet`, `parula`, `gray`: resets the field colours (below) to that ramp | `colormap` | | `geodesic.source` | source vertex for `geodesic` (clamped to #V-1; -1 is the highest vertex) | `geodesic_source` | | `modes.index` | the Laplace–Beltrami eigenfunction `modes` shows (`fem::laplacian_eigenmodes` on the surface; 1 is the first non-constant, up to 50) | `mode` | -| `color.min`, `color.max` | the colour range used (2-98% percentiles for curvature) | — | +| `color.min`, `color.max` | the colour range used: the field colours' explicit domain, else the values' (2-98% percentiles for curvature) | — | + +**Field colours (both node types).** The values a node shows are coloured through a transfer +function at `.field_colors` (a `cvc::tf::model` string, outside `mesh_ops.*` / `fe.*`), so +a `tf_editor` edits the ramp: mesh_tools and fe_controls each hold one (`show_opacity: false`, +`show_isocontours: false`). Only the colour ramp is used. The colormap key (`color.colormap`, +`view.colormap`) seeds it and resets it to that colormap's ramp whenever it changes, so a +document stepping through colormaps still works; an edit stands until the next change. An +explicit domain in the model is the range the values are coloured over (in fe_lab it wins over +`view.fixed_range`). Beside it the node publishes `field_colors.range` (the colours' span under +an `auto` domain) and `field_colors.histogram` (128 bins of the values over it; fe_lab's is the +whole solution in the field view, the shown elements' size or quality in the elements view): the +editor's axis and histogram. `curvature` switches a non-curvature `color.mode` to `mean` (or recomputes the one shown). A geodesic solver is cached per mesh version, so moving the source is two @@ -676,13 +688,13 @@ Keys under `.fe.*`: | `modes.index` | the eigenmode `modes` solves for (1 is the first non-constant, up to 50) | `mode` | | `view.mode` | `field` or `elements` | `view` | | `view.boundary` | `input` (the smooth surface, when there is one) or `mesh` | `boundary` | -| `view.colormap`, `view.show_surface`, `view.show_slice` | display | `colormap`, `show_surface`, `show_slice` | +| `view.colormap`, `view.show_surface`, `view.show_slice` | display; `view.colormap` resets the field colours (above) to that ramp | `colormap`, `show_surface`, `show_slice` | | `view.slice_axis`, `view.slice_offset` | 0/1/2 and -1..1 across the mesh bounds: the cut, in both views | `slice_axis`, `slice_offset` | | `view.surface_opacity` | the boundary's opacity while a slice shows | `surface_opacity` | | `view.color_by` | the elements' colour: `size` or `quality` | `color_by` | | `view.cut_side` | the side of the cut the elements view keeps: 1 (+) or -1 (−) | `cut_side` | | `view.ghost` | the outer surface's opacity in the elements view (0 = off) | `ghost` | -| `view.fixed_range`, `view.range_min`, `view.range_max` | colour over a fixed range instead of the data's (both views) | `fixed_range`, `range_min`, `range_max` | +| `view.fixed_range`, `view.range_min`, `view.range_max` | colour over a fixed range instead of the data's (both views; an explicit field-colours domain wins) | `fixed_range`, `range_min`, `range_max` | The heat problem holds the boundary vertices in the lowest 5% of the height at 1 and insulates the rest; the body starts at 0. `auto.solve` solves after every mesh, diff --git a/docs/TRANSFER_FUNCTIONS.md b/docs/TRANSFER_FUNCTIONS.md index f8941ef55..f699c38ca 100644 --- a/docs/TRANSFER_FUNCTIONS.md +++ b/docs/TRANSFER_FUNCTIONS.md @@ -303,6 +303,17 @@ and the overlays, computed by a `schedule_overlays` task from a copy-on-write co (the histogram at once, the spectrum and tree on first request) and re-binned onto `T.range` when that differs from the range they were computed over. +**Field colours: a colour ramp for a field, not a volume.** `mesh_lab` and `fe_lab` also colour +the values they show (a curvature, a geodesic distance, a FEM solution, element sizes) through a +model at `.field_colors`, which the nodes follow themselves (no `tf_binding`): its colour +ramp is baked into the table every GeometryNode showing the values maps through +(`GeometryNode::setColorTable`), over the model's explicit domain or else the values' own range. +The opacity curve and isocontours are not used, so the components' editors hide them +(`show_opacity: false`, `show_isocontours: false`). The node publishes `field_colors.range` and a +128-bin `field_colors.histogram` of the values beside it. The colormap key (`color.colormap`, +`fe.view.colormap`) seeds the model and resets it to that colormap's ramp when it changes. See +[ARIADNE.md](ARIADNE.md), "Field colours". + **volslice: the TF is fixed in data units, the window is the LUT's span.** `VolSliceNode` samples the raw-unit tables over its window (else the data range), so the model's `auto` domain is the volume's range, not the window: moving the window re-lowers nothing, and the slices show the diff --git a/inc/cvc/gl/GeometryNode.h b/inc/cvc/gl/GeometryNode.h index a59843f3d..43e0f0f1e 100644 --- a/inc/cvc/gl/GeometryNode.h +++ b/inc/cvc/gl/GeometryNode.h @@ -135,6 +135,12 @@ class GeometryNode : public GraphicsNode { double hi = std::numeric_limits::quiet_NaN()); void setScalarRange(double lo, double hi); void setColorMap(cvc::colormap_kind cm); + // A colour table the field maps through instead of the colormap: `rgb` holds n >= 2 entries + // (3n floats, each channel 0..1) uniform over [lo, hi], sampled linearly between entries (a + // transfer function's baked ramp, cvc::tf::model::bake). It stays across setScalarField, + // setColorMap and clearScalarField; an empty (or shorter) table goes back to the colormap. + // Recolours in place. NaN values draw mid-gray, as with a colormap. + void setColorTable(const std::vector &rgb); void clearScalarField(); // True while the mesh has a scalar field to colour by (an explicit one, or a // color_by field this geometry carries), shown or hidden by use_single_color. @@ -315,6 +321,7 @@ class GeometryNode : public GraphicsNode { std::vector m_scalarField; // ... and its values std::string m_colorBy = "none"; // the color_by state key cvc::colormap_kind m_colorMap = cvc::colormap_kind::VIRIDIS; + std::vector m_colorTable; // setColorTable: replaces m_colorMap while it has >= 2 entries double m_scalarMin, m_scalarMax; // scalar_min / scalar_max; NaN = auto bool m_scalarActive = false; // the last recolour mapped a field std::pair m_scalarRangeUsed; diff --git a/inc/cvc/gl/ariadne/mesh_nodes.h b/inc/cvc/gl/ariadne/mesh_nodes.h index cfcc8cf2d..28e915d1d 100644 --- a/inc/cvc/gl/ariadne/mesh_nodes.h +++ b/inc/cvc/gl/ariadne/mesh_nodes.h @@ -38,6 +38,7 @@ // geodesic.source (clamped to #V-1; -1 = the highest vertex), modes.index (the // Laplace-Beltrami eigenfunction, >= 1) (followed live) // color.min, color.max (published) +// and .field_colors (below). // // type: fe_lab -- a volume read like a `volren` node (source: { file } or { sdf }), meshed // with the LBIE mesher (cvc::tetrahedralize, tetrahedralize_interval below, hexahedralize) @@ -83,6 +84,16 @@ // .transfer_function is bound (tf_binding.h): its isocontours drive the bounds, over // the range of the volume the last load brought. // +// Field colours (both types): ".field_colors" holds a cvc::tf::model (a tf_editor widget +// edits it) whose colour ramp the shown values map through -- mesh_lab's colour field; fe_lab's +// field on the boundary and the cut, and its elements' size or quality -- over the range they +// are coloured with: the model's explicit domain, else (fe_lab) view.fixed_range's, else their +// own. Its opacity curve and isocontours are not used. The colormap key (color.colormap, +// view.colormap) seeds it and, whenever it changes, resets it to that colormap's ramp (auto +// domain); otherwise an edit stands. Beside it the node publishes .range (the colours' span +// under an auto domain) and .histogram (128 bins of the values over it; fe_lab: the whole +// solution, or the shown elements' values). mesh_lab's color.min / color.max are the range used. +// // Counts and indices read from state are range-checked (NaN = the default): smooth.iterations // 1..1000, decimate.target_faces >= 1, mesh.improve 0..5, mesh.improve_iterations 0..100, // heat.steps 1..1e6, modes.index 1..50, source.dim 0..1024, view.slice_axis 0..2. diff --git a/src/cvc/ariadne/components/fe_controls.ari b/src/cvc/ariadne/components/fe_controls.ari index ae54735c7..bb64b2d75 100644 --- a/src/cvc/ariadne/components/fe_controls.ari +++ b/src/cvc/ariadne/components/fe_controls.ari @@ -10,8 +10,10 @@ # settles, and the editor shades the meshed interval; these sliders move the bars too, and wait for # Mesh. "Solve" runs a linear (P1) FEM Poisson, heat or eigenmode problem on that mesh (cvc::fem, # libigl assembly). The field is shown on the tet boundary surface and on a movable axis-aligned -# cut, coloured by a colormap; the elements view shows the mesh itself, cut away, each element -# coloured by its size or quality. Binds the fe.* keys +# cut; the elements view shows the mesh itself, cut away, each element coloured by its size or +# quality. Both are coloured through the node's field colours (graphics.root.children.domain +# .field_colors, a transfer function: the Colours editor edits its ramp over the values' histogram, +# and the Colormap combo resets it to a named ramp). Binds the fe.* keys # the cvcGL fe_lab realizer owns (state at ".graphics.root.children.domain.fe.*"). Commands # go through fe.request: the realizer consumes it and runs the job off the render thread; Stop # interrupts a heat solve at its next step, and is enabled only while one runs (fe.stoppable): @@ -192,6 +194,15 @@ units: - combo: Colormap bind: graphics.root.children.domain.fe.view.colormap options: [viridis, magma, plasma, inferno, turbo, jet, parula, gray] + tooltip: "resets the colours below to this colormap's ramp" + - tf_editor: Colours + id: fe_field_colors + bind: graphics.root.children.domain.field_colors + height: 70 + layers: [histogram] + show_opacity: false + show_isocontours: false + interactive_updates: true - checkbox: Boundary surface bind: graphics.root.children.domain.fe.view.show_surface visible_when: (!= (state-get "graphics.root.children.domain.fe.view.mode") "elements") diff --git a/src/cvc/ariadne/components/mesh_tools.ari b/src/cvc/ariadne/components/mesh_tools.ari index 9c60c4fd9..a3e870ebc 100644 --- a/src/cvc/ariadne/components/mesh_tools.ari +++ b/src/cvc/ariadne/components/mesh_tools.ari @@ -6,7 +6,10 @@ # mesh_ops.request, the realizer consumes it (writes "" back), runs the operation off the render # thread and publishes status / stats. While a job runs mesh_ops.busy is 1 and the command buttons # grey out. Colour by / Colormap / Geodesic source / Mode are FOLLOWED: changing them recolours the -# mesh. Load reads another mesh file (source.*), so one node can switch datasets. +# mesh. The colours come from the node's field colours (graphics.root.children.mesh.field_colors, a +# transfer function): the Colours editor edits its ramp over the values' histogram, and Colormap +# resets it to a named ramp. Load reads another mesh file (source.*), so one node can switch +# datasets. # Needs a libcvc built with CVC_ENABLE_LIBIGL (the mesh_lab node type is registered only there). # # Convention: it binds a mesh_lab node named `mesh`. To drive a differently-named node, copy this @@ -117,6 +120,15 @@ units: - combo: Colormap bind: graphics.root.children.mesh.mesh_ops.color.colormap options: [viridis, magma, plasma, inferno, turbo, jet, parula, gray] + tooltip: "resets the colours below to this colormap's ramp" + - tf_editor: Colours + id: mesh_field_colors + bind: graphics.root.children.mesh.field_colors + height: 70 + layers: [histogram] + show_opacity: false + show_isocontours: false + interactive_updates: true - slider_int: Geodesic source vertex bind: graphics.root.children.mesh.mesh_ops.geodesic.source lo: -1 diff --git a/src/cvcGL/GeometryNode.cpp b/src/cvcGL/GeometryNode.cpp index b2d0fc74a..4fe6dcd2f 100644 --- a/src/cvcGL/GeometryNode.cpp +++ b/src/cvcGL/GeometryNode.cpp @@ -778,6 +778,32 @@ bool isColorBy(const std::string &s) { return s == "none" || s == "colors" || s == "function" || s == "k1" || s == "k2" || s == "mean" || s == "gaussian"; } + +// colormap_rgb through a colour table (setColorTable) instead of a colormap: entry i of the +// table's n sits at lo + i (hi - lo) / (n - 1), linear between entries, clamped at the ends. +std::vector tableRgb(const std::vector &values, + const std::vector &table, double lo, double hi) { + const std::size_t n = table.size() / 3; + std::vector rgb(3 * values.size()); + const double span = hi - lo; + const auto byte = [](double c) { + return static_cast(std::lround(255.0 * std::clamp(c, 0.0, 1.0))); + }; + for (std::size_t i = 0; i < values.size(); ++i) { + unsigned char *out = &rgb[3 * i]; + if (!std::isfinite(values[i])) { + out[0] = out[1] = out[2] = 128; + continue; + } + const double t = span > 0.0 ? std::clamp((values[i] - lo) / span, 0.0, 1.0) : 0.5; + const double x = t * static_cast(n - 1); + const std::size_t a = std::min(static_cast(x), n - 2); + const double f = x - static_cast(a); + for (int c = 0; c < 3; ++c) + out[c] = byte((1.0 - f) * table[3 * a + c] + f * table[3 * (a + 1) + c]); + } + return rgb; +} } // namespace void GeometryNode::setScalarField(const std::vector &perVertex, cvc::colormap_kind cm, @@ -824,6 +850,17 @@ void GeometryNode::setColorMap(cvc::colormap_kind cm) { }); } +void GeometryNode::setColorTable(const std::vector &rgb) { + runOnMainThread([this, table = rgb]() mutable { + if (table.size() < 6 || table.size() % 3 != 0) + table.clear(); + if (table == m_colorTable) + return; + m_colorTable = std::move(table); + recolor(); + }); +} + void GeometryNode::clearScalarField() { runOnMainThread([this]() { if (!m_hasScalarField) @@ -1034,7 +1071,8 @@ void GeometryNode::applyVertexColors(const cvc::geometry &geom, bool inPlace) { } m_scalarRangeUsed = {lo, hi}; if (!m_useSingleColor) { - rgb = cvc::colormap_rgb(field, m_colorMap, lo, hi); + rgb = m_colorTable.empty() ? cvc::colormap_rgb(field, m_colorMap, lo, hi) + : tableRgb(field, m_colorTable, lo, hi); if (rgb.size() != 3 * n) rgb.clear(); } diff --git a/src/cvcGL/ariadne/mesh_nodes.cpp b/src/cvcGL/ariadne/mesh_nodes.cpp index 673a39876..c7e4b3a5c 100644 --- a/src/cvcGL/ariadne/mesh_nodes.cpp +++ b/src/cvcGL/ariadne/mesh_nodes.cpp @@ -53,7 +53,10 @@ #include #include #include // the isovalue as an editable transfer-function isocontour -#include // cvc::tetrahedralize (CVC_ENABLE_MESHER), element quality +#include +#include // field colours: a transfer function's colour ramp +#include // ... and the range / histogram its editor shows +#include // cvc::tetrahedralize (CVC_ENABLE_MESHER), element quality #include #include #include @@ -370,6 +373,131 @@ void hide_field(GeometryNode &node, bool single_color) { node.setUseSingleColor(single_color); } +// --- field colours ------------------------------------------------------------------------------- +// +// The colours a node's scalar field is drawn with, as a transfer function: the cvc::tf::model at +// ".field_colors" (one string; a tf_editor widget edits it). Its colour ramp is baked into +// the table every GeometryNode showing the field maps through (setColorTable); its opacity curve +// and isocontours are not used. A named colormap key (fe_lab's view.colormap, mesh_lab's +// color.colormap) seeds it and resets it to that colormap's ramp whenever the key changes, so a +// document or a combo still picks a colormap, and an edit in the editor stands until the next one. +// An explicit domain in the model is the range the field is coloured over. Beside the key the node +// publishes .range (what the colours span under an `auto` domain) and .histogram (the shown values +// over it): the editor's axis and histogram. +struct FieldColors { + std::string key; // .field_colors + std::string colormap_seen; // the colormap key's value as last followed + std::string model_seen; // the model string last taken in + cvc::tf::model model; // ... parsed (the last one that parsed) + std::vector table; // its colour ramp, baked: model.resolution RGB entries + std::string published; // what .range / .histogram were last published from +}; + +constexpr int kFieldColorBins = 128; + +// A named colormap as a transfer function: its ramp sampled at 33 nodes, opaque. +cvc::tf::model colormap_model(cvc::colormap_kind k) { + constexpr int kNodes = 33; + std::vector t(kNodes); + for (int i = 0; i < kNodes; ++i) + t[std::size_t(i)] = double(i) / double(kNodes - 1); + const std::vector rgb = cvc::colormap_rgb(t, k, 0.0, 1.0); + cvc::tf::model m; + if (rgb.size() == 3 * t.size()) + for (std::size_t i = 0; i < t.size(); ++i) + m.color.push_back({t[i], rgb[3 * i] / 255.f, rgb[3 * i + 1] / 255.f, rgb[3 * i + 2] / 255.f}); + m.alpha = {{0.0, 1.f}, {1.0, 1.f}}; + m.normalize(); + return m; +} + +// Follow the colormap key (a change resets the model to that colormap's ramp) and the model (a +// change re-bakes the table). True when the table or the model's domain changed; a model that does +// not parse keeps the last good colours. +bool follow_field_colors(cvc::app &a, FieldColors &fc, const std::string &colormap_key) { + const std::string cm = ari::read_or(a, colormap_key, "viridis"); + if (cm != fc.colormap_seen) { + fc.colormap_seen = cm; + ari::write(a, fc.key, cvc::tf::to_string(colormap_model(colormap_or_viridis(cm)))); + } + const std::string s = ari::read_or(a, fc.key, ""); + if (s == fc.model_seen) + return false; + fc.model_seen = s; + cvc::tf::model m; + if (!cvc::tf::from_string(s, m)) + return false; + m.normalize(); + const cvc::tf::domain_spec &d0 = fc.model.domain, &d1 = m.domain; + const bool domain_moved = + d0.automatic != d1.automatic || (!d1.automatic && (d0.min != d1.min || d0.max != d1.max)); + std::vector table; + for (const cvc::tf::rgba &c : m.bake(m.resolution)) + table.insert(table.end(), {c.r, c.g, c.b}); + const bool changed = fc.table.empty() || table != fc.table || domain_moved; + fc.model = std::move(m); + fc.table = std::move(table); + return changed; +} + +// Seed .field_colors with the colormap's ramp when it is empty (a value already there -- a +// restored session, a hot reload's predecessor -- stands) and take it in. +void init_field_colors(cvc::app &a, FieldColors &fc, const std::string &key, + const std::string &colormap_key) { + fc.key = key; + fc.colormap_seen = ari::read_or(a, colormap_key, "viridis"); + if (ari::read_or(a, key, "").empty()) + ari::write( + a, key, cvc::tf::to_string(colormap_model(colormap_or_viridis(fc.colormap_seen)))); + follow_field_colors(a, fc, colormap_key); +} + +// The range a field whose colours would otherwise span [lo, hi] is coloured over: the model's +// explicit domain, else [lo, hi]. And a signature of that choice, for a view's colour signature. +std::pair field_colors_range(const FieldColors &fc, double lo, double hi) { + if (!fc.model.domain.automatic) + return {fc.model.domain.min, fc.model.domain.max}; + return {lo, hi}; +} +std::string field_colors_sig(const FieldColors &fc) { + return fc.model.domain.automatic + ? std::string("auto") + : cvc::tf::range_to_string(fc.model.domain.min, fc.model.domain.max); +} + +// Publish .range = [lo, hi] (the colours' span under an `auto` domain) and .histogram (`values` +// over it, kFieldColorBins bins) beside the key, unless they were last published from the same +// range and `sig` (what names the values). +void publish_field_colors(cvc::app &a, FieldColors &fc, const std::vector &values, + double lo, double hi, const std::string &sig) { + if (!std::isfinite(lo) || !std::isfinite(hi)) + return; + if (hi < lo) + std::swap(lo, hi); + const std::string p = cvc::tf::range_to_string(lo, hi) + "|" + sig; + if (p == fc.published) + return; + fc.published = p; + cvc::tf::overlays ov; + ov.has_range = true; + ov.range_lo = lo; + ov.range_hi = hi; + ov.histogram.assign(kFieldColorBins, 0.0); + const double span = hi - lo; + for (const double v : values) { + if (!std::isfinite(v) || v < lo || v > hi) + continue; + const int b = span > 0.0 ? std::min(kFieldColorBins - 1, + static_cast((v - lo) / span * kFieldColorBins)) + : kFieldColorBins / 2; + ov.histogram[std::size_t(b)] += 1.0; + } + try { + cvc::tf::publish_overlays(cvc::state::instance(a)(fc.key), ov); + } catch (const std::exception &) { + } +} + // Show or hide a child of a node. The document can bind only the node's own visibility, so a // hidden parent hides its children too. The key write is a no-op when unchanged, and // SceneNode::propagateVisible flips the child's flag without touching its key (showing the @@ -652,6 +780,9 @@ struct MeshLabRT { cvc::index_t top_vertex = 0; std::string field_sig; // "|[|]" the shown colour was made for std::string colormap; // the colormap last applied + FieldColors fc; // .field_colors: the colours the field maps through + double field_lo = std::numeric_limits::quiet_NaN(); // the shown field's own range + double field_hi = std::numeric_limits::quiet_NaN(); std::shared_ptr solver; std::uint64_t solver_version = 0; std::shared_ptr eigen; @@ -862,9 +993,13 @@ void apply_mesh_job(MeshLabRT &rt, GeometryNode &node, MeshJob &j) { } if (!j.field.empty()) { rt.colormap = ari::read_or(a, rt.k.colormap, "viridis"); - show_field(node, j.field, colormap_or_viridis(rt.colormap), j.lo, j.hi); - ari::write(a, rt.k.color_min, j.lo); - ari::write(a, rt.k.color_max, j.hi); + rt.field_lo = j.lo; + rt.field_hi = j.hi; + const std::pair r = field_colors_range(rt.fc, j.lo, j.hi); + show_field(node, j.field, colormap_or_viridis(rt.colormap), r.first, r.second); + ari::write(a, rt.k.color_min, r.first); + ari::write(a, rt.k.color_max, r.second); + publish_field_colors(a, rt.fc, j.field, j.lo, j.hi, rt.field_sig); } } else if (j.op == "load") { if (j.geom) { @@ -898,14 +1033,21 @@ void follow_mesh_color(MeshLabRT &rt, GeometryNode &node) { if (!mode.empty() && mode != "none") ari::write(a, rt.k.status, "unknown color.mode '" + mode + "'"); hide_field(node, rt.single_color); + rt.field_lo = rt.field_hi = std::numeric_limits::quiet_NaN(); ari::write(a, rt.k.color_min, "-"); ari::write(a, rt.k.color_max, "-"); } - const std::string cm = ari::read_or(a, rt.k.colormap, "viridis"); - if (cm != rt.colormap) { - rt.colormap = cm; - if (node.hasScalarField()) - node.setColorMap(colormap_or_viridis(cm)); + // The field's colours: color.colormap resets .field_colors to its ramp, and an edited + // ramp or domain recolours in place. + rt.colormap = ari::read_or(a, rt.k.colormap, "viridis"); + if (follow_field_colors(a, rt.fc, rt.k.colormap)) { + node.setColorTable(rt.fc.table); + if (node.hasScalarField() && std::isfinite(rt.field_lo)) { + const std::pair r = field_colors_range(rt.fc, rt.field_lo, rt.field_hi); + node.setScalarRange(r.first, r.second); + ari::write(a, rt.k.color_min, r.first); + ari::write(a, rt.k.color_max, r.second); + } } } @@ -1063,6 +1205,8 @@ std::shared_ptr realize_mesh_lab(SceneGraph &sg, const ari::SceneN ari::write(a, k.color_min, "-"); ari::write(a, k.color_max, "-"); rt->colormap = ari::read_or(a, k.colormap, "viridis"); + init_field_colors(a, rt->fc, g->stateName("field_colors"), k.colormap); + g->setColorTable(rt->fc.table); // Keys written before the scene was realized (an init: program) name another source: read it. if (from_file && mesh_source_from_keys(*rt).sig() != rt->source_sig) ari::write(a, k.request, "load"); @@ -1370,6 +1514,8 @@ struct FeLabRT { std::shared_ptr eigen; // for the current mesh std::uint64_t mesh_version = 0, field_version = 0; std::string main_sig, color_sig, slice_sig, slice_cm, slice_rsig, cut_sig, ghost_sig; + FieldColors + fc; // .field_colors: the colours the field, the cut and the elements map through std::shared_ptr cut; std::uint64_t cut_mesh_version = 0, cut_serial = 0; double opacity = -1.0, ghost_opacity = -1.0; @@ -1950,10 +2096,23 @@ void follow_fe_view(FeLabRT &rt, GeometryNode &node, GeometryNode *slice, Geomet const bool fixed = ari::read_bool_or(a, rt.k.fixed_range, 0) != 0; const double rmin = ari::read_or(a, rt.k.range_min, 0.0); const double rmax = ari::read_or(a, rt.k.range_max, 1.0); - const std::string rsig = fixed ? fmt_g(rmin) + "," + fmt_g(rmax) : std::string("auto"); - const auto range = [&](double lo, double hi) { + // The field's colours (view.colormap resets them to its ramp): a changed ramp recolours every + // node showing the field in place. The colour range: .field_colors' explicit domain, else + // view.fixed_range's, else the values' own (auto_range, what the editor's axis spans). + if (follow_field_colors(a, rt.fc, rt.k.colormap)) { + node.setColorTable(rt.fc.table); + if (slice) + slice->setColorTable(rt.fc.table); + } + const std::string rsig = (fixed ? fmt_g(rmin) + "," + fmt_g(rmax) : std::string("auto")) + "|" + + field_colors_sig(rt.fc); + const auto auto_range = [&](double lo, double hi) { return fixed ? std::make_pair(rmin, rmax) : std::make_pair(lo, hi); }; + const auto range = [&](double lo, double hi) { + const std::pair r = auto_range(lo, hi); + return field_colors_range(rt.fc, r.first, r.second); + }; // A finished cut-away (of this mesh) replaces the shown one. if (rt.cut_job && rt.cut_job->done.load(std::memory_order_acquire)) { @@ -2002,6 +2161,9 @@ void follow_fe_view(FeLabRT &rt, GeometryNode &node, GeometryNode *slice, Geomet rt.color_sig = csig; const std::pair r = range(rt.cut->lo, rt.cut->hi); show_field(node, rt.cut->values, cm, r.first, r.second); + const std::pair ar = auto_range(rt.cut->lo, rt.cut->hi); + publish_field_colors(a, rt.fc, rt.cut->values, ar.first, ar.second, + "e|" + std::to_string(rt.cut_serial)); } } else if (rt.surface && show_surface) { const std::string csig = @@ -2021,6 +2183,10 @@ void follow_fe_view(FeLabRT &rt, GeometryNode &node, GeometryNode *slice, Geomet } else { show_field(node, rt.field, cm, r.first, r.second); } + // The editor's histogram: the whole solution (the cut shows its inside too). + const std::pair ar = auto_range(rt.field_lo, rt.field_hi); + publish_field_colors(a, rt.fc, rt.field, ar.first, ar.second, + "f|" + std::to_string(rt.field_version)); } else { hide_field(node, rt.single_color); } @@ -2405,6 +2571,10 @@ std::shared_ptr realize_fe_lab(SceneGraph &sg, const ari::SceneNod ari::write(a, k.field_min, "-"); ari::write(a, k.field_max, "-"); ari::write(a, k.last_mesh, std::string()); + init_field_colors(a, rt->fc, g->stateName("field_colors"), k.colormap); + g->setColorTable(rt->fc.table); + if (s) + s->setColorTable(rt->fc.table); // Keys written before the scene was realized (an init: program) name another source: load it // first. A seeded request (auto_mesh) runs after it. (A pending load reads the keys anyway.) if (!rt->load_pending && fe_source_from_keys(*rt).sig() != rt->source_sig) { diff --git a/src/cvcGL/test/cvcgl_ariadne_realize.cpp b/src/cvcGL/test/cvcgl_ariadne_realize.cpp index 50c141340..f686ab813 100644 --- a/src/cvcGL/test/cvcgl_ariadne_realize.cpp +++ b/src/cvcGL/test/cvcgl_ariadne_realize.cpp @@ -32,6 +32,8 @@ #include #include #include // clock test: read/write the bound sim.* keys +#include // mesh_lab / fe_lab field colours +#include #include #include #include // mesh_lab / fe_lab sources: a temp "document directory" @@ -265,6 +267,52 @@ static void test_lab_sources(cvc::app &app) { ok = settle(mb); chk(ok && num(mb + ".color.max") > 0.0, "geodesic.source -1: from the highest vertex"); + // Field colours (.field_colors, a transfer function a tf_editor edits): its range and a + // histogram of the values are published beside it, the colormap key resets its ramp, and an + // explicit domain is the range the field is coloured over. + const auto field_colors = [&](const std::string &T, const std::string &cm_key, + const std::string &min_key, const std::string &max_key, + GeometryNode &node, const char *who) { + cvc::tf::model m; + double lo = 0.0, hi = 0.0; + std::vector bins; + double total = 0.0; + const bool published = cvc::tf::from_string(val(T), m) && m.color.size() > 2 && + cvc::tf::range_from_string(val(T + ".range"), lo, hi) && + cvc::tf::histogram_from_string(val(T + ".histogram"), bins); + for (const double b : bins) + total += b; + chk(published && approx(lo, num(min_key)) && approx(hi, num(max_key)) && bins.size() == 128 && + total > 0.0, + std::string(who) + ": field_colors holds a ramp; its range is the values' (" + + val(T + ".range") + ") and a 128-bin histogram is published"); + set(cm_key, "gray"); + tick(); + float c0[3] = {1.f, 1.f, 1.f}, c1[3] = {0.f, 0.f, 0.f}; + if (cvc::tf::from_string(val(T), m)) { + m.color_at(0.0, c0); + m.color_at(1.0, c1); + } + chk(c0[0] < 0.05f && c1[0] > 0.95f && m.domain.automatic, + std::string(who) + ": the colormap key gray resets field_colors to black..white"); + m.domain.automatic = false; + m.domain.min = lo - 1.0; + m.domain.max = hi + 1.0; + set(T, cvc::tf::to_string(m)); + tick(); + const std::pair r = node.scalarRange(); + chk(node.hasScalarField() && approx(r.first, lo - 1.0) && approx(r.second, hi + 1.0), + std::string(who) + ": an explicit field_colors domain is the colour range (" + + std::to_string(r.first) + ".." + std::to_string(r.second) + ")"); + set(cm_key, "viridis"); // back to the viridis ramp, auto domain + tick(); + chk(node.hasScalarField() && approx(node.scalarRange().first, lo) && + approx(node.scalarRange().second, hi), + std::string(who) + ": the colormap key resets the domain to auto"); + }; + field_colors(cvc::ariadne::resolve_bind("lsrc", "graphics.root.children.mesh.field_colors"), + mb + ".color.colormap", mb + ".color.min", mb + ".color.max", *mesh, "mesh_lab"); + #ifdef CVC_ENABLE_MESHER if (!domain) { // the SDF needs CVC_ENABLE_SDF printf(" (fe_lab skipped: no volume -- a build without CVC_ENABLE_SDF?)\n"); @@ -330,6 +378,9 @@ static void test_lab_sources(cvc::app &app) { "fe_lab: a chained request (mesh solve) runs both"); chk(!val(fb + ".stats.work_ms").empty() && num(fb + ".stats.work_ms") >= 0.0, "fe_lab publishes stats.work_ms (" + val(fb + ".stats.work_ms") + ")"); + tick(); + field_colors(cvc::ariadne::resolve_bind("lsrc", "graphics.root.children.domain.field_colors"), + fb + ".view.colormap", fb + ".field.min", fb + ".field.max", *domain, "fe_lab"); // Interval meshing: the shell between two densities is a solid layer with a hole. set(fb + ".mesh.region", "interval"); diff --git a/src/cvcGL/test/cvcgl_scalar_field.cpp b/src/cvcGL/test/cvcgl_scalar_field.cpp index ed5ee1573..02b6b2026 100644 --- a/src/cvcGL/test/cvcgl_scalar_field.cpp +++ b/src/cvcGL/test/cvcgl_scalar_field.cpp @@ -22,7 +22,8 @@ // colours it bakes into the polydata's 3-channel uchar array must equal // cvc::colormap_rgb of the field, survive use_single_color and render-mode // rebuilds, change in place (same colour array, no cell rebuild) for a range -// or colormap change, and a size mismatch must no-op. Under a texture the +// or colormap change, and a size mismatch must no-op. A colour table +// (setColorTable) replaces the colormap until it is cleared. Under a texture the // geometry's colours stay off whatever is recoloured; a field still shows. The // polydata is reached // through SceneNode::prop() -> vtkActor -> mapper input. Headless except the @@ -30,6 +31,7 @@ // (skipped where nothing rasterises, unless CVC_REQUIRE_RENDER=1). Explicit // checks + non-zero return (assert() is a no-op under Release). +#include #include #include #include @@ -275,6 +277,33 @@ void testScalarField(cvc::app &app) { check(colorsOf(*node) == magma, "an unknown colormap name is ignored"); check(noRebuild(), "state-key recolours: still no rebuild"); + // A colour table replaces the colormap (a transfer function's baked ramp): entries uniform over + // the range, linear between them. Black -> red -> white over [-5, 5]: x = 0 is pure red. + const std::vector table = {0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 1.f, 1.f}; + std::vector want(3 * n); + for (size_t i = 0; i < n; ++i) { + const double t = std::min(1.0, std::max(0.0, (x[i] + 5.0) / 10.0)); + const double r = std::min(1.0, 2.0 * t), gb = std::max(0.0, 2.0 * t - 1.0); + want[3 * i] = static_cast(std::lround(255.0 * r)); + want[3 * i + 1] = want[3 * i + 2] = static_cast(std::lround(255.0 * gb)); + } + node->setColorTable(table); + check(colorsOf(*node) == want, "setColorTable: the field maps through the table"); + check(noRebuild(), "setColorTable: recoloured in place"); + node->setColorMap(colormap_kind::VIRIDIS); + node->setScalarField(x, colormap_kind::GRAY, -5.0, 5.0); + check(colorsOf(*node) == want, "the table stays across setColorMap and setScalarField"); + node->setColorTable({0.f, 0.f, 0.f, 1.f}); // fewer than two entries: none + check(colorsOf(*node) == cvc::colormap_rgb(x, colormap_kind::GRAY, -5.0, 5.0), + "a table of fewer than two entries goes back to the colormap"); + node->setColorTable(table); + node->setColorTable({}); + check(colorsOf(*node) == cvc::colormap_rgb(x, colormap_kind::GRAY, -5.0, 5.0), + "an empty table goes back to the colormap"); + setStateKey(*node, "colormap", "magma"); // as before the table checks: magma, auto range + setStateKey(*node, "scalar_min", ""); + setStateKey(*node, "scalar_max", ""); + // use_single_color hides the field; switching it off brings the field back. node->setUseSingleColor(true); check(colorArray(*node) == nullptr && !scalarsVisible(*node),