diff --git a/deps/src/00_includes.cpp b/deps/src/00_includes.cpp index 2b90661..575c918 100644 --- a/deps/src/00_includes.cpp +++ b/deps/src/00_includes.cpp @@ -28,8 +28,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -40,6 +42,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/deps/src/10_geometry.cpp b/deps/src/10_geometry.cpp index 1f697b0..52bc6c1 100644 --- a/deps/src/10_geometry.cpp +++ b/deps/src/10_geometry.cpp @@ -287,6 +287,17 @@ static int polyHitPolygon(Scene *s, int x, int y, double tol); // p static void nestReflow(Scene *s, bool snap = true); // re-quantize "Nested grids" chain (85); snap=false = don't move verts, only recompute indices (restore) static void nestNewChild(Scene *s); // append a refined nested child (85) +// Per-side illumination snapshot for an Aquamoto layer. WATER and LAND are two SEPARATE images, each +// with its OWN light: editing the selected side updates only ITS snapshot, and bakeAquaShade re-bakes +// the OTHER side from its own (unchanged) snapshot -- so a water edit changes NOTHING of the land +// (no colour, no light), and vice versa. Only geometry (xfac/zfac/ve) is shared (read live from Scene). +struct AquaSideShade { + bool valid = false; + bool useHillshade = true, hillGrd = true, litBake = false; + double lightAz = 315.0, lightEl = 45.0, hillAmbient = 0.3, hillGain = 2.0; + double roughness = 0.45, lightIntensity = 1.0, fillIntensity = 0.4; +}; + // ---- scene we hang onto for the callbacks / menu actions -------------------- struct Scene { vtkSmartPointer ren; @@ -351,6 +362,26 @@ struct Scene { double barLo = 0, barHi = 1; bool surfShowBar = true; // base relief: user wants its colorbar shown (when active) + // --- Aquamoto dual colorbar (water / land) ------------------------------ + // A tsunami netCDF layer (customLayerTexture) needs TWO colour scales at once: `bar` above + // (built exactly like any other grid's colorbar by showLayerImageTail) serves as the WATER bar; + // this is a SEPARATE, persistent LAND bar for the (static, file-open-time) bathymetry range. + // Only one is ever visible at a time, gated by aquaShowWater (mirrors the Aquamoto dialog's + // Shade Water/Land radio) ANDed with each side's own Scene-Objects checkbox. + vtkSmartPointer aquaLandLut; // keeps the land CTF alive (mirrors surfLut) + vtkSmartPointer aquaLandBar; + vtkSmartPointer aquaLandBarTicks; + std::vector> aquaLandBarLabels; + std::vector aquaLandBarValues; + vtkSmartPointer aquaLandBarTickPts; + double aquaLandBarLo = 0, aquaLandBarHi = 1; + bool aquaLandShowBar = true; // Scene-Objects "Color Bar Land" checkbox intent + bool aquaShowWater = true; // which side is ACTIVE; default water (per spec) + std::string aquaVarLabel; // Scene Objects label for the composited surface's OWN + // group = the active variable's real name, whatever the + // file itself calls it (gmtvtk_aqua_set_var_label_h); + // empty -> rebuildSceneObjects falls back to surfName. + // --- tiled-LOD pyramid (plain grid) ------------------------------------- // Quadtree of tiles; coarse near root, refined per-frame by screen-space error so only the // visible region at the needed resolution is resident. surfGroup holds the live tile actors. @@ -380,6 +411,22 @@ struct Scene { // (no geometry rebuild). Cleared by buildSceneContent (any real surface build). int layerTexW = 0, layerTexH = 0; // baked hillshade texture size (<= grid size; capped so a huge // cube's per-layer bake stays cheap). Fast-repaint reuses the texture iff these match. + bool customLayerTexture = false; // the drape texture was supplied ALREADY COMPOSITED by the host + // (gmtvtk_show_layer_rgba_h, e.g. Aquamoto's dry/wet blend) instead of baked + // here from a single CPT + illumination -- rebakeLayerImage/refineLayerDetail + // must never regenerate it from gridZ+cpt, or they would silently overwrite it. + // Aquamoto hillshade: shade the host-composited texture through the SAME applyReliefShade the whole + // app uses (bakeAquaShade), so the Shading dock's Hillshade drives the tsunami too. aquaBaseRGBA is + // the UNSHADED composite (row-major, row0=south, nx*ny*4) re-shaded on every dock change; aquaBathyZ + // is the static bathymetry (column-major, like gridZ) = the LAND surface, while gridZ carries the + // per-slice stage = the WATER surface. Empty -> not an Aquamoto layer, bakeAquaShade is a no-op. + std::vector aquaBaseRGBA; + std::vector aquaBathyZ; + AquaSideShade aquaWaterShade; // WATER image's OWN light (updated only when Water is the selected side) + AquaSideShade aquaLandShade; // LAND image's OWN light (updated only when Land is the selected side) + bool aquaShadeSelWater = true; // which side the Shading dock edits (Shade Water/Land radio). PURE + // selector: flipping it changes NOTHING visible, NOT the colorbar -- + // it only routes the NEXT shading edit to water (true) or land (false). // 3-D-cube shading selection. A cube layer can be shown either as the fast flat shaded IMAGE (the new // "Shaded image (2-D)" algorithm) or, if the user picks one of the surface looks (Cast shadows / // Hillshade Lambert / grdimage) in the Shading dock, as a real 3-D surface with that look. The dock diff --git a/deps/src/30_app.cpp b/deps/src/30_app.cpp index 77578b4..7ae4a7b 100644 --- a/deps/src/30_app.cpp +++ b/deps/src/30_app.cpp @@ -305,6 +305,17 @@ static JuliaCubeLoadAllFn g_juliaCubeLoadAll = nullptr; typedef void (*JuliaCubeSliderFn)(void *scene, const char *name); static JuliaCubeSliderFn g_juliaCubeSlider = nullptr; +// The Aquamoto control window (75_aquamoto.cpp) closes to HIDDEN, never destroyed; its viewer scene's +// surface handle offers "Aquamoto viewer…" to re-show it. surfaceObjectMenu (50_scene.cpp) is compiled +// before 75_aquamoto.cpp, so it reaches the window through these hooks, which 75 installs at load. +// `has` reports whether `scene` owns an Aquamoto window (gates the menu entry); `reopen` re-shows it. +struct Scene; +static bool (*g_aquamotoHasWindow)(Scene *scene) = nullptr; +static void (*g_aquamotoReopen)(Scene *scene) = nullptr; +static void (*g_aquamotoSetVisible)(Scene *scene, int on) = nullptr; // Scene Objects handle checkbox: show/hide the window +static bool (*g_aquamotoIsVisible)(Scene *scene) = nullptr; // current window visibility (checkbox initial state) +static void (*g_aquamotoDestroy)(Scene *scene) = nullptr; // destroy the window (lifetime-tied to its nc cube surface) + // File > Save Grid / Save Image. The host File menu opens a QFileDialog (format picked via the // filter) and hands ";;" to Julia (g_juliaSave): kind = "grid" | "image"; fmt a // short format code (nc/surfer/gtiff/jp2/erdas/envi for grids; those + jpg/png/tif/bmp for images); diff --git a/deps/src/40_shading.cpp b/deps/src/40_shading.cpp index cea7e70..e0649ab 100644 --- a/deps/src/40_shading.cpp +++ b/deps/src/40_shading.cpp @@ -190,16 +190,21 @@ static void layerTexSize(int nx, int ny, int &txW, int &txH) { // whole extent for the base texture, a zoomed sub-rectangle for the detail tile), so the same code // serves both the full-map bake and the hi-res zoom refine. This loop IS the cost of a layer switch / // relight / zoom refine: no VTK geometry. +// `baseRGBA` (optional, node-resolution nx*ny*4, row-major row0=south) overrides the CPT lookup: when +// given, each pixel's ALBEDO is the host composite (e.g. Aquamoto's dry/wet blend) instead of CPT(z), +// and everything else — the SAME gradient, normal and applyReliefShade/applyPBRShade — is identical. +// This is how the tsunami shades through the ONE bake function instead of a fork; grids pass nullptr. static void bakeLayerRGBA(Scene *s, const float *z, int nx, int ny, double gx0, double gy0, double dx, double dy, vtkColorTransferFunction *ctf, double lo, double hi, double wx0, double wx1, double wy0, double wy1, - int txW, int txH, std::vector &out) { + int txW, int txH, std::vector &out, + const unsigned char *baseRGBA = nullptr) { out.assign((size_t)txW * txH * 4, 0); - if (!ctf || dx == 0.0 || dy == 0.0) return; - // discretize the CPT once + if ((!ctf && !baseRGBA) || dx == 0.0 || dy == 0.0) return; + // discretize the CPT once (skipped when a host composite supplies the albedo) const int NT = 1024; - std::vector tbl((size_t)NT * 3); - { + std::vector tbl(baseRGBA ? 0 : (size_t)NT * 3); + if (!baseRGBA) { const double span = (hi > lo) ? (hi - lo) : 1.0; double c[3]; for (int i = 0; i < NT; ++i) { @@ -227,15 +232,22 @@ static void bakeLayerRGBA(Scene *s, const float *z, int nx, int ny, double gx0, const int ix = clampi((int)std::lround((tx - gx0) / dx), nx - 1); const double zc = Zc(ix, iy); unsigned char *p = out.data() + ((size_t)r * txW + col) * 4; - if (std::isnan(zc)) { // paint NaN with the Preferences NaN fill colour (opaque) - p[0] = (unsigned char)(s->nanColor[0]*255.0+0.5); - p[1] = (unsigned char)(s->nanColor[1]*255.0+0.5); - p[2] = (unsigned char)(s->nanColor[2]*255.0+0.5); - p[3] = 255; continue; + unsigned char cr, cg, cb; + if (baseRGBA) { // albedo from the host composite (node-aligned, row0=south) + const size_t bi = ((size_t)iy * nx + ix) * 4; + cr = baseRGBA[bi]; cg = baseRGBA[bi+1]; cb = baseRGBA[bi+2]; + } else { + if (std::isnan(zc)) { // paint NaN with the Preferences NaN fill colour (opaque) + p[0] = (unsigned char)(s->nanColor[0]*255.0+0.5); + p[1] = (unsigned char)(s->nanColor[1]*255.0+0.5); + p[2] = (unsigned char)(s->nanColor[2]*255.0+0.5); + p[3] = 255; continue; + } + int ti = (int)((zc - lo) * invspan); if (ti < 0) ti = 0; else if (ti > NT - 1) ti = NT - 1; + const unsigned char *rgb8 = &tbl[3 * ti]; + cr = rgb8[0]; cg = rgb8[1]; cb = rgb8[2]; } - int ti = (int)((zc - lo) * invspan); if (ti < 0) ti = 0; else if (ti > NT - 1) ti = NT - 1; - const unsigned char *rgb8 = &tbl[3 * ti]; - if (!shade) { p[0] = rgb8[0]; p[1] = rgb8[1]; p[2] = rgb8[2]; p[3] = 255; continue; } + if (!shade) { p[0] = cr; p[1] = cg; p[2] = cb; p[3] = 255; continue; } // central-difference gradient (full-res neighbours), edge-clamped; NaN neighbour -> flat. const int ixm = ix > 0 ? ix - 1 : ix, ixp = ix < nx - 1 ? ix + 1 : ix; const double za = Zc(ixp, iy), zb = Zc(ixm, iy); @@ -246,7 +258,7 @@ static void bakeLayerRGBA(Scene *s, const float *z, int nx, int ny, double gx0, const double len = std::sqrt(n0*n0 + n1*n1 + n2*n2); if (len > 0.0) { n0 /= len; n1 /= len; n2 /= len; } const double nv[3] = { n0, n1, n2 }; - double c[3] = { rgb8[0] / 255.0, rgb8[1] / 255.0, rgb8[2] / 255.0 }; + double c[3] = { cr / 255.0, cg / 255.0, cb / 255.0 }; if (pbr) applyPBRShade(L, nv, c); // PBR lit look (no hillshade selected) else applyReliefShade(L, nv, c); // SHARED hillshade (grdimage or Lambert), matches the surface p[0] = (unsigned char)std::min(255.0, c[0] * 255.0 + 0.5); @@ -296,8 +308,127 @@ static bool layerVisibleRegion(Scene *s, double &W, double &E, double &S, double // (sun az/el, gain, Lambert/grdimage/off) relights the flat image live — the image-mode counterpart // of hillshadeMapper re-colouring a surface. No-op unless the window is in cube-image mode. static void invalidateLayerDetail(Scene *s); // fwd (defined below) + +// Snapshot the live Shading-dock illumination into a per-side struct (one side's OWN light). +static AquaSideShade snapshotShade(Scene *s) { + AquaSideShade a; a.valid = true; + a.useHillshade = s->useHillshade; a.hillGrd = s->hillGrd; a.litBake = s->litBake; + a.lightAz = s->lightAz; a.lightEl = s->lightEl; a.hillAmbient = s->hillAmbient; a.hillGain = s->hillGain; + a.roughness = s->roughness; a.lightIntensity = s->lightIntensity; a.fillIntensity = s->fillIntensity; + return a; +} +// makeReliefLight, but with the light/style taken from a per-side snapshot (geometry xfac/zfac/ve still +// live from the Scene). Lets WATER and LAND shade with independent suns through the SAME applyReliefShade. +static ReliefLight makeReliefLightSide(Scene *s, const AquaSideShade &a) { + ReliefLight L; + const double az = a.lightAz * vtkMath::Pi() / 180.0; + const double el = a.lightEl * vtkMath::Pi() / 180.0; + L.Lx = std::sin(az) * std::cos(el); L.Ly = std::cos(az) * std::cos(el); L.Lz = std::sin(el); + const double elG = (90.0 - a.lightEl) * vtkMath::Pi() / 180.0; + L.LxG = std::sin(az) * std::cos(elG); L.LyG = std::cos(az) * std::cos(elG); L.LzG = std::sin(elG); + L.fx = (s->xfac != 0.0) ? 1.0 / s->xfac : 1.0; + L.fz = (s->zfac * s->ve != 0.0) ? 1.0 / (s->zfac * s->ve) : 1.0; + L.amb = a.hillAmbient; L.gain = a.hillGain; L.twoOverPi = 2.0 / vtkMath::Pi(); L.grd = a.hillGrd; + L.rough = a.roughness < 0.05 ? 0.05 : a.roughness; + L.keyI = a.lightIntensity; L.fillI = a.fillIntensity; + return L; +} + +// Aquamoto hillshade: re-light the host-composited tsunami texture through the SAME illumination the +// whole app uses (applyReliefShade / applyPBRShade — ONE shading source of truth; never fork). The +// colour is the host's dry/wet composite (aquaBaseRGBA, unshaded). WATER and LAND are TWO SEPARATE +// images with INDEPENDENT lights: water pixels shade from the per-slice stage (s->gridZ) with the +// WATER snapshot, land pixels from the static bathymetry (s->aquaBathyZ) with the LAND snapshot. +// Because each side re-bakes from its OWN snapshot, editing one side (only its snapshot changes, see +// rebakeLayerImage) leaves the OTHER side pixel-identical — no colour, no light of the other touched. +static void bakeAquaShade(Scene *s) { + if (!s || !s->layerImgMode || !s->customLayerTexture || !s->drape) return; + const int nx = s->gnx, ny = s->gny; + if (nx < 2 || ny < 2) return; + if ((int)s->aquaBaseRGBA.size() != nx * ny * 4) return; // no base composite -> nothing to shade + const bool haveStage = (int)s->gridZ.size() == nx * ny; + const bool haveBathy = (int)s->aquaBathyZ.size() == nx * ny; + vtkTexture *tx = s->drape->GetTexture(); + vtkImageData *id = tx ? vtkImageData::SafeDownCast(tx->GetInput()) : nullptr; + if (!id) return; + int dims[3] = { 0, 0, 0 }; id->GetDimensions(dims); + if (dims[0] != nx || dims[1] != ny) return; + unsigned char *out = static_cast(id->GetScalarPointer()); + const unsigned char *base = s->aquaBaseRGBA.data(); + + // Each side uses its OWN light snapshot (fall back to the live dock only the first time, before either + // side has ever been set). Water shades from the stage, land from the bathymetry — fully independent. + const AquaSideShade wS = s->aquaWaterShade.valid ? s->aquaWaterShade : snapshotShade(s); + const AquaSideShade lS = s->aquaLandShade.valid ? s->aquaLandShade : snapshotShade(s); + const ReliefLight Lw = makeReliefLightSide(s, wS); + const ReliefLight Ll = makeReliefLightSide(s, lS); + const bool wPbr = !wS.useHillshade && wS.litBake, wShade = (wS.useHillshade || wPbr) && haveStage; + const bool lPbr = !lS.useHillshade && lS.litBake, lShade = (lS.useHillshade || lPbr); + if (!wShade && !lShade) { // neither side shades -> the composite verbatim + memcpy(out, base, (size_t)nx * ny * 4); + id->Modified(); tx->Modified(); + if (s->widget && s->widget->renderWindow()) s->widget->renderWindow()->Render(); + return; + } + const float *stage = haveStage ? s->gridZ.data() : nullptr; + const float *bathy = haveBathy ? s->aquaBathyZ.data() : nullptr; + const double dx = s->gdx != 0.0 ? s->gdx : 1.0, dy = s->gdy != 0.0 ? s->gdy : 1.0; + auto at = [](const float *z, int ix, int iy, int gny) -> double { return z[(size_t)ix * gny + iy]; }; + // Per-row parallel: texel (row r = south..north, col) <-> grid (ix=col, iy=r); z is column-major + // z[ix*ny+iy] (same layout as gridZ), the composite RGBA is row-major row0=south (aqua_pack_rgba). + vtkSMPTools::For(0, ny, [&](vtkIdType rBeg, vtkIdType rEnd) { + for (int r = (int)rBeg; r < (int)rEnd; ++r) { + const int iy = r; + const int iym = iy > 0 ? iy - 1 : iy, iyp = iy < ny - 1 ? iy + 1 : iy; + for (int col = 0; col < nx; ++col) { + const int ix = col; + const size_t t = ((size_t)r * nx + col) * 4; + const double sz = stage ? at(stage, ix, iy, ny) : std::numeric_limits::quiet_NaN(); + // dry LAND where the water level sits on the sea floor (or no water / no stage) -> the LAND + // image (bathymetry + land light); else WATER (stage + water light). Same test as _aqua_indland. + const bool land = bathy && (!stage || std::isnan(sz) || std::fabs(at(bathy, ix, iy, ny) - sz) < 1e-2); + const bool shadeThis = land ? lShade : wShade; + if (!shadeThis) { // this side's light is off -> its colour verbatim + out[t] = base[t]; out[t+1] = base[t+1]; out[t+2] = base[t+2]; out[t+3] = base[t+3]; + continue; + } + const ReliefLight &L = land ? Ll : Lw; + const bool pbr = land ? lPbr : wPbr; + const float *z = land ? bathy : stage; + const int ixm = ix > 0 ? ix - 1 : ix, ixp = ix < nx - 1 ? ix + 1 : ix; + const double za = at(z, ixp, iy, ny), zb = at(z, ixm, iy, ny); + const double zu = at(z, ix, iyp, ny), zd = at(z, ix, iym, ny); + const double dzdx = (ixp == ixm || std::isnan(za) || std::isnan(zb)) ? 0.0 : (za - zb) / ((ixp - ixm) * dx); + const double dzdy = (iyp == iym || std::isnan(zu) || std::isnan(zd)) ? 0.0 : (zu - zd) / ((iyp - iym) * dy); + double n0 = -dzdx, n1 = -dzdy, n2 = 1.0; + const double len = std::sqrt(n0*n0 + n1*n1 + n2*n2); + if (len > 0.0) { n0 /= len; n1 /= len; n2 /= len; } + const double nv[3] = { n0, n1, n2 }; + double c[3] = { base[t] / 255.0, base[t+1] / 255.0, base[t+2] / 255.0 }; + if (pbr) applyPBRShade(L, nv, c); // SAME PBR bake as the flat CPT image + else applyReliefShade(L, nv, c); // SAME grdimage/Lambert shade as every surface + out[t] = (unsigned char)std::min(255.0, c[0] * 255.0 + 0.5); + out[t+1] = (unsigned char)std::min(255.0, c[1] * 255.0 + 0.5); + out[t+2] = (unsigned char)std::min(255.0, c[2] * 255.0 + 0.5); + out[t+3] = base[t+3]; + } + } + }); + id->Modified(); tx->Modified(); + if (s->widget && s->widget->renderWindow()) s->widget->renderWindow()->Render(); +} + static void rebakeLayerImage(Scene *s) { if (!s || !s->layerImgMode || !s->drape || s->gridZ.empty() || s->gnx < 2 || s->gny < 2) return; + if (s->customLayerTexture) { // Aquamoto: shade via the SHARED engine, but ONE SIDE AT A TIME + // A Shading-dock edit updates ONLY the side the Shade Water/Land radio selected (aquaShadeSelWater; + // false = Land). The OTHER side's snapshot is left untouched, so bakeAquaShade re-bakes it + // pixel-identical -- editing one image changes nothing (no colour, no light) of the other. + if (s->aquaShadeSelWater) s->aquaWaterShade = snapshotShade(s); + else s->aquaLandShade = snapshotShade(s); + bakeAquaShade(s); + return; + } vtkColorTransferFunction *ctf = vtkColorTransferFunction::SafeDownCast(s->surfLut); if (!ctf) return; vtkTexture *tx = s->drape->GetTexture(); @@ -321,6 +452,7 @@ static void rebakeLayerImage(Scene *s) { // that the base texture already resolves the view, the tile is dropped. static void refineLayerDetail(Scene *s) { if (!s || !s->layerImgMode || s->gridZ.empty() || s->gnx < 2 || s->gny < 2) return; + if (s->customLayerTexture) return; // host-composited texture (Aquamoto) -- no CPT to bake a hi-res tile from if (!s->ren || !s->widget || !s->widget->renderWindow()) return; vtkColorTransferFunction *ctf = vtkColorTransferFunction::SafeDownCast(s->surfLut); if (!ctf) return; diff --git a/deps/src/50_scene.cpp b/deps/src/50_scene.cpp index cae44e4..16fe6a6 100644 --- a/deps/src/50_scene.cpp +++ b/deps/src/50_scene.cpp @@ -87,10 +87,25 @@ static void buildColorbar(Scene *s, vtkScalarsToColors *lut, double lo, double h s->bar->SetDrawTickLabels(false); // WE draw the numbers — kill the overlapping built-ins s->bar->SetTextPositionToPrecedeScalarBar(); s->bar->SetBarRatio(cbar::BARRATIO); + // Without this, vtkScalarBarActor still auto-shrinks the PAINTED swatch inside its own + // Position/Height box to leave internal room for label text metrics -- even with DrawTickLabels + // off -- so the coloured strip does not reach the actor's own top/bottom edge (the strip visibly + // stops short of the 0 tick our OWN layoutColorbar draws at the true edge). Forces the strip to + // fill the full declared height. + s->bar->SetUnconstrainedFontSize(true); s->ren->AddActor2D(s->bar); // Nice round tick values (800, 900, ...) at a constant 1/2/5 x10^n step. const double step = niceNum(niceNum(hi - lo, false) / 5.0, true); + // Decimal places to print: driven by the tick STEP, not hard-coded. A relief grid (step 100/1000) + // prints integers; a tsunami water range (step 0.02/0.05 m) prints "0.02", not "0". The old fixed + // "%.0f" collapsed every fractional tick to "0"/"-0" -- the all-zero colourbar users kept hitting. + int decimals = 0; + if (step > 0) { + const double d = std::floor(std::log10(step)); + if (d < 0) decimals = std::min(6, (int)(-d)); + } + const double qscale = std::pow(10.0, decimals); s->barValues.clear(); s->barLabels.clear(); s->barTickPts = vtkSmartPointer::New(); vtkNew tlines; @@ -99,7 +114,9 @@ static void buildColorbar(Scene *s, vtkScalarsToColors *lut, double lo, double h vtkIdType a = s->barTickPts->InsertNextPoint(0, 0, 0); // real coords set by layoutColorbar vtkIdType b = s->barTickPts->InsertNextPoint(0, 0, 0); tlines->InsertNextCell(2); tlines->InsertCellPoint(a); tlines->InsertCellPoint(b); - char buf[32]; snprintf(buf, sizeof buf, "%.0f", v); + // Snap a tick that rounds to zero at this precision to +0 so it never prints "-0". + double vv = (std::llround(v * qscale) == 0) ? 0.0 : v; + char buf[32]; snprintf(buf, sizeof buf, "%.*f", decimals, vv); vtkSmartPointer ta = vtkSmartPointer::New(); ta->SetInput(buf); ta->GetTextProperty()->SetColor(0.9, 0.9, 0.9); @@ -122,6 +139,110 @@ static void buildColorbar(Scene *s, vtkScalarsToColors *lut, double lo, double h layoutColorbar(s); } +// Aquamoto's LAND colorbar: a SEPARATE, persistent bar for the (static, file-open-time) bathymetry +// range -- `bar` above serves as the WATER bar (built the normal way by showLayerImageTail). Only +// one of the two is ever visible at once (see refreshGridColorbar), so this shares the water bar's +// screen position (barX0/barY0) rather than tracking its own. Kept as its own small function rather +// than parameterizing buildColorbar/layoutColorbar -- those are threaded through a dozen call sites +// (drag, colormap chooser, session save/restore) that all assume exactly ONE bar; this is a +// genuinely different lifecycle (built once, never retargeted to "the active grid"). +static void layoutAquaLandColorbar(Scene *s) { + if (!s || !s->aquaLandBar) return; + const double X0 = s->barX0, Y0 = s->barY0; // shared position: never shown at the same time as `bar` + const double barLeft = X0 + cbar::W * (1.0 - cbar::BARRATIO); + s->aquaLandBar->SetPosition(X0, Y0); + s->aquaLandBar->SetWidth(cbar::W); s->aquaLandBar->SetHeight(cbar::H); + const double lo = s->aquaLandBarLo, hi = s->aquaLandBarHi; + const double span = (hi > lo) ? (hi - lo) : 1.0; + for (size_t i = 0; i < s->aquaLandBarValues.size(); ++i) { + const double frac = (s->aquaLandBarValues[i] - lo) / span; + const double y = Y0 + frac * cbar::H; + if (s->aquaLandBarTickPts) { + s->aquaLandBarTickPts->SetPoint(2*i, barLeft, y, 0.0); + s->aquaLandBarTickPts->SetPoint(2*i+1, barLeft - cbar::TICKLEN, y, 0.0); + } + if (i < s->aquaLandBarLabels.size() && s->aquaLandBarLabels[i]) + s->aquaLandBarLabels[i]->SetPosition(barLeft - cbar::TICKLEN - cbar::LABELGAP, y); + } + if (s->aquaLandBarTickPts) s->aquaLandBarTickPts->Modified(); +} + +// Built ONCE when an Aquamoto file opens (bathymetry range is static -- never re-scanned per +// slice). Mirrors buildColorbar's tick generation exactly. +static void buildAquaLandColorbar(Scene *s, vtkScalarsToColors *lut, double lo, double hi) { + if (!s) return; + if (s->aquaLandBar) { s->ren->RemoveActor2D(s->aquaLandBar); s->aquaLandBar = nullptr; } + if (s->aquaLandBarTicks) { s->ren->RemoveActor2D(s->aquaLandBarTicks); s->aquaLandBarTicks = nullptr; } + for (auto& ta : s->aquaLandBarLabels) if (ta) s->ren->RemoveActor2D(ta); + s->aquaLandBarLabels.clear(); s->aquaLandBarValues.clear(); s->aquaLandBarTickPts = nullptr; + if (!(hi > lo)) hi = lo + 1.0; + s->aquaLandBarLo = lo; s->aquaLandBarHi = hi; + s->aquaLandBar = vtkSmartPointer::New(); + s->aquaLandBar->SetLookupTable(lut); + s->aquaLandBar->SetTitle(""); + s->aquaLandBar->SetDrawTickLabels(false); + s->aquaLandBar->SetTextPositionToPrecedeScalarBar(); + s->aquaLandBar->SetBarRatio(cbar::BARRATIO); + s->aquaLandBar->SetUnconstrainedFontSize(true); // see buildColorbar -- stops the painted strip + // shrinking inside its own Position/Height box + s->aquaLandBar->SetVisibility(0); // hidden until refreshGridColorbar decides it should show + s->ren->AddActor2D(s->aquaLandBar); + + const double step = niceNum(niceNum(hi - lo, false) / 5.0, true); + int decimals = 0; + if (step > 0) { + const double d = std::floor(std::log10(step)); + if (d < 0) decimals = std::min(6, (int)(-d)); + } + const double qscale = std::pow(10.0, decimals); + vtkNew tlines; + s->aquaLandBarTickPts = vtkSmartPointer::New(); + // LAND bar MUST show a tick at exactly `lo` (sea level, 0) -- ceil(lo/step)*step can drift off lo + // by floating-point noise and silently skip it. Build the value list explicitly: lo first, then + // the regular step grid strictly above it (skip a regular tick that lands on/near lo already). + std::vector ticks; + ticks.push_back(lo); + for (double v = std::ceil(lo / step) * step; v <= hi + 1e-9 * (hi - lo); v += step) { + if (v <= lo + 1e-9 * std::max(1.0, hi - lo)) continue; // don't duplicate the forced `lo` tick + ticks.push_back(v); + } + for (double v : ticks) { + s->aquaLandBarValues.push_back(v); + vtkIdType a = s->aquaLandBarTickPts->InsertNextPoint(0, 0, 0); + vtkIdType b = s->aquaLandBarTickPts->InsertNextPoint(0, 0, 0); + tlines->InsertNextCell(2); tlines->InsertCellPoint(a); tlines->InsertCellPoint(b); + double vv = (std::llround(v * qscale) == 0) ? 0.0 : v; + char buf[32]; snprintf(buf, sizeof buf, "%.*f", decimals, vv); + vtkSmartPointer ta = vtkSmartPointer::New(); + ta->SetInput(buf); + ta->GetTextProperty()->SetColor(0.9, 0.9, 0.9); + ta->GetTextProperty()->SetFontSize(10); + ta->GetTextProperty()->SetJustificationToRight(); + ta->GetTextProperty()->SetVerticalJustificationToCentered(); + ta->GetPositionCoordinate()->SetCoordinateSystemToNormalizedViewport(); + s->ren->AddActor2D(ta); + s->aquaLandBarLabels.push_back(ta); + } + vtkNew tpd; tpd->SetPoints(s->aquaLandBarTickPts); tpd->SetLines(tlines); + vtkNew nc; nc->SetCoordinateSystemToNormalizedViewport(); + vtkNew tmap; tmap->SetInputData(tpd); tmap->SetTransformCoordinate(nc); + s->aquaLandBarTicks = vtkSmartPointer::New(); + s->aquaLandBarTicks->SetMapper(tmap); + s->aquaLandBarTicks->GetProperty()->SetColor(0.9, 0.9, 0.9); + s->aquaLandBarTicks->GetProperty()->SetLineWidth(1.5); + s->aquaLandBarTicks->SetVisibility(0); + s->ren->AddActor2D(s->aquaLandBarTicks); + + layoutAquaLandColorbar(s); +} + +static void setAquaLandColorbarVisible(Scene *s, bool on) { + if (!s || !s->aquaLandBar) return; + s->aquaLandBar->SetVisibility(on ? 1 : 0); + if (s->aquaLandBarTicks) s->aquaLandBarTicks->SetVisibility(on ? 1 : 0); + for (auto& ta : s->aquaLandBarLabels) if (ta) ta->SetVisibility(on ? 1 : 0); +} + // Show/hide the WHOLE colorbar (strip + ticks + numbers) together. The old code toggled only the // strip, so the numbers kept floating after the strip vanished. static void setColorbarVisible(Scene *s, bool on) { @@ -132,13 +253,16 @@ static void setColorbarVisible(Scene *s, bool on) { } static bool colorbarVisible(Scene *s) { return s && s->bar && s->bar->GetVisibility() != 0; } +// Aquamoto's LAND bar (see layoutAquaLandColorbar) shares the SAME barX0/barY0 frame as `s->bar` — +// only one of the two is ever visible at once — so drag hit-testing must treat either as "the bar". +static bool aquaLandColorbarVisible(Scene *s) { return s && s->aquaLandBar && s->aquaLandBar->GetVisibility() != 0; } // --- colorbar left-drag, handled at the GLView widget level (60_profile.cpp), exactly like the // polygon-vertex / text-label drags. Qt delivers the press to the widget BEFORE VTK's interactor // adapter, so a VTK observer would lose the race to the trackball — the widget path is the only // reliable one in this codebase. nx/ny are NORMALIZED viewport coords (bottom-up, 0..1). static bool colorbarHit(Scene *s, double nx, double ny) { // cursor over the (visible) bar frame? - if (!s || !s->bar || !colorbarVisible(s)) return false; + if (!s || (!colorbarVisible(s) && !aquaLandColorbarVisible(s))) return false; const double L = s->barX0 - 0.05, R = s->barX0 + cbar::W; // include the numbers to the left const double B = s->barY0 - 0.02, T = s->barY0 + cbar::H + 0.02; return nx >= L && nx <= R && ny >= B && ny <= T; @@ -154,7 +278,8 @@ static bool colorbarDragTo(Scene *s, double nx, double ny) { if (!s || !s->barDragging) return false; s->barX0 = std::min(std::max(nx - s->barGrabX, 0.05), 1.0 - cbar::W); s->barY0 = std::min(std::max(ny - s->barGrabY, 0.0), 1.0 - cbar::H); - layoutColorbar(s); + layoutColorbar(s); // repositions s->bar (water/grid) -- harmless no-op if hidden + layoutAquaLandColorbar(s); // repositions the LAND bar too -- whichever is visible moves if (s->widget && s->widget->renderWindow()) s->widget->renderWindow()->Render(); return true; } @@ -673,19 +798,26 @@ static ActiveGrid resolveActiveGrid(Scene *s) { // Retarget the single rendered colorbar + the hover/coordinate readout to the active (topmost-visible) // grid. Called on every grid add / visibility toggle / restack / delete. No grid visible -> bar hidden. +// For an Aquamoto layer (customLayerTexture): `bar` (built here as usual) is the WATER bar, shown +// only while aquaShowWater is true; the separate, persistent aquaLandBar is shown only while it's +// false -- the two are mutually exclusive, matching the dialog's Shade Water/Land radio. static void refreshGridColorbar(Scene *s) { if (!s || s->imageOnly) return; // bare-image windows never carry a z colorbar ActiveGrid ag = resolveActiveGrid(s); destroyColorbar(s); + const bool isAqua = s->customLayerTexture; if (!ag.valid || !ag.lut) { // nothing visible to colour -> no bar, readout falls back s->actZ = nullptr; + if (isAqua) setAquaLandColorbarVisible(s, false); if (s->widget && s->widget->renderWindow()) s->widget->renderWindow()->Render(); return; } // Route the hover/coordinate readout to the active grid ALWAYS; only DRAW the bar if this grid wants it. s->actZ = ag.z; s->actNx = ag.nx; s->actNy = ag.ny; s->actX0 = ag.x0; s->actX1 = ag.x1; s->actY0 = ag.y0; s->actY1 = ag.y1; - if (ag.showBar) buildColorbar(s, ag.lut, ag.zmin, ag.zmax); + const bool showWaterBar = ag.showBar && (!isAqua || s->aquaShowWater); + if (showWaterBar) buildColorbar(s, ag.lut, ag.zmin, ag.zmax); + if (isAqua) setAquaLandColorbarVisible(s, s->aquaLandShowBar && !s->aquaShowWater); if (s->widget && s->widget->renderWindow()) s->widget->renderWindow()->Render(); } @@ -811,6 +943,9 @@ static void addGridStackActions(Scene *s, QMenu &m, int *stackPtr) { // prologue of buildSceneContent — same actors removed, same pointers nulled. static void sceneRemoveSurface(Scene *s) { if (!s || !s->ren) return; + // The Aquamoto control window is lifetime-tied to its nc cube surface: removing the surface destroys + // the (otherwise un-killable) window too. Do it FIRST, before the surface actors go. + if (g_aquamotoDestroy && g_aquamotoHasWindow && g_aquamotoHasWindow(s)) g_aquamotoDestroy(s); // LOD quad-tree observer + tile cache if (s->lodCmd && s->ren->GetActiveCamera()) s->ren->GetActiveCamera()->RemoveObserver(s->lodCmd); @@ -907,6 +1042,9 @@ static void surfaceObjectMenu(Scene *s, const QPoint& gp) { QAction *aCube = nullptr; // present iff this base grid is a 3-D cube variable if (s->cubeNLayers > 1 && g_juliaCubeSlider) aCube = m.addAction("Cube layers…"); + QAction *aAqua = nullptr; // present iff this surface is an Aquamoto layer + if (g_aquamotoReopen && g_aquamotoHasWindow && g_aquamotoHasWindow(s)) + aAqua = m.addAction("Aquamoto viewer…"); // re-show the hidden Aquamoto control window m.addSeparator(); addGridStackActions(s, m, &s->surfStack); m.addSeparator(); @@ -917,6 +1055,7 @@ static void surfaceObjectMenu(Scene *s, const QPoint& gp) { if (c == aInfo) { runGridInfo(s, nm); return; } if (aTransplant && c == aTransplant) { runNestedTransplant(s, nm); return; } if (aCube && c == aCube) { g_juliaCubeSlider(s, nm.toUtf8().constData()); return; } + if (aAqua && c == aAqua) { g_aquamotoReopen(s); return; } if (c == aMove) { if (moveObjectToNewWindow(s, "grid", nm)) sceneRemoveSurface(s); return; } if (c == aRem) sceneRemoveSurface(s); } @@ -1184,6 +1323,12 @@ static void rebuildSceneObjects(Scene *s) { // curParent: the group node that newly-made rows attach to. null = top level. QTreeWidgetItem *curParent = nullptr; + // parentStack: lets beginGroupHandle/beginSlipGroup NEST (a group opened while curParent is + // already set attaches as ITS child instead of a new top-level item) -- used by the Aquamoto file + // wrapper (one variable's group nested inside the main per-file group). endGroup restores the + // OUTER curParent instead of unconditionally nulling it, so non-nested callers (the overwhelming + // majority) see no behaviour change: push null, pop null. + std::vector parentStack; // One row = [checkbox] [type icon] [label] hosted in a tree item (child of curParent, else top-level). // onToggle drives visibility; onProps (optional) is the properties menu, opened by a LEFT click on the @@ -1235,8 +1380,9 @@ static void rebuildSceneObjects(Scene *s) { std::function onContext, const QString &tip = QString()) { QTreeWidgetItem *grp = new QTreeWidgetItem(); - tree->addTopLevelItem(grp); + if (curParent) curParent->addChild(grp); else tree->addTopLevelItem(grp); grp->setExpanded(true); + parentStack.push_back(curParent); curParent = grp; QWidget *row = new QWidget(tree); @@ -1271,14 +1417,18 @@ static void rebuildSceneObjects(Scene *s) { h->addWidget(text, 1); tree->setItemWidget(grp, 0, row); }; - auto endGroup = [&]() { curParent = nullptr; }; + auto endGroup = [&]() { + curParent = parentStack.empty() ? nullptr : parentStack.back(); + if (!parentStack.empty()) parentStack.pop_back(); + }; // Slip-model group (Import Model Slip): a collapsible parent with a "Delete group" property. // Replaces the default beginGroup for slip groups to add the delete menu. auto beginSlipGroup = [&](const QString &name, int iconKind = IC_Rect) { QTreeWidgetItem *grp = new QTreeWidgetItem(); - tree->addTopLevelItem(grp); + if (curParent) curParent->addChild(grp); else tree->addTopLevelItem(grp); grp->setExpanded(false); // start collapsed: a slip model is many patches + parentStack.push_back(curParent); curParent = grp; // Custom row widget: [checkbox] [icon] [label] with right-click delete menu. @@ -1357,6 +1507,28 @@ static void rebuildSceneObjects(Scene *s) { [s, gridSel](const QPoint& g) { chooseColormap(s, g, gridSel); }, "Show / hide this grid's colorbar · left-click the label to choose a colormap"); }; + // Aquamoto's two colorbar rows. Unlike the generic colorbarRow above (checkbox = a separate + // "want this shown" intent flag, meaningful because there's only ever ONE active grid to be + // wrong about), these checkboxes reflect the bar's ACTUAL on-screen visibility directly -- + // otherwise, since only ONE of water/land is ever drawn at a time, the un-shown side's checkbox + // would stay stuck exactly as the user last left it and never visibly react to the Shade + // Water/Land radio, which is precisely the "not toggling" bug this replaces. Checking either + // box also SWITCHES the active side (mirrors clicking the corresponding radio); unchecking one + // just hides it without touching the other. + auto aquaWaterColorbarRow = [&]() { + const bool vis = s->bar && s->bar->GetVisibility() != 0; + makeRow("Color Bar water", IC_ColorBar, vis, + [s](bool on) { s->surfShowBar = on; if (on) s->aquaShowWater = true; refreshGridColorbar(s); rebuildSceneObjects(s); }, + [s](const QPoint& g) { chooseColormap(s, g, -1); }, + "Show / hide the Water colorbar · checking it switches to Shade Water"); + }; + auto aquaLandColorbarRow = [&]() { + const bool vis = s->aquaLandBar && s->aquaLandBar->GetVisibility() != 0; + makeRow("Color Bar Land", IC_ColorBar, vis, + [s](bool on) { s->aquaLandShowBar = on; if (on) s->aquaShowWater = false; refreshGridColorbar(s); rebuildSceneObjects(s); }, + nullptr, + "Show / hide the Land colorbar · checking it switches to Shade Land"); + }; // Per-grid / per-image AXES handle. Properties come LATER; for now the box toggles the cube axes // and the label shows a placeholder. Every grid (and referenced image) carries one. grpVisible // gates the initial checkbox so a hidden group's Axes row starts unchecked (see colorbarRow). @@ -1368,11 +1540,23 @@ static void rebuildSceneObjects(Scene *s) { "Axes handle (properties coming soon)"); }; + // ── AQUAMOTO FILE WRAPPER ── every variable loaded from the open tsunami netCDF -- the composited + // water/land surface below, PLUS bathymetry and any other static grid loaded alongside it as an + // extra -- nests under ONE collapsible parent named after the file: each variable gets its own + // group, all hosted in the main group for the file. Scoped strictly to customLayerTexture (an + // Aquamoto window) so a plain window's base grid + extras render exactly as before. + const bool aquaWrap = s->customLayerTexture; + if (aquaWrap) { + const QString fileNm = s->surfName.empty() ? QString("Tsunami") : QString::fromStdString(s->surfName); + beginGroupHandle(fileNm, IC_Surface, true, nullptr, nullptr, "Every variable loaded from this file"); + } + // ── GRID GROUPS ── each grid = [surface][drape?][colorbar][axes], split by a light rule. A bare // image (view_image) is its own group (image row + axes). Non-grid objects follow, after a rule. if (!s->imageOnly) { if (vtkProp3D *sp = surfProp(s)) { // base relief grid group — header IS the surface handle - const QString nm = s->surfName.empty() ? QString("Surface") : QString::fromStdString(s->surfName); + const QString nm = (s->customLayerTexture && !s->aquaVarLabel.empty()) ? QString::fromStdString(s->aquaVarLabel) + : s->surfName.empty() ? QString("Surface") : QString::fromStdString(s->surfName); beginGroupHandle(nm, IC_Surface, sp->GetVisibility() != 0, nullptr, // container does NOT fold the Shading dock (the Surface leaf does) [s](const QPoint& g) { surfaceObjectMenu(s, g); }, @@ -1383,7 +1567,14 @@ static void rebuildSceneObjects(Scene *s) { "Left-click to fold / un-fold the Shading panel · right-click for save / stacking", [s](const QPoint& g) { surfaceObjectMenu(s, g); }); if (s->drape) addRow(QString("Image drape"), s->drape, IC_Image); // grid's drape texture - colorbarRow(&s->surfShowBar, -1); // base relief grid + if (s->customLayerTexture) { // Aquamoto: same file group, but each variable's row is + // its OWN independent handle -- never merged into one + // combined label/row (that would mix variables together). + aquaWaterColorbarRow(); + aquaLandColorbarRow(); + } else { + colorbarRow(&s->surfShowBar, -1); // base relief grid + } axesRow(); endGroup(); } @@ -1448,6 +1639,8 @@ static void rebuildSceneObjects(Scene *s) { endGroup(); } + if (aquaWrap) endGroup(); // close the per-file wrapper opened above + // ── OTHER OBJECTS ── lines / points / curtains / polygons / text / profile (top-level rows; a fault // with planes becomes its own group, see below). for (auto& ov : s->overlays) { @@ -1617,6 +1810,17 @@ static void rebuildSceneObjects(Scene *s) { LineRef lr{ LK_Profile, s->profLine }; addRow("Profile", s->profLine, IC_Profile, &lr); } + // The Aquamoto CONTROL window's own STANDALONE handle (its own top-level row, NOT nested inside the + // nc cube's group). It is only ASSOCIATED with the cube by LIFETIME: destroying the cube surface + // (its Remove) also destroys this window -- see g_aquamotoDestroy in sceneRemoveSurface. The window + // is otherwise un-killable by its own X (75_aquamoto.cpp); this handle's checkbox shows/hides it. + if (g_aquamotoHasWindow && g_aquamotoHasWindow(s)) { + makeRow("Aquamoto viewer", IC_Image, + g_aquamotoIsVisible && g_aquamotoIsVisible(s), + [s](bool on) { if (g_aquamotoSetVisible) g_aquamotoSetVisible(s, on ? 1 : 0); }, + [s](const QPoint&) { if (g_aquamotoReopen) g_aquamotoReopen(s); }, + "Show / hide the Aquamoto control window · left-click to raise it"); + } } static void addOverlay(Scene *s, const double *xyz, int npts, const int *segoff, int nseg, diff --git a/deps/src/70_window.cpp b/deps/src/70_window.cpp index 2710d43..fdc75a1 100644 --- a/deps/src/70_window.cpp +++ b/deps/src/70_window.cpp @@ -858,6 +858,12 @@ extern "C" __declspec(dllexport) int gmtvtk_show_layer_image_h(void*, const floa double, double, int, const double*, const double*, int, const char*); extern "C" __declspec(dllexport) int gmtvtk_replace_base_grid_h(void*, const float*, int, int, double, double, double, double, int, const double*, const double*, int, const char*); +// fwd (defined in 90_c_api.cpp; same TU) -- the SHARED flat-image/composite builder. +static int showLayerImageTail(Scene *s, const unsigned char *rgba, int txW, int txH, + const float *z, int nx, int ny, + double x0, double x1, double y0, double y1, int geographic, + const double *cz, const double *crgb, int ncolor, const char *name, bool isCustom); + static void rebuildBaseFromStored(Scene *s, bool asImage) { if (!s || s->gridZ.empty() || s->gnx < 2 || s->gny < 2 || s->baseCz.size() < 2) return; const std::vector z = s->gridZ; // COPY: the callees reassign s->gridZ from this pointer @@ -865,10 +871,24 @@ static void rebuildBaseFromStored(Scene *s, bool asImage) { const std::vector crgb = s->baseCrgb; const int nc = (int)cz.size(); const std::string nm = s->surfName; - if (asImage) + if (asImage) { + // FLAT image. A tsunami is a host-composited land/water blend with NO single CPT to re-bake from, + // so restore it through the SAME custom builder that made it (showLayerImageTail + stored composite + // + per-side bakeAquaShade). A plain grid uses the normal flat-image bake. + if ((int)s->aquaBaseRGBA.size() == s->gnx * s->gny * 4) { + showLayerImageTail(s, s->aquaBaseRGBA.data(), s->gnx, s->gny, z.data(), s->gnx, s->gny, + s->gx0, s->gx1, s->gy0, s->gy1, s->baseGeog, cz.data(), crgb.data(), nc, nm.c_str(), true); + return; + } gmtvtk_show_layer_image_h(s, z.data(), s->gnx, s->gny, s->gx0, s->gx1, s->gy0, s->gy1, s->baseGeog, cz.data(), crgb.data(), nc, nm.c_str()); - else + } else { + // 3-D SURFACE. SACRED LAW: the same operation uses the same function -- a tsunami warps + hillshades + // through the EXACT builder every grid uses (gmtvtk_replace_base_grid_h -> hillshadeMapper), no + // special case. Drop the custom-texture flag so it shades as a plain grid; the stored composite + // (aquaBaseRGBA) survives, so re-checking "Shaded image (2-D)" restores the flat blend above. + s->customLayerTexture = false; gmtvtk_replace_base_grid_h(s, z.data(), s->gnx, s->gny, s->gx0, s->gx1, s->gy0, s->gy1, s->baseGeog, cz.data(), crgb.data(), nc, nm.c_str()); + } } // GRAPHICAL ELEMENT: custom dock title bar that folds the dock HORIZONTALLY. @@ -5610,6 +5630,13 @@ static Scene *buildAndShow(vtkSmartPointer pd, dlg->setWindowModality(Qt::NonModal); dlg->show(); }); + mGphy->addAction("Aquamoto viewer…", [win, s]() { + // Non-modal, own top-level window (aquamoto.ui roots at QMainWindow, loaded verbatim -- + // no resize/rescale of its own geometry). Closing only HIDES it (AquamotoHideOnClose); this + // reuses the SAME window for the scene, with its file/slice/state intact, rather than + // spawning a duplicate. Re-accessible afterwards from the layer's Scene Objects handle too. + AquamotoWindow::openFor(win, s); + }); mGphy->addAction(actNestedGridsTsu); reopen(); }; diff --git a/deps/src/75_aquamoto.cpp b/deps/src/75_aquamoto.cpp new file mode 100644 index 0000000..a27853a --- /dev/null +++ b/deps/src/75_aquamoto.cpp @@ -0,0 +1,538 @@ +// ============================================================================================ +// Aquamoto viewer — port of Mirone's aquamoto.m NETCDF TAB ONLY (the first of its three tabs). +// Visualizes NSWING/tsunami netCDF output where dry land and ocean wave height must be coloured +// SEPARATELY (very different scales) and blended only at render time — see src/aquamoto.jl for +// the actual compositing math (indLand mask / clamp / colourize / blend / hard land overwrite, +// a direct port of aqua_suppfuns.m's coards_sliceShow + aquamoto.m's do_imgWater/do_imgBat/ +// mixe_images, IamTSU branch). +// +// Loaded from aquamoto.ui via QUiLoader and used DIRECTLY as the returned QMainWindow (the .ui's +// own root class — unlike every other tool dialog in this file, which loads a QDialog-rooted .ui +// and wraps it). A plain non-QObject wrapper (`win` member holds the real widget), same +// self-deleting idiom as IgrfDialog/MagBarcodeDialog: `win`'s WA_DeleteOnClose frees the Qt widget +// on close, and a `destroyed -> delete this` connection frees the wrapper alongside it. Non-modal, +// stays open across any number of slice/run-in actions. +// +// Every Julia round-trip goes through the generic console-eval bridge (g_juliaEval) — the SAME +// synchronous mechanism NswingDialog already uses for its own small queries — rather than a +// dedicated typed callback: no new @cfunction/registration needed, only the composited-texture +// push (gmtvtk_show_layer_rgba_h, 90_c_api.cpp) is a new C export. +// +// Show mesh (ANUGA triangulated-mesh display) and Derived var stay disabled/unwired -- the .ui +// itself ships Show-mesh disabled, and there is no VTK/GMT triangulated-mesh equivalent on hand +// for the derived-var formulas. The Primary-quantities picker (Stage/Xmoment/Ymoment/Or…) IS wired: +// every time-varying variable a netCDF file actually has must be loadable and switchable, not just +// the first match discarding the rest (see aquamoto.jl's _aqua_find_all_varnames/_aquamoto_set_var). +// ============================================================================================ + +// Run a Julia expression synchronously via the console-eval bridge, with `scene` as the acting +// window. Fills `out` with printed stdout and returns true on success; on failure (an exception in +// the evaluated code, or the bridge not registered yet) fills `out` with the error text and +// returns false. Mirrors NswingDialog::juliaEvalCall (70_window.cpp) — one eval helper per caller +// convention already established in this file, not reinvented here. +static bool aquaEval(Scene *scene, const QString &call, QString &out) { + if (!g_juliaEval) { out = "Julia eval bridge not registered"; return false; } + std::vector buf(1 << 16); + int n = g_juliaEval(scene, call.toUtf8().constData(), buf.data(), (int)buf.size()); + out = QString::fromUtf8(buf.data(), n < 0 ? -n : n); + return n >= 0; +} + +// The acting window's Scene* as a Julia pointer literal, the same "Ptr{Cvoid}(UInt(...))" spelling +// NswingDialog's own calls use. +static QString aquaScenePtr(Scene *scene) { + return QString("Ptr{Cvoid}(UInt(%1))").arg((quintptr)scene); +} + +// The Aquamoto window is never DESTROYED by its X -- clicking the title-bar X HIDES it (state kept), +// and its "Aquamoto viewer" handle in Scene Objects unticks to reflect that; re-ticking the handle +// brings it back. The QCloseEvent is swallowed (no WA_DeleteOnClose), the window hidden, and the +// scene's object panel refreshed so the handle checkbox mirrors the new hidden state. +class AquamotoHideOnClose : public QObject { +public: + Scene *scene_; + AquamotoHideOnClose(QObject *parent, Scene *scene) : QObject(parent), scene_(scene) {} + bool eventFilter(QObject *obj, QEvent *ev) override { + if (ev->type() == QEvent::Close) { + ev->ignore(); + if (auto *w = qobject_cast(obj)) w->hide(); // hidden, NOT destroyed + if (scene_) rebuildSceneObjects(scene_); // untick the handle checkbox + return true; + } + return QObject::eventFilter(obj, ev); + } +}; + +class AquamotoWindow { +public: + QMainWindow *win = nullptr; // the loaded aquamoto.ui window itself (NOT wrapped/copied) + Scene *scene_ = nullptr; // the viewer window this Aquamoto session renders into + bool busy_ = false; // a blocking aquaEval() call is currently in flight (reentrancy guard) + std::shared_ptr alive_ = std::make_shared(true); // false once `this` is destroyed + ~AquamotoWindow() { if (alive_) *alive_ = false; } + + // One live (possibly hidden) Aquamoto window per viewer scene. Closing hides the window and keeps + // its entry here; the scene's grid handle re-shows it via openFor(). Keyed by Scene*. + static QHash ®istry() { + static QHash m; + return m; + } + // Open (or re-show) the Aquamoto window for `scene`: reuse the existing one -- with its file, slice + // and every other bit of state intact -- if there is one, else build a fresh window. Used by both + // the Geophysics menu and the grid handle's "Aquamoto viewer…" entry. + static void openFor(QWidget *parent, Scene *scene) { + AquamotoWindow *w = registry().value(scene, nullptr); + if (!w || !w->win) { + w = new AquamotoWindow(parent, scene); + if (!w->win) { delete w; return; } + registry().insert(scene, w); + } + w->win->show(); + w->win->raise(); + w->win->activateWindow(); + } + QLineEdit *pathEdit = nullptr; + QLabel *timeStepsLabel = nullptr, *waterTransparencyLabel = nullptr; + QScrollBar *sliceSlider = nullptr; // QScrollBar, NOT QSlider -- arrow buttons at each tip (Mirone-style) + QSlider *waterTransparencySlider = nullptr; + QLineEdit *sliceSpin = nullptr; // a PLAIN edit box (replaces the .ui's QSpinBox at runtime -- + // see the constructor), not a spinner: user wants a simple box + QCheckBox *splitDryWetCheck = nullptr, *scaleGlobalCheck = nullptr; + QPushButton *showSliceBtn = nullptr, *runInBtn = nullptr; + QRadioButton *stageRadioButton = nullptr, *xmomentRadioButton = nullptr, *ymomentRadioButton = nullptr; + QComboBox *orComboBox = nullptr; + QString activeVar_; // the varname currently selected in the quantity picker + bool settingVar_ = false; // guard: suppress fireSlice while WE are (un)checking radios + QRadioButton *shadeWaterBtn = nullptr, *shadeLandBtn = nullptr; // split which side's colour scale is shown + bool opened_ = false; // a file has been successfully opened this session + + explicit AquamotoWindow(QWidget *parent, Scene *scene) : scene_(scene) { + QUiLoader loader; + QFile f(gmtvtkUiDir() + "/aquamoto.ui"); + if (!f.open(QFile::ReadOnly)) { + qWarning("AquamotoWindow: cannot open %s", qUtf8Printable(f.fileName())); + return; + } + const QByteArray uiBytes = f.readAll(); // keep the raw text -- QUiLoader consumes the + QBuffer buf(const_cast(&uiBytes)); // device, but a QMainWindow-rooted .ui's own + buf.open(QIODevice::ReadOnly); // declared size is NOT reproduced by load() + // No `parent` on purpose: a QMainWindow-rooted .ui loaded WITH a parent widget embeds as a + // plain CHILD widget inside it (invisible, since the caller never adds it to a layout) -- + // only a parentless top-level QMainWindow shows up as its own real window. + win = qobject_cast(loader.load(&buf)); + f.close(); + if (!win) { qWarning("AquamotoWindow: QUiLoader failed to load the .ui"); return; } + + // Deliberately do NOT apply the .ui's own declared size here (that + // number just drifts every time the window gets resized in Designer, see git history of this + // file). The window is instead sized to its layout's true minimum at the very end of this + // constructor (win->adjustSize(), after every widget including the runtime-added slider arrow + // buttons exists) -- it always opens at the smallest size that fits everything, never bigger. + + // Pin Aquamoto's OS z-order above the main iGMT window (never hidden behind it), WITHOUT + // reparenting into the widget tree (that would embed it as a child, see the loader comment + // above) and WITHOUT setWindowFlags (recreates the native window, see the comment below). + // winId() forces each native handle to exist, then setTransientParent() makes `win` an + // OWNED window of the main window -- Windows keeps an owned window above its owner in + // z-order, but (unlike Qt::WindowStaysOnTopHint) only relative to that one window, and it + // stays a fully independent top-level (still get its own taskbar/alt-tab entry etc.). + // Use scene_->win, not the `parent` argument -- callers pass nullptr from some call sites. + win->winId(); + if (scene_ && scene_->win) { + scene_->win->winId(); + if (QWindow *wh = win->windowHandle()) wh->setTransientParent(scene_->win->windowHandle()); + } + + // NO WA_DeleteOnClose: the window must survive a close (see AquamotoHideOnClose) -- closing + // only HIDES it, so reopening shows the same window with its file/slice/state intact. The + // wrapper therefore lives for the whole session (kept in `registry()`, keyed by scene); it is + // deleted only when the window is genuinely destroyed (app teardown), which is what the + // destroyed handler below is for. + // NO setWindowFlags() here: it recreates the native window and drops the "explicit size was + // set" state, undoing the resize() just above. A parentless top-level QMainWindow already + // gets a normal frame (close/min/max) by default; there is nothing to add here. + win->setWindowModality(Qt::NonModal); + win->installEventFilter(new AquamotoHideOnClose(win, scene_)); + QObject::connect(win, &QObject::destroyed, win, [this]() { registry().remove(scene_); delete this; }); + + QMainWindow *w = win; // local copy for lambda capture (member `win` still usable directly) + + pathEdit = w->findChild("filePathLineEdit"); + timeStepsLabel = w->findChild("timeStepsLabel"); + waterTransparencyLabel = w->findChild("waterTransparencyLabel"); + sliceSlider = w->findChild("sliceSlider"); + sliceSpin = w->findChild("sliceNSpinBox"); // aquamoto.ui: a plain edit box, not a QSpinBox + waterTransparencySlider = w->findChild("waterTransparencySlider"); + splitDryWetCheck = w->findChild("splitDryWetCheckBox"); + scaleGlobalCheck = w->findChild("scaleColorGlobalCheckBox"); + showSliceBtn = w->findChild("showSliceButton"); + runInBtn = w->findChild("plotRunInButton"); + shadeWaterBtn = w->findChild("shadeWaterButton"); + shadeLandBtn = w->findChild("shadeLandButton"); + stageRadioButton = w->findChild("stageRadioButton"); + xmomentRadioButton = w->findChild("xmomentRadioButton"); + ymomentRadioButton = w->findChild("ymomentRadioButton"); + orComboBox = w->findChild("orComboBox"); + auto *browseBtn = w->findChild("browseFileButton"); + + // Force Water/Land mutually exclusive with an explicit group -- do NOT rely on QRadioButton's + // implicit parent-based auto-exclusive grouping here: the two buttons sit inside a nested + // QHBoxLayout loaded via QUiLoader, and whether they end up with the exact same QObject + // parent (required for the automatic grouping to kick in) is not guaranteed. A QButtonGroup + // is unambiguous regardless of the widget tree shape. + if (shadeWaterBtn && shadeLandBtn) { + auto *shadeGroup = new QButtonGroup(w); + shadeGroup->setExclusive(true); + shadeGroup->addButton(shadeWaterBtn); + shadeGroup->addButton(shadeLandBtn); + } + + // Show mesh (ANUGA-only) never gets wired -- ships disabled in the .ui and stays that way, as + // does Derived var (still out of scope: no VTK/GMT triangulated-mesh equivalent on hand). + // Primary quantities (Stage/Xmoment/Ymoment/Or…) DO get wired now -- every time-varying + // variable the nc file actually has must be loadable, not just the first match (see + // aquamoto.jl's _aqua_find_all_varnames/_aquamoto_set_var). Radios/combo start disabled here; + // openPath() enables exactly the ones the just-opened file's varnames cover. + if (stageRadioButton) stageRadioButton->setEnabled(false); + if (xmomentRadioButton) xmomentRadioButton->setEnabled(false); + if (ymomentRadioButton) ymomentRadioButton->setEnabled(false); + if (orComboBox) { orComboBox->setEnabled(false); orComboBox->clear(); orComboBox->addItem("Or ..."); } + + // sliceNSpinBox is a plain, EDITABLE QLineEdit in aquamoto.ui (not a QSpinBox). setFixedWidth + // (not maximumWidth) locks BOTH min and max to the same value -- the layout literally cannot + // stretch it, no matter what policy or row space is in play. + if (sliceSpin) { + sliceSpin->setValidator(new QIntValidator(1, 1, sliceSpin)); // real range set in openPath() + sliceSpin->setReadOnly(false); + sliceSpin->setFixedWidth(50); // exactly enough for a 5-digit number, hard-locked + } + + if (!pathEdit || !sliceSlider || !sliceSpin || !splitDryWetCheck || !scaleGlobalCheck || + !waterTransparencySlider || !showSliceBtn || !runInBtn) { + qWarning("AquamotoWindow: could not find one or more expected controls in aquamoto.ui"); + } + + // A native QScrollBar/QSlider's OWN chrome is unreliable across styles (verified live: + // "windowsvista" draws NO arrows at all in this Qt6 build; "windows" draws arrows but ugly + // chunky Win95 buttons) -- stop fighting native styles entirely. Re-skin sliceSlider via a + // stylesheet into a plain thin modern groove+handle (no native arrows drawn at all: add-line/ + // sub-line width forced to 0), then add two SEPARATE small arrow QToolButtons flanking it in + // its own row layout, wired straight to its value -- simple slider look + real, always-visible + // arrows-at-the-tips, independent of whatever QStyle plugin this machine has. + if (sliceSlider) { + sliceSlider->setStyleSheet( + "QScrollBar:horizontal { border: none; background: #e0e0e0; height: 8px; margin: 0px; border-radius: 4px; }" + "QScrollBar::handle:horizontal { background: #808080; border-radius: 4px; min-width: 20px; }" + "QScrollBar::handle:horizontal:hover { background: #606060; }" + "QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0px; }" + "QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }"); + if (auto *row = w->findChild("sliceSliderLayout")) { + const int idx = row->indexOf(sliceSlider); + auto *leftBtn = new QToolButton(w); + auto *rightBtn = new QToolButton(w); + leftBtn->setArrowType(Qt::LeftArrow); + rightBtn->setArrowType(Qt::RightArrow); + leftBtn->setAutoRepeat(true); + rightBtn->setAutoRepeat(true); + row->insertWidget(idx, leftBtn); + row->insertWidget(idx + 2, rightBtn); // sliceSlider shifted to idx+1 by the insert above + QObject::connect(leftBtn, &QToolButton::clicked, sliceSlider, [this]() { + if (sliceSlider) sliceSlider->setValue(sliceSlider->value() - 1); + }); + QObject::connect(rightBtn, &QToolButton::clicked, sliceSlider, [this]() { + if (sliceSlider) sliceSlider->setValue(sliceSlider->value() + 1); + }); + } + } + + bool *guard = new bool(false); // slider<->spin re-entrancy guard, freed with the window + QObject::connect(w, &QObject::destroyed, w, [guard]{ delete guard; }); + + auto openFile = [this, w]() { + QString p = QFileDialog::getOpenFileName(w, "Select SWW or NC file", prefStartDir(), + "netCDF files (*.nc *.sww *.NC *.SWW);;All files (*)"); + if (p.isEmpty()) return; + rememberStartDir(p); + if (pathEdit) pathEdit->setText(p); + openPath(p); + }; + if (browseBtn) QObject::connect(browseBtn, &QToolButton::clicked, w, openFile); + if (pathEdit) { + QObject::connect(pathEdit, &QLineEdit::returnPressed, w, [this]() { + if (pathEdit && !pathEdit->text().trimmed().isEmpty()) openPath(pathEdit->text().trimmed()); + }); + } + + if (sliceSlider && sliceSpin) { + QObject::connect(sliceSlider, &QScrollBar::valueChanged, w, [this, guard](int v) { + if (*guard) return; + *guard = true; sliceSpin->setText(QString::number(v)); *guard = false; + fireSlice(); + }); + // A plain edit box has no live valueChanged(int) -- act when the user commits (Enter or + // focus-out), not on every keystroke (a half-typed number would fire mid-edit otherwise). + QObject::connect(sliceSpin, &QLineEdit::editingFinished, w, [this, guard]() { + if (*guard) return; + bool ok = false; + int v = sliceSpin->text().toInt(&ok); + if (!ok) { sliceSpin->setText(QString::number(sliceSlider->value())); return; } + v = std::clamp(v, sliceSlider->minimum(), sliceSlider->maximum()); + *guard = true; sliceSlider->setValue(v); *guard = false; + sliceSpin->setText(QString::number(v)); // reflect the clamp back into the box + fireSlice(); + }); + } + if (splitDryWetCheck) QObject::connect(splitDryWetCheck, &QCheckBox::toggled, w, [this](bool) { fireSlice(); }); + if (scaleGlobalCheck) QObject::connect(scaleGlobalCheck, &QCheckBox::toggled, w, [this](bool) { fireSlice(); }); + // Shade Water / Shade Land radio = the selector of WHERE the Shading dock operates (water or land). + // Flipping it SWAPS the on-screen colorbar (water bar <-> land bar) as the INDICATOR of which side + // can now be changed, and routes the next Shading-dock edit to that side (aquaShadeSelWater). It + // must NOT re-composite the images -- so NO fireSlice; the land/water illumination stays exactly as + // is. refreshGridColorbar only rebuilds the colorbar actors, never the drape texture. + if (shadeWaterBtn) QObject::connect(shadeWaterBtn, &QRadioButton::toggled, w, [this](bool on) { + if (on && scene_) { + scene_->aquaShadeSelWater = true; scene_->aquaShowWater = true; + refreshGridColorbar(scene_); rebuildSceneObjects(scene_); + } + }); + if (shadeLandBtn) QObject::connect(shadeLandBtn, &QRadioButton::toggled, w, [this](bool on) { + if (on && scene_) { + scene_->aquaShadeSelWater = false; scene_->aquaShowWater = false; + refreshGridColorbar(scene_); rebuildSceneObjects(scene_); + } + }); + // Primary-quantities picker: Stage/Xmoment/Ymoment/Or… switches which nc variable is the + // ACTIVE one (see aquamoto.jl's _aquamoto_set_var). Exclusive as a group -- an explicit + // QButtonGroup, same reasoning as the shadeWater/shadeLand group above (QUiLoader nesting + // doesn't guarantee implicit auto-exclusive grouping). + if (stageRadioButton && xmomentRadioButton && ymomentRadioButton) { + auto *varGroup = new QButtonGroup(w); + varGroup->setExclusive(true); + varGroup->addButton(stageRadioButton); + varGroup->addButton(xmomentRadioButton); + varGroup->addButton(ymomentRadioButton); + } + auto onVarRadio = [this](QRadioButton *btn) { + if (settingVar_ || !btn) return; + if (btn->isChecked()) setActiveVar(btn->property("aquaVar").toString()); + }; + if (stageRadioButton) QObject::connect(stageRadioButton, &QRadioButton::toggled, w, [this, onVarRadio](bool) { onVarRadio(stageRadioButton); }); + if (xmomentRadioButton) QObject::connect(xmomentRadioButton, &QRadioButton::toggled, w, [this, onVarRadio](bool) { onVarRadio(xmomentRadioButton); }); + if (ymomentRadioButton) QObject::connect(ymomentRadioButton, &QRadioButton::toggled, w, [this, onVarRadio](bool) { onVarRadio(ymomentRadioButton); }); + if (orComboBox) { + QObject::connect(orComboBox, QOverload::of(&QComboBox::currentIndexChanged), w, [this](int idx) { + if (settingVar_ || idx <= 0) return; // index 0 is the "Or ..." placeholder, not a real var + setActiveVar(orComboBox->itemData(idx).toString()); + }); + } + if (waterTransparencySlider) { + QObject::connect(waterTransparencySlider, &QSlider::valueChanged, w, [this](int v) { + if (waterTransparencyLabel) waterTransparencyLabel->setText(QString("Water transparency %1%").arg(v)); + fireSlice(); + }); + } + if (showSliceBtn) QObject::connect(showSliceBtn, &QPushButton::clicked, w, [this]() { fireSlice(); }); + if (runInBtn) QObject::connect(runInBtn, &QPushButton::clicked, w, [this]() { fireRunIn(); }); + + // Restore a prior session on this SAME scene (the panel was closed and reopened, or opened a + // 2nd time on a window that already had a file loaded) instead of starting blank -- Julia + // still has the file/bathymetry/step-count cached even though this is a brand-new panel. + QString state; + if (aquaEval(scene_, QString("InteractiveGMT._aquamoto_state(%1)").arg(aquaScenePtr(scene_)), state) && + !state.trimmed().isEmpty()) { + const QStringList parts = state.trimmed().split('|'); + if (parts.size() == 4) { + bool nok = false; + const int n = parts[1].toInt(&nok); + if (nok && n >= 1) { + if (pathEdit) pathEdit->setText(parts[0]); + if (timeStepsLabel) timeStepsLabel->setText(QString("Time steps = %1").arg(n)); + if (sliceSlider) { sliceSlider->setRange(1, n); sliceSlider->setEnabled(true); } + if (sliceSpin) { + sliceSpin->setValidator(new QIntValidator(1, n, sliceSpin)); + sliceSpin->setEnabled(true); + } + if (showSliceBtn) showSliceBtn->setEnabled(true); + if (runInBtn) runInBtn->setEnabled(true); + populateVarPicker(parts[2], parts[3].split(',', Qt::SkipEmptyParts)); + opened_ = true; + } + } + } + + win->adjustSize(); // open at the layout's true minimum -- see the loader comment above + } + + // Run one blocking Julia call. `aquaEval` pumps QApplication::processEvents(), so the user can + // close the window DURING this call -- WA_DeleteOnClose then deletes `win` and (via destroyed -> + // delete this) this very object while we are still on the stack. We detect that WITHOUT ever + // blocking the close: hold a LOCAL copy of the shared `alive_` token (survives the object's + // death); if it reads false after aquaEval returns, `this` is gone. `closedNow` comes back true + // and every caller MUST return immediately, touching no member of `this` afterward. + bool runBlocking(const QString &call, QString &out, bool &closedNow) { + closedNow = false; + auto alive = alive_; // local copy -- outlives `this` if it gets deleted mid-call + busy_ = true; + const bool ok = aquaEval(scene_, call, out); + if (!*alive) { closedNow = true; return ok; } // `this` was destroyed during the pump -- bail + busy_ = false; + return ok; + } + + void openPath(const QString &path) { + if (busy_) return; // reentrancy guard -- see fireSlice + // Busy cursor (hourglass) for the duration of the open -- header read + the eager per-layer + // min/max prescan (every layer, every time) can take real seconds; the Julia side also raises + // its own progress dialog for it, this cursor covers the header read on top of that. + QApplication::setOverrideCursor(Qt::WaitCursor); + QString out; + bool closedNow = false; + const bool ok0 = runBlocking(QString("InteractiveGMT._aquamoto_open(%1,raw\"%2\")") + .arg(aquaScenePtr(scene_)).arg(path), out, closedNow); + QApplication::restoreOverrideCursor(); + if (closedNow) return; // `this` may already be destroyed -- touch NOTHING below + if (!ok0) { + QMessageBox::warning(win, "Aquamoto", out.isEmpty() ? "could not open the file" : out); + return; + } + const QStringList parts = out.trimmed().split('|'); // "nsteps|activevar|var1,var2,…" + bool ok = false; + int n = parts.isEmpty() ? 0 : parts[0].toInt(&ok); + if (!ok || n < 1) n = 1; + opened_ = true; + if (timeStepsLabel) timeStepsLabel->setText(QString("Time steps = %1").arg(n)); + if (sliceSlider) { sliceSlider->setRange(1, n); sliceSlider->setValue(1); sliceSlider->setEnabled(true); } + if (sliceSpin) { + sliceSpin->setValidator(new QIntValidator(1, n, sliceSpin)); + sliceSpin->setText("1"); + sliceSpin->setEnabled(true); + } + if (showSliceBtn) showSliceBtn->setEnabled(true); + if (runInBtn) runInBtn->setEnabled(true); + if (parts.size() == 3) populateVarPicker(parts[1], parts[2].split(',', Qt::SkipEmptyParts)); + fireSlice(); + } + + // Fill the Stage/Xmoment/Ymoment/Or… quantity picker from a just-opened (or restored) file's + // variable list, and check/select whichever is the currently active one. `settingVar_` guards + // every widget mutation here so none of it fires the toggled/currentIndexChanged handlers back + // into setActiveVar -- this function only reflects state Julia already has, never changes it. + void populateVarPicker(const QString &activeVar, const QStringList &allVars) { + settingVar_ = true; + activeVar_ = activeVar; + if (stageRadioButton) { stageRadioButton->setEnabled(false); stageRadioButton->setProperty("aquaVar", QString()); stageRadioButton->setChecked(false); } + if (xmomentRadioButton) { xmomentRadioButton->setEnabled(false); xmomentRadioButton->setProperty("aquaVar", QString()); xmomentRadioButton->setChecked(false); } + if (ymomentRadioButton) { ymomentRadioButton->setEnabled(false); ymomentRadioButton->setProperty("aquaVar", QString()); ymomentRadioButton->setChecked(false); } + if (orComboBox) { orComboBox->clear(); orComboBox->addItem("Or ..."); orComboBox->setEnabled(false); } + for (const QString &v : allVars) { + const QString lv = v.toLower(); + if (lv == "stage" && stageRadioButton) { + stageRadioButton->setProperty("aquaVar", v); stageRadioButton->setEnabled(true); + } else if ((lv == "xmoment" || lv == "xmomentum") && xmomentRadioButton) { + xmomentRadioButton->setProperty("aquaVar", v); xmomentRadioButton->setEnabled(true); + } else if ((lv == "ymoment" || lv == "ymomentum") && ymomentRadioButton) { + ymomentRadioButton->setProperty("aquaVar", v); ymomentRadioButton->setEnabled(true); + } else if (orComboBox) { + orComboBox->addItem(v, v); + orComboBox->setEnabled(true); + } + } + if (stageRadioButton && stageRadioButton->property("aquaVar").toString() == activeVar) { + stageRadioButton->setChecked(true); + } else if (xmomentRadioButton && xmomentRadioButton->property("aquaVar").toString() == activeVar) { + xmomentRadioButton->setChecked(true); + } else if (ymomentRadioButton && ymomentRadioButton->property("aquaVar").toString() == activeVar) { + ymomentRadioButton->setChecked(true); + } else if (orComboBox) { + const int idx = orComboBox->findData(activeVar); + orComboBox->setCurrentIndex(idx >= 0 ? idx : 0); + } + settingVar_ = false; + } + + // Switch the active quantity variable (called from the Stage/Xmoment/Ymoment/Or… picker) and + // re-render the current slice against it. Reentrancy-guarded like every other blocking call here. + void setActiveVar(const QString &varname) { + if (busy_ || varname.isEmpty() || varname == activeVar_) return; + QString out; + bool closedNow = false; + const bool ok = runBlocking(QString("InteractiveGMT._aquamoto_set_var(%1,raw\"%2\")") + .arg(aquaScenePtr(scene_)).arg(varname), out, closedNow); + if (closedNow) return; // `this` may already be destroyed -- touch NOTHING below + if (!ok) { + QMessageBox::warning(win, "Aquamoto", out.isEmpty() ? "could not switch quantity variable" : out); + return; + } + activeVar_ = varname; + fireSlice(); + } + + void fireSlice() { + // REENTRANCY GUARD (why the window "could not be killed"): every widget signal here fires a + // blocking runBlocking(), and aquaEval() pumps QApplication::processEvents() internally. Without + // this guard a second signal delivered during that pump -- e.g. sliceSpin's editingFinished, + // which fires the instant focus leaves the box on a Close click -- re-enters fireSlice and starts + // a NESTED runBlocking. The nested call clears the single `busy_` flag on return, so the outer + // call's close-defer logic (AquamotoCloseFilter -> closePending_ -> win->close()) desyncs and the + // user's Close is silently dropped. Refuse to start any new work while one is already in flight. + if (busy_) return; + if (!opened_ || !sliceSlider) return; + const int k = sliceSlider->value() - 1; // 0-based for the Julia side + const bool split = splitDryWetCheck && splitDryWetCheck->isChecked(); + const bool global = scaleGlobalCheck && scaleGlobalCheck->isChecked(); + const double transp = waterTransparencySlider ? waterTransparencySlider->value() / 100.0 : 0.0; + const bool shadeWater = !shadeWaterBtn || shadeWaterBtn->isChecked(); // no button found -> behave as always-on + const bool shadeLand = !shadeLandBtn || shadeLandBtn->isChecked(); + QString out; + bool closedNow = false; + const bool ok = runBlocking(QString("InteractiveGMT._aquamoto_slice(%1,%2,%3,%4,%5,%6,%7)") + .arg(aquaScenePtr(scene_)).arg(k) + .arg(split ? "true" : "false").arg(global ? "true" : "false") + .arg(transp, 0, 'f', 4) + .arg(shadeWater ? "true" : "false").arg(shadeLand ? "true" : "false"), out, closedNow); + if (closedNow) return; // `this` may already be destroyed -- touch NOTHING below + if (!ok && win) win->statusBar()->showMessage("Aquamoto: " + out, 5000); + } + + void fireRunIn() { + if (busy_) return; // reentrancy guard -- see fireSlice + if (!opened_) return; + QString out; + bool closedNow = false; + const bool ok = runBlocking(QString("InteractiveGMT._aquamoto_runin(%1)").arg(aquaScenePtr(scene_)), out, closedNow); + if (closedNow) return; // `this` may already be destroyed -- touch NOTHING below + if (!ok) QMessageBox::warning(win, "Aquamoto", out.isEmpty() ? "could not compute the inundation zone" : out); + } +}; + +// Install the hooks surfaceObjectMenu (50_scene.cpp) uses to offer "Aquamoto viewer…" on an Aquamoto +// layer's surface handle. This fragment is compiled AFTER 50_scene.cpp/30_app.cpp in the single TU, so +// the globals already exist; a file-scope initializer wires them at load, before any menu can pop. +static bool aquamotoHasWindow(Scene *scene) { return AquamotoWindow::registry().contains(scene); } +static void aquamotoReopen(Scene *scene) { AquamotoWindow::openFor(nullptr, scene); rebuildSceneObjects(scene); } +static bool aquamotoIsVisible(Scene *scene) { + AquamotoWindow *w = AquamotoWindow::registry().value(scene, nullptr); + return w && w->win && w->win->isVisible(); +} +static void aquamotoSetVisible(Scene *scene, int on) { + AquamotoWindow *w = AquamotoWindow::registry().value(scene, nullptr); + if (!w || !w->win) { if (on) AquamotoWindow::openFor(nullptr, scene); return; } + if (on) { w->win->show(); w->win->raise(); w->win->activateWindow(); } + else { w->win->hide(); } +} +// Destroy the window for good -- called when its nc cube surface is removed (the window is otherwise +// un-killable). Deleting `win` bypasses the close filter (which only swallows QCloseEvent); its +// destroyed handler removes the registry entry and deletes the wrapper. +static void aquamotoDestroy(Scene *scene) { + AquamotoWindow *w = AquamotoWindow::registry().value(scene, nullptr); + if (w && w->win) w->win->deleteLater(); +} +static const struct AquamotoHookInstaller { + AquamotoHookInstaller() { + g_aquamotoHasWindow = &aquamotoHasWindow; + g_aquamotoReopen = &aquamotoReopen; + g_aquamotoIsVisible = &aquamotoIsVisible; + g_aquamotoSetVisible = &aquamotoSetVisible; + g_aquamotoDestroy = &aquamotoDestroy; + } +} g_aquamotoHookInstaller; diff --git a/deps/src/90_c_api.cpp b/deps/src/90_c_api.cpp index a12046e..47606ed 100644 --- a/deps/src/90_c_api.cpp +++ b/deps/src/90_c_api.cpp @@ -2330,6 +2330,113 @@ GMTVTK_API int gmtvtk_nswing_enter_test(void *scene) { dlg.hide(); return ran; } + +// test hook: open a real AquamotoWindow (loaded from aquamoto.ui via QUiLoader, no parent), show +// it, and report whether every expected control was actually found by findChild — the exact bug +// class an "it compiled" claim would miss (a reparenting mistake leaving the window visually +// empty). Optionally grabs a screenshot to `pngPath` ("" to skip) so the result can be visually +// verified, not just asserted. Returns: 1 = window + every control found, 0 = the .ui failed to +// load at all, -1 = window loaded but one or more expected controls were missing. +GMTVTK_API int gmtvtk_aquamoto_open_test(void *scene, const char *pngPath) { + auto *w = new AquamotoWindow(nullptr, (Scene*)scene); + if (!w->win) { delete w; return 0; } + w->win->show(); + // Pump events for a bit (not just one processEvents() call) -- a brand-new native window's + // first paint can lag the OS theme engine (UxTheme "Scrollbar" data), so a screenshot grabbed + // too early can catch an under-themed frame that a normally-running app never shows. + { + QElapsedTimer t; t.start(); + while (t.elapsed() < 500) { QApplication::processEvents(QEventLoop::AllEvents, 50); } + } + const bool allFound = w->pathEdit && w->sliceSlider && w->sliceSpin && w->splitDryWetCheck && + w->scaleGlobalCheck && w->waterTransparencySlider && w->showSliceBtn && w->runInBtn; + if (pngPath && pngPath[0]) { + QPixmap pm = w->win->grab(); + pm.save(QString::fromUtf8(pngPath)); + if (w->sliceSlider) { + // A separate, upscaled close-up of JUST the slider widget -- the arrow glyphs are only + // a few px tall in the full-window shot, easy to miss/misjudge at that scale. + QPixmap sl = w->sliceSlider->grab(); + QPixmap big = sl.scaled(sl.width() * 4, sl.height() * 4); + QString p2 = QString::fromUtf8(pngPath) + ".slider.png"; + big.save(p2); + } + } + return allFound ? 1 : -1; +} + +// test hook: diagnose WHERE the AquamotoWindow's size diverges from the .ui's declared geometry. +// out[0,1] = win->size() right after QUiLoader::load() (before show()); out[2,3] = win->sizeHint(); +// out[4,5] = win->layout() ? layout()->minimumSize() : (-1,-1); out[6,7] = win->size() after +// show()+processEvents(). Lets the mismatch be pinned to "load already wrong" vs +// "content needs more room than 760x640" vs "show() itself re-lays-out" instead of guessing. +GMTVTK_API int gmtvtk_aquamoto_size_test(void *scene, double *out) { + auto *w = new AquamotoWindow(nullptr, (Scene*)scene); + if (!w->win) { delete w; return 0; } + out[0] = w->win->size().width(); out[1] = w->win->size().height(); + out[2] = w->win->sizeHint().width(); out[3] = w->win->sizeHint().height(); + if (w->win->layout()) { + out[4] = w->win->layout()->minimumSize().width(); + out[5] = w->win->layout()->minimumSize().height(); + } else { out[4] = -1; out[5] = -1; } + w->win->show(); + QApplication::processEvents(); + out[6] = w->win->size().width(); out[7] = w->win->size().height(); + return 1; +} + +// test hook: isolate whether a bare "windowsvista"-styled QScrollBar draws arrow buttons AT ALL in +// this environment -- a standalone QWidget + QScrollBar, nothing from AquamotoWindow/CubeLayerDialog +// involved, to settle whether the "force windowsvista" trick genuinely works here before blaming +// any specific dialog's wiring. +GMTVTK_API int gmtvtk_scrollbar_style_test(const char *pngPath, const char *styleKey) { + auto *host = new QWidget(nullptr); + host->resize(300, 60); + auto *sb = new QScrollBar(Qt::Horizontal, host); + sb->setGeometry(10, 20, 260, 20); + sb->setRange(1, 180); + sb->setValue(1); + QString styleName = "(app default, no override)"; + const QString key = QString::fromUtf8(styleKey ? styleKey : ""); + if (!key.isEmpty()) { + if (QStyle *classicStyle = QStyleFactory::create(key)) { + classicStyle->setParent(sb); + sb->setStyle(classicStyle); + styleName = classicStyle->objectName(); + } else { + styleName = "(create FAILED)"; + } + } + host->show(); + QElapsedTimer t; t.start(); + while (t.elapsed() < 500) { QApplication::processEvents(QEventLoop::AllEvents, 50); } + if (pngPath && pngPath[0]) { + QPixmap pm = host->grab(); + QPixmap big = pm.scaled(pm.width() * 3, pm.height() * 3); + big.save(QString::fromUtf8(pngPath)); + } + qWarning("scrollbar_style_test: requested=%s style=%s transient=%d", qUtf8Printable(key), + qUtf8Printable(styleName), sb->style()->styleHint(QStyle::SH_ScrollBar_Transient, nullptr, sb)); + return 1; +} + +// test hook: flip an Aquamoto scene to "Shade Land" (same state change the Scene Objects "Color Bar +// Land" checkbox lambda makes, 50_scene.cpp:1489/1496) so the LAND colorbar becomes visible without +// driving the real Qt checkbox — lets a screenshot show its ACTUAL rendered geometry (bar rect vs +// tick positions) for diagnosis. Returns 1 on success, 0 on a dead scene. +GMTVTK_API int gmtvtk_aqua_force_land_bar_test(void *scene) { + // NOTE: sceneAlive() checks a file-static registry that does NOT cross the DLL boundary (this + // hook is called with a Scene* opened through the PRODUCTION gmtvtk.dll, not this test dll) -- + // only a null-check here, no registry lookup. + Scene *s = static_cast(scene); + if (!s || !s->ren) return 0; + s->aquaLandShowBar = true; + s->aquaShowWater = false; + refreshGridColorbar(s); + rebuildSceneObjects(s); + if (s->widget && s->widget->renderWindow()) s->widget->renderWindow()->Render(); + return 1; +} #endif // GMTVTK_TEST_API // Register the grid-metadata callback used by the grdsample dialog's "OR Ref grid" picker. @@ -2897,39 +3004,26 @@ GMTVTK_API int gmtvtk_replace_base_grid_h(void *handle, const float *z, int nx, // Show a 2-D grid layer as a fast ILLUMINATED IMAGE (Mirone-style) — the cheap alternative to a // warped 3-D surface for scrubbing the layers of a heavy 3-D cube. The relief is NOT triangulated: -// a single flat quad carries an nx*ny hillshade texture (bakeLayerRGBA), so a layer switch is a -// texture repaint + Render, not a per-layer geometry rebuild. The full-res z stays in s->gridZ, so -// the coordinate readout still reports the true elevation (not a pixel colour) and overlays drape -// correctly. The colorbar is kept (this is a grid shown as an image, not a bare image). +// a single flat quad carries an nx*ny texture, so a layer switch is a texture repaint + Render, not +// a per-layer geometry rebuild. The full-res z stays in s->gridZ, so the coordinate readout still +// reports the true elevation (not a pixel colour) and overlays drape correctly. The colorbar is +// kept (this is a grid shown as an image, not a bare image). // +// Shared tail of gmtvtk_show_layer_image_h (bakes the texture from a CPT + illumination) and +// gmtvtk_show_layer_rgba_h (the texture arrives ALREADY composited, e.g. Aquamoto's dry/wet blend). // First call (or a grid-size / extent change) builds the flat drape scene; every later same-size // call takes the FAST path: overwrite the texture bytes + s->gridZ, one Render. `cz/crgb/ncolor` is -// the CPT to bake with; the host re-pushes the same CPT (gmtvtk_set_cpt) afterwards so the colorbar -// legend tracks the chosen (per-layer or whole-cube) range. Returns 1 on success, 0 on failure. -GMTVTK_API int gmtvtk_show_layer_image_h(void *handle, const float *z, int nx, int ny, - double x0, double x1, double y0, double y1, int geographic, - const double *cz, const double *crgb, int ncolor, - const char *name) { - Scene *s = static_cast(handle); - if (!sceneAlive(s) || !z || nx < 2 || ny < 2 || !cz || !crgb || ncolor < 2) - return 0; - - // CPT for baking (a vtkColorTransferFunction over the exact GMT control nodes). - vtkNew ctf; - for (int i = 0; i < ncolor; ++i) - ctf->AddRGBPoint(cz[i], crgb[3*i], crgb[3*i+1], crgb[3*i+2]); - - // A cube opens as an illuminated relief map: default a FRESH cube window to the grdimage hillshade - // (done before the bake so the very first texture is already shaded). The Shading dock then switches - // style (Lambert / off) or moves the sun and relights live via rebakeLayerImage. - if (s->emptyStart && !s->layerImgMode) { s->useHillshade = true; s->hillGrd = true; } - +// only the CPT the colourbar legend reflects (the surface itself is 100% covered by the drape, so +// this never colours a visible pixel). `isCustom` marks the texture as host-composited so the +// generic relight/hi-res-detail paths (rebakeLayerImage/refineLayerDetail, 40_shading.cpp) never +// try to regenerate it from gridZ+cpt and stomp it. Returns 1 on success, 0 on a dead/invalid scene. +static int showLayerImageTail(Scene *s, const unsigned char *rgba, int txW, int txH, + const float *z, int nx, int ny, + double x0, double x1, double y0, double y1, int geographic, + const double *cz, const double *crgb, int ncolor, const char *name, + bool isCustom) { const double dx = (nx > 1) ? (x1 - x0) / (nx - 1) : 0.0; const double dy = (ny > 1) ? (y1 - y0) / (ny - 1) : 0.0; - int txW, txH; layerTexSize(nx, ny, txW, txH); // capped texture size (subsample a heavy cube) - std::vector rgba; - bakeLayerRGBA(s, z, nx, ny, x0, y0, dx, dy, ctf, cz[0], cz[ncolor - 1], - x0, x1, y0, y1, txW, txH, rgba); // base texture = the whole extent // ---- FAST path: same window, same grid size + extent -> just repaint the drape texture ---- if (s->layerImgMode && s->drape && !s->emptyStart && @@ -2939,11 +3033,43 @@ GMTVTK_API int gmtvtk_show_layer_image_h(void *handle, const float *z, int nx, i vtkImageData *id = tx ? vtkImageData::SafeDownCast(tx->GetInput()) : nullptr; int dims[3] = { 0, 0, 0 }; if (id) id->GetDimensions(dims); if (id && dims[0] == txW && dims[1] == txH) { - memcpy(id->GetScalarPointer(), rgba.data(), rgba.size()); + memcpy(id->GetScalarPointer(), rgba, (size_t)txW * txH * 4); id->Modified(); tx->Modified(); s->gridZ.assign(z, z + (size_t)nx * ny); // hover now reads the NEW layer's z s->zmin = cz[0]; s->zmax = cz[ncolor - 1]; if (name && name[0]) s->surfName = name; + s->customLayerTexture = isCustom; + // Aquamoto: keep the UNSHADED composite as the shading base, then relight it through the + // SHARED engine (bakeAquaShade -> applyReliefShade), so this slice is hillshaded like the + // previous one and the Shading dock keeps driving it. gridZ (above) is this slice's stage. + if (isCustom) { + s->aquaBaseRGBA.assign(rgba, rgba + (size_t)txW * txH * 4); + bakeAquaShade(s); + } + + // Recolour + retarget the colorbar to THIS layer -- was previously only s->zmin/zmax being + // updated silently, leaving the on-screen bar (ticks, labels, LUT) frozen on whatever layer + // built it first (e.g. every later cube-layer switch or Aquamoto slice change never moved + // the bar). Mutate s->surfLut's CTF nodes in place (same trick as gmtvtk_set_cpt) so the + // surface mapper stays in sync too, then rebuild the bar's ticks/labels for the new range. + if (cz && crgb && ncolor > 1) { + vtkColorTransferFunction *ctf = vtkColorTransferFunction::SafeDownCast(s->surfLut); + if (ctf) { + ctf->RemoveAllPoints(); + for (int i = 0; i < ncolor; ++i) + ctf->AddRGBPoint(cz[i], crgb[3*i], crgb[3*i+1], crgb[3*i+2]); + } + s->baseCz.assign(cz, cz + ncolor); + s->baseCrgb.assign(crgb, crgb + 3 * ncolor); + } + destroyColorbar(s); + // For an Aquamoto layer, `bar` is specifically the WATER side -- only rebuild/show it while + // aquaShowWater is true (the Shade Water/Land radio). Without this gate, every slice repaint + // (fireSlice fires right after the radio toggle) unconditionally redrew the water bar and + // undid whatever refreshGridColorbar had just done to switch to the land bar. + if (s->surfShowBar && (!s->customLayerTexture || s->aquaShowWater)) + buildColorbar(s, s->surfLut, s->zmin, s->zmax); + invalidateLayerDetail(s); // the zoom detail tile is for the OLD layer -> refresh on settle if (s->widget && s->widget->renderWindow()) s->widget->renderWindow()->Render(); return 1; @@ -2976,7 +3102,7 @@ GMTVTK_API int gmtvtk_show_layer_image_h(void *handle, const float *z, int nx, i vtkSmartPointer pd = makeGridFromArray(flatz, 2, 2, x0, x1, y0, y1, zlo, zhi, /*triangulate=*/true, /*wantTC=*/true); s->imageOnly = false; // keep the colorbar + z readout (a grid shown as an image, not a bare image) - buildSceneContent(s, pd, x0, x1, y0, y1, cz, crgb, ncolor, rgba.data(), txW, txH, 4, + buildSceneContent(s, pd, x0, x1, y0, y1, cz, crgb, ncolor, rgba, txW, txH, 4, /*edges=*/0, /*pointCloud=*/false, geographic, nullptr, 0, 0, /*blankStart=*/false); // Full-res data layer for the hover/coordinate readout (the single-actor drape path does NOT @@ -2989,6 +3115,18 @@ GMTVTK_API int gmtvtk_show_layer_image_h(void *handle, const float *z, int nx, i s->actX0 = x0; s->actX1 = x1; s->actY0 = y0; s->actY1 = y1; s->layerImgMode = true; s->layerTexW = txW; s->layerTexH = txH; + s->customLayerTexture = isCustom; + // Aquamoto: stash the UNSHADED composite as the shading base (applyShading below -> bakeAquaShade + // relights it through the shared engine). Clear it for a plain baked layer so bakeAquaShade stays a + // no-op there. + if (isCustom) { + s->aquaBaseRGBA.assign(rgba, rgba + (size_t)txW * txH * 4); + // Seed each side's OWN light from the current dock ONCE (both start equally lit); thereafter each + // side is only ever updated when it is the selected side, so they stay independent. + if (!s->aquaWaterShade.valid) s->aquaWaterShade = snapshotShade(s); + if (!s->aquaLandShade.valid) s->aquaLandShade = snapshotShade(s); + } + else { s->aquaBaseRGBA.clear(); s->aquaBathyZ.clear(); } s->emptyStart = false; if (s->axes) { s->axes->SetZAxisVisibility(0); s->axes->DrawZGridlinesOff(); } // a 2-D map: no Z axis @@ -3039,6 +3177,114 @@ GMTVTK_API int gmtvtk_show_layer_image_h(void *handle, const float *z, int nx, i return 1; } +GMTVTK_API int gmtvtk_show_layer_image_h(void *handle, const float *z, int nx, int ny, + double x0, double x1, double y0, double y1, int geographic, + const double *cz, const double *crgb, int ncolor, + const char *name) { + Scene *s = static_cast(handle); + if (!sceneAlive(s) || !z || nx < 2 || ny < 2 || !cz || !crgb || ncolor < 2) + return 0; + + // CPT for baking (a vtkColorTransferFunction over the exact GMT control nodes). + vtkNew ctf; + for (int i = 0; i < ncolor; ++i) + ctf->AddRGBPoint(cz[i], crgb[3*i], crgb[3*i+1], crgb[3*i+2]); + + // A cube opens as an illuminated relief map: default a FRESH cube window to the grdimage hillshade + // (done before the bake so the very first texture is already shaded). The Shading dock then switches + // style (Lambert / off) or moves the sun and relights live via rebakeLayerImage. + if (s->emptyStart && !s->layerImgMode) { s->useHillshade = true; s->hillGrd = true; } + + const double dx = (nx > 1) ? (x1 - x0) / (nx - 1) : 0.0; + const double dy = (ny > 1) ? (y1 - y0) / (ny - 1) : 0.0; + int txW, txH; layerTexSize(nx, ny, txW, txH); // capped texture size (subsample a heavy cube) + std::vector rgba; + bakeLayerRGBA(s, z, nx, ny, x0, y0, dx, dy, ctf, cz[0], cz[ncolor - 1], + x0, x1, y0, y1, txW, txH, rgba); // base texture = the whole extent + + return showLayerImageTail(s, rgba.data(), txW, txH, z, nx, ny, x0, x1, y0, y1, geographic, + cz, crgb, ncolor, name, /*isCustom=*/false); +} + +// Aquamoto-style variant: the caller (Julia) hands over an ALREADY-COMPOSITED RGBA texture (e.g. +// a dry-land / wet-water blend that no single CPT could produce) instead of a z array to bake — +// same flat-quad "first call builds, later same-size calls just repaint" scene as +// gmtvtk_show_layer_image_h, minus the internal CPT bake. `rgba` is nx*ny*4 bytes (no subsampling — +// the caller already sized it to the display resolution it wants). `zhover` (nx*ny floats, e.g. the +// water stage) feeds the coordinate readout the same way gridZ does for the CPT path. `cz/crgb/ +// ncolor` is only the colourbar legend's scale (e.g. the water colour ramp) — it never colours a +// visible pixel, since the drape covers the surface 100%. +GMTVTK_API int gmtvtk_show_layer_rgba_h(void *handle, const unsigned char *rgba, int nx, int ny, + double x0, double x1, double y0, double y1, int geographic, + const double *cz, const double *crgb, int ncolor, + const float *zhover, const char *name) { + Scene *s = static_cast(handle); + if (!sceneAlive(s) || !rgba || nx < 2 || ny < 2 || !cz || !crgb || ncolor < 2 || !zhover) + return 0; + // A FRESH tsunami (no base composite pushed yet): WATER opens FLAT (the host-composited 256-colour + // :polar image, Mirone-style) -- hillshade relighting saturates the water's steep-gradient normals + // and washes the palette toward white/dark, a 2-colour collapse of an image that is really 256. + // LAND opens SHADED (grdimage hillshade) by default -- AquaSideShade's own defaults (useHillshade= + // true, hillGrd=true) already say so; only WATER needs an explicit flat override. Each side gets + // its OWN valid snapshot here so bakeAquaShade never falls back to the live dock state for either. + if (s->aquaBaseRGBA.empty()) { + s->aquaWaterShade = AquaSideShade{}; + s->aquaWaterShade.valid = true; s->aquaWaterShade.useHillshade = false; s->aquaWaterShade.hillGrd = false; + s->aquaWaterShade.litBake = false; + s->aquaLandShade = AquaSideShade{}; + s->aquaLandShade.valid = true; // useHillshade/hillGrd default true -> shaded + // Live dock state mirrors whichever side aquaShadeSelWater currently edits, so the Shading + // dock checkboxes reflect the truth the first time it's opened on this file. + const AquaSideShade &live = s->aquaShadeSelWater ? s->aquaWaterShade : s->aquaLandShade; + s->useHillshade = live.useHillshade; s->hillGrd = live.hillGrd; s->litBake = live.litBake; + } + return showLayerImageTail(s, rgba, nx, ny, zhover, nx, ny, x0, x1, y0, y1, geographic, + cz, crgb, ncolor, name, /*isCustom=*/true); +} + +// Build/replace Aquamoto's persistent LAND colorbar (the bathymetry range + :geo ramp) once, at +// file-open time -- it never changes per slice, unlike the water bar showLayerImageTail already +// refreshes every call. Visibility is decided by refreshGridColorbar (aquaShowWater / aquaLandShowBar), +// never forced on here. Returns 1 on success, 0 on a dead scene or bad CPT. +GMTVTK_API int gmtvtk_aqua_set_land_cpt_h(void *handle, const double *cz, const double *crgb, int ncolor, + double lo, double hi) { + Scene *s = static_cast(handle); + if (!sceneAlive(s) || !cz || !crgb || ncolor < 2) return 0; + vtkNew ctf; + for (int i = 0; i < ncolor; ++i) ctf->AddRGBPoint(cz[i], crgb[3*i], crgb[3*i+1], crgb[3*i+2]); + s->aquaLandLut = ctf; + buildAquaLandColorbar(s, s->aquaLandLut, lo, hi); + setAquaLandColorbarVisible(s, s->aquaLandShowBar && !s->aquaShowWater); + rebuildSceneObjects(s); + if (s->widget && s->widget->renderWindow()) s->widget->renderWindow()->Render(); + return 1; +} + +// Hand the Aquamoto layer its static BATHYMETRY (the LAND surface for hillshading), column-major +// z[ix*ny+iy] exactly like gridZ (the per-slice stage = WATER surface). Stored once at file-open; +// bakeAquaShade then shades LAND pixels from this and WATER pixels from the live stage, both through +// the SAME applyReliefShade the rest of the app uses. Relights immediately. Returns 1 on success. +GMTVTK_API int gmtvtk_aqua_set_bathy_h(void *handle, const float *z, int nx, int ny) { + Scene *s = static_cast(handle); + if (!sceneAlive(s) || !z || nx < 2 || ny < 2) return 0; + s->aquaBathyZ.assign(z, z + (size_t)nx * ny); + bakeAquaShade(s); // land surface now available -> relight through the shared engine + return 1; +} + +// Display label for the composited water/land surface's OWN Scene Objects group -- the active +// time-varying quantity variable's real name, whatever the file itself calls it (no assumed +// naming). The outer per-file wrapper group (rebuildSceneObjects) uses surfName (the file name) +// instead, so the two never collide. Purely cosmetic: Save/session still key off surfName/name. +// Returns 1 on success, 0 on a dead scene. +GMTVTK_API int gmtvtk_aqua_set_var_label_h(void *handle, const char *label) { + Scene *s = static_cast(handle); + if (!sceneAlive(s)) return 0; + s->aquaVarLabel = label ? label : ""; + rebuildSceneObjects(s); + return 1; +} + // Remove an EXTRA grid (a dropped/added grid surface, addressed by its Scene Objects name) IN PLACE, // same teardown as the grid row's "Remove" menu item. Used by the "layerN" transplant path: // Julia removes the blank grid, then re-adds a FILLED grid under the SAME name. Returns 1 if removed, diff --git a/deps/src/gmtvtk.cpp b/deps/src/gmtvtk.cpp index bea621d..c10b805 100644 --- a/deps/src/gmtvtk.cpp +++ b/deps/src/gmtvtk.cpp @@ -16,6 +16,8 @@ // 55_lineprops shared Line Properties dialog + unified line right-click menu // 60_profile ProfilePanel (2D s,z) + profile sampling + GLView // 65_xyplot standalone X,Y plot tool (vtkChartXY + Object Manager + Data Viewer) +// 75_aquamoto Aquamoto viewer (tsunami dry/wet netCDF viewer, port of Mirone aquamoto.m) -- +// included BEFORE 70_window.cpp, whose Geophysics menu constructs it by name // 70_window buildAndShow — the Qt main window // 80_rubberband Ctrl+right-drag point-cloud selection // 85_polygon toolbar polygon draw/edit tool (3-D vertices draped on the relief) @@ -29,6 +31,7 @@ #include "55_lineprops.cpp" #include "60_profile.cpp" #include "65_xyplot.cpp" +#include "75_aquamoto.cpp" #include "70_window.cpp" #include "80_rubberband.cpp" #include "85_polygon.cpp" diff --git a/deps/ui/aquamoto.ui b/deps/ui/aquamoto.ui new file mode 100644 index 0000000..6a93191 --- /dev/null +++ b/deps/ui/aquamoto.ui @@ -0,0 +1,502 @@ + + + AquamotoWindow + + + + 0 + 0 + 386 + 525 + + + + Aquamoto + + + + + 10 + + + 12 + + + + + 0 + + + + netCDF + + + + 10 + + + + + 0 + + + + + + + + 0 + 0 + + + + + Times New Roman + 11 + true + + + + Input a SWW or NC file + + + + + + + Qt::Orientation::Horizontal + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + Time steps = 180 + + + + + + + + + + + C:\v\tsu\aiai.nc + + + + + + + ... + + + + + + + + + + + + 0 + 0 + + + + Primary quantities (ANUGA) + + + + + + + + Stage + + + true + + + + + + + Xmoment + + + + + + + Ymoment + + + + + + + + Or ... + + + + + + + + + + + + false + + + Derived var + + + + + + + + Absolute Velocity (V) + + + + + + + + + + + + + + + + + + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Orientation::Horizontal + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + Slice n? + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 1 + + + 180 + + + 1 + + + Qt::Orientation::Horizontal + + + + + + + + 0 + 0 + + + + + 50 + 16777215 + + + + 1 + + + + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 20 + 10 + + + + + + + + + + Shade Water + + + true + + + + + + + Shade Land + + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 20 + 10 + + + + + + + + + + + true + + + + Plot Run In + + + + + + + false + + + + true + + + + Show mesh + + + + + + + + true + + + + Show slice + + + + + + + + + + Shading OR Image + + + + + + Shading / Image options go here + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + Cinema + + + + + + Cinema options go here + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + + + + + + + 0 + 0 + + + + Water transparency 0% + + + + + + + 0 + + + 100 + + + 0 + + + Qt::Orientation::Horizontal + + + + + + + + + + + Split Dry/Wet + + + true + + + + + + + Scale color to global min/max + + + + + + + + + + + + + 0 + 0 + 386 + 25 + + + + + + + + diff --git a/src/InteractiveGMT.jl b/src/InteractiveGMT.jl index c1840bc..ea4e9fb 100644 --- a/src/InteractiveGMT.jl +++ b/src/InteractiveGMT.jl @@ -56,6 +56,7 @@ include("session.jl") # File > Save/Load Session (.igmtz): provenance registr include("geography.jl") # Geography menu -> GSHHG coastlines for the current view include("solids.jl") # 3-D Bodies toolbar flyout -> GMT solids (cube/sphere/torus/cylinder/…) via view_fv include("nswing.jl") # Geophysics > NSWING tsunami (port of Mirone swan_options.m -> nswing exe) +include("aquamoto.jl") # Geophysics > Tsunamis > Aquamoto viewer (port of Mirone aquamoto.m netCDF tab) include("igrf.jl") # Geophysics > Magnetics > IGRF (port of Mirone igrf_options.m; GMT.magref) include("seismicity.jl") # Geophysics > Seismology > Seismicity (port of Mirone earthquakes.m) include("focal.jl") # Geophysics > Seismology > Focal mechanisms (port of Mirone focal_meca.m) diff --git a/src/aquamoto.jl b/src/aquamoto.jl new file mode 100644 index 0000000..6448996 --- /dev/null +++ b/src/aquamoto.jl @@ -0,0 +1,441 @@ +# aquamoto.jl — Geophysics > Tsunamis > "Aquamoto viewer…" (port of Mirone's aquamoto.m + its +# netCDF support half aqua_suppfuns.m — NETCDF TAB ONLY, the first tab of aquamoto.ui). The target +# file class is NSWING's own single 3-D netCDF output (`-G,`, no `+m`): a static 2-D +# `bathymetry` variable + a time-varying 3-D quantity variable (usually "z"), read the SAME way +# any other 3-D netCDF cube is read in this app (`file.nc?var[i]`, see drop.jl). Only "bathymetry" +# is a known/guaranteed name -- the time-varying variable's own name is never assumed. +# +# The whole point of the tool (aqua_suppfuns.m's IamTSU branch, coards_sliceShow/do_imgWater/ +# do_imgBat/mixe_images): ocean wave height and dry-land elevation live on very different scales, +# so they are coloured SEPARATELY and blended only at render time, never on one shared colour scale. +# Per requested slice k: indLand = abs(bathymetry - stage) < tol (cells where the water level equals +# the sea floor -> no water on top -> dry); the land side is coloured from the (cached) bathymetry, +# the wet side from the (possibly clamped) stage, cross-blended by the Water-transparency slider and +# then the land pixels are HARD-overwritten with the land colour (mixe_images) so land always reads +# as land regardless of the transparency slider. +# +# ANUGA's .sww triangulated-mesh path (Show mesh, the 14-formula get_derivedVar, vector/momentum +# plotting) has no VTK/GMT triangulated-mesh equivalent on hand and is OUT OF SCOPE this pass — the +# .ui ships Show-mesh + Derived var disabled and they stay that way. The Primary-quantities picker +# itself (Stage/Xmoment/Ymoment/Or…) IS in scope: every time-varying quantity variable found in the +# opened nc file is loadable and switchable as the ACTIVE one (`_aqua_find_all_varnames`, +# `_aquamoto_set_var`) — loading only the first match and silently discarding the rest was a bug, +# not the intended scope cut. Shading/illumination is the separate "Shading OR Image" tab (also out +# of scope): this pass paints flat, unshaded colour. +# +# Every call comes from the C++ AquamotoWindow (75_aquamoto.cpp) through the generic console-eval +# bridge (g_juliaEval / juliaEvalCall — the SAME synchronous round-trip NswingDialog already uses +# for its own small queries), keyed to the caller's own live viewer window (`scene`). No new +# @cfunction/registration is needed for that: only the composited-texture push +# (gmtvtk_show_layer_rgba_h) is a new C export (see 90_c_api.cpp). + +# Per-variable min/max scan result -- one of these per varname, ALL built up front at open time so +# every variable in the file is actually loaded, not just the active one. `alllo`/`allhi` are NOT +# scanned -- they come straight from the per-layer GMTgrid's own `.range` (GMT already computes a +# grid's z min/max when it reads it; recomputing that with a manual Julia loop is pure waste). +# `wetlo`/`wethi` genuinely need a scan (the wet/dry split is a per-cell comparison against +# bathymetry that no metadata carries); `wetany` flags a layer that had at least one wet cell. +struct _AquaVarScan + wetlo::Vector{Float64} # per-layer WET-cell min (meaningless where wetany is false) + wethi::Vector{Float64} # per-layer WET-cell max (meaningless where wetany is false) + wetany::Vector{Bool} # true iff that layer had at least one wet cell + alllo::Vector{Float64} # per-layer min over EVERY cell (land included) -- from .range + allhi::Vector{Float64} # per-layer max over EVERY cell (land included) -- from .range +end + +mutable struct _AquaState + path::String # the netCDF file + varname::String # ACTIVE time-varying quantity variable name (whatever the file calls it) + varnames::Vector{String} # EVERY time-varying quantity variable found in the file + scans::Dict{String,_AquaVarScan} # ALL varnames' scans, built at open time -- every + # variable is loaded up front, switching is a lookup + bat::GMTgrid{Float32,2} # bathymetry grid, read once + nsteps::Int + geog::Bool + imgbat::Array{UInt8,3} # cached land RGB (ny,nx,3); EMPTY (size 0) = not built yet + first::Bool # true until the first slice has been shown (Save/Session bookkeeping) +end + +const _AQUA = Dict{Ptr{Cvoid}, _AquaState}() + +# Every time-varying (>=3-D) quantity variable in `path`, skipping `skip` (the bathymetry +# variable) — NOT just the first match: the caller must load and offer ALL of them, never silently +# pick one and discard the rest. Pure netCDF subdataset introspection (drop.jl's +# `_netcdf_subdatasets`, GDAL's Subdatasets report) -- no guessed/hard-coded variable names. A +# tsunami netCDF of this file class always carries >1 variable (bathymetry + the time-varying +# quantity, at minimum), so it always shows up in GDAL's Subdatasets report; there is no +# single-variable case to fall back for. Empty if none found. +function _aqua_find_all_varnames(path::String, skip::String) + found = String[] + for v in _netcdf_subdatasets(path) + lowercase(v.name) == lowercase(skip) && continue + length(v.dims) >= 3 && push!(found, v.name) + end + return found +end + +# z -> RGB (UInt8, ny x nx x 3) via a LINEAR cpt built fresh over [zlo,zhi] with `cmap` (any GMT +# colormap name, e.g. :geo, :polar). Nearest-bin lookup against the cpt's own discrete nodes (same +# convention as cpt.jl's `_z_to_hex`, generalized to a whole array). No NaN handling -- this file +# class is guaranteed clean Float32 data. Returns a flat greyed-out array if the cpt itself fails to +# build (`_cpt_nodes_range` returned nothing usable). +function _aqua_colorize(Z::Matrix{Float32}, zlo::Float64, zhi::Float64, cmap::Symbol)::Array{UInt8,3} + ny, nx = size(Z) + rgb = Array{UInt8}(undef, ny, nx, 3) + cz, crgb, n = _cpt_nodes_range(zlo, zhi, cmap) + # 256-entry LUT (`_cpt_nodes_range` resamples any master CPT to 256 continuous nodes): index each + # pixel into it. As long as the [zlo,zhi] range is matched to the data, the full 256-colour palette + # is spanned -- the banding earlier came from a MIS-matched range (e.g. :geo over the full bathymetry + # left land in only ~16 of the 256 nodes), not from too few palette entries. + span = (zhi > zlo) ? (zhi - zlo) : 1.0 + invspan = (n - 1) / span + @inbounds for j in 1:nx, i in 1:ny + v = Float64(Z[i, j]) + idx = clamp(round(Int, (v - zlo) * invspan) + 1, 1, n) + b = 3 * (idx - 1) + rgb[i, j, 1] = round(UInt8, clamp(crgb[b+1] * 255, 0, 255)) + rgb[i, j, 2] = round(UInt8, clamp(crgb[b+2] * 255, 0, 255)) + rgb[i, j, 3] = round(UInt8, clamp(crgb[b+3] * 255, 0, 255)) + end + return rgb +end + +# Pack an (ny,nx,3) RGB array (GMT's native column-major layout, row 1 = y_min = SOUTH, row ny = +# NORTH, column 1 = x_min = WEST — see grid.jl/drape.jl's own "y ascending" convention) into the +# row-major, row-0-=-south, west->east, opaque RGBA byte buffer `gmtvtk_show_layer_rgba_h` expects +# (the SAME convention bakeLayerRGBA's own output uses, 40_shading.cpp) — no vertical flip needed +# since GMT's row-ascending-with-y already puts the south row first in memory. +function _aqua_pack_rgba(rgb::Array{UInt8,3})::Vector{UInt8} + ny, nx, _ = size(rgb) + buf = Vector{UInt8}(undef, ny * nx * 4) + k = 1 + @inbounds for i in 1:ny + for j in 1:nx + buf[k] = rgb[i, j, 1] + buf[k+1] = rgb[i, j, 2] + buf[k+2] = rgb[i, j, 3] + buf[k+3] = 0xff + k += 4 + end + end + return buf +end + +# Open a netCDF file, cache its header (bathymetry grid, EVERY time-varying quantity var name, +# step count) per window, and immediately scan the ACTIVE variable's layers ONCE for its own +# min/max (both the WET-cell-only range and the whole-cell range) -- so navigating slices and +# toggling "Scale colour to global min/max" are instant lookups afterwards, never a fresh rescan. +# Prints "nsteps|activevar|var1,var2,…" (parsed by the C++ dialog to fill "Time steps = N" + the +# slider range + the Stage/Xmoment/Ymoment/Or… quantity picker) on success; throws (shown as an +# error dialog by the console-eval bridge) on anything it can't make sense of. +function _aquamoto_open(scene::Ptr{Cvoid}, path::String) + isfile(path) || error("Aquamoto: file not found: $path") + varnames = _aqua_find_all_varnames(path, "bathymetry") + isempty(varnames) && error("Aquamoto: could not find a time-varying quantity variable in $path " * + "(expected alongside a 'bathymetry' variable — NSWING's own single 3-D netCDF output)") + varname = varnames[1] # no name-based preference -- whichever time-varying quantity var was found first + bat = try + GMT.gmtread("$(path)?bathymetry") + catch e + error("Aquamoto: could not read 'bathymetry' from $path ($(sprint(showerror, e)))") + end + info = try + GMT.grdinfo("$(path)?$(varname)", C = true, Q = true) + catch e + error("Aquamoto: could not read '$varname' header from $path ($(sprint(showerror, e)))") + end + inl = findfirst(==("n_layers"), info.colnames) + nsteps = inl === nothing ? 1 : max(1, Int(info.data[inl])) + geog = _isgeographic(bat) + + batz = bat.z + # Scan EVERY discovered variable now, not just the active one -- the whole point of this fix is + # that no variable in the file is silently left unread. One shared progress bar spans all of them. + # `alllo`/`allhi` are read straight off each layer's OWN GMTgrid `.range` -- GMT already computes a + # grid's z min/max when it reads it, so recomputing that by hand would be pure waste. `wetlo`/ + # `wethi` genuinely need a per-cell scan (wet/dry is a comparison against bathymetry, not something + # any header/metadata carries) -- this file class is guaranteed clean Float32 with no NaNs, so no + # isnan guard either. + scans = Dict{String,_AquaVarScan}() + _progress_show_async(nsteps * length(varnames), "Aquamoto — scanning layers…") + for (vi, vn) in enumerate(varnames) + sc = get!(scans, vn) do + _AquaVarScan(fill(NaN, nsteps), fill(NaN, nsteps), falses(nsteps), fill(NaN, nsteps), fill(NaN, nsteps)) + end + for k in 0:nsteps-1 + Gk = GMT.gmtread("$(path)?$(vn)[$(k)]") + Z = Gk.z + sc.alllo[k+1], sc.allhi[k+1] = Gk.range[5], Gk.range[6] + lo_w, hi_w = Inf, -Inf + @inbounds for i in eachindex(Z) + z = Z[i] + abs(batz[i] - z) < 1e-2 && continue # dry cell -> excluded from the wet-only range + z < lo_w && (lo_w = z); z > hi_w && (hi_w = z) + end + sc.wetany[k+1] = lo_w <= hi_w + sc.wetlo[k+1], sc.wethi[k+1] = lo_w, hi_w + _progress_status((vi - 1) * nsteps + k + 1, "Aquamoto — scanning layers… ($(vn) $(k + 1)/$(nsteps))") + end + end + _progress_close() + + # Push the LAND colorbar ONCE (static for the whole file, unlike the per-slice water bar). LAND is + # elevation >= 0, so the bar MUST start at sea level (0), never at the ocean-floor depth, AND the + # :geo ramp is built over the LAND-ONLY span so it matches what _aqua_composite_rgb's imgbat cache + # actually paints (see there) -- both spend the full 256-node ramp on land only. `bat.range[6]` IS + # the max land elevation whenever any land exists (the overall max of a bathymetry grid always + # lands on a land cell, since land is defined as z>=0 and the sea floor is negative) -- no scan. + lbarlo = 0.0 # displayed LAND range: [0, max land elevation] + lbarhi = max(bat.range[6], lbarlo + 0.1) # falls back to lbarlo+0.1 when the whole area is ocean + # Build the CPT DIRECTLY over the land-only span so all 256 nodes land on [0,lbarhi] -- building + # over the full bathymetry range (as before) and then keeping only the z>=0 nodes wasted almost + # the whole ramp on ocean-floor depths, leaving land with only a handful of distinct colours. + lcz, lcrgb, ln = _cpt_nodes_range(lbarlo, lbarhi, :geo) + ccall(_fn(:gmtvtk_aqua_set_land_cpt_h), Cint, (Ptr{Cvoid}, Ptr{Cdouble}, Ptr{Cdouble}, Cint, Cdouble, Cdouble), + scene, lcz, lcrgb, Cint(ln), Cdouble(lbarlo), Cdouble(lbarhi)) + + # Hand the viewer the static bathymetry = the LAND surface for hillshading. Column-major Float32, + # the SAME layout as the per-slice stage (zhover) the viewer already receives. The viewer then + # shades LAND from this and WATER from the live stage through the ONE shared applyReliefShade + # (bakeAquaShade) -- so the Shading dock's Hillshade drives the tsunami like any other layer. + bz = eltype(batz) === Float32 ? batz : Float32.(batz) + bny, bnx = size(bz) + ccall(_fn(:gmtvtk_aqua_set_bathy_h), Cint, (Ptr{Cvoid}, Ptr{Cfloat}, Cint, Cint), scene, bz, Cint(bnx), Cint(bny)) + + # Label the composited water/land surface's OWN Scene Objects group with the active variable's + # real name (whatever the file itself calls it -- no assumed naming). + ccall(_fn(:gmtvtk_aqua_set_var_label_h), Cint, (Ptr{Cvoid}, Cstring), scene, varname) + + # Load EVERY variable this file actually carries as its OWN Scene Objects group (nested, in the + # viewer, under the file's group): bathymetry itself, plus any other static 2-D grid the file + # happens to have alongside bathymetry/the time-varying quantity var(s) -- pure enumeration off + # the file's real Subdatasets report, no guessed/hard-coded variable names beyond the + # already-established "bathymetry" convention this file class uses. Only the ACTIVE quantity + # variable ('z'/the composited water surface) starts visible -- every other loaded group + # (bathymetry, any extra static grid) starts UNCHECKED (gmtvtk_set_object_visible, the same + # "add hidden" call nested.jl's blank-grid path already uses). + _add_grid_to_scene(scene, bat, "bathymetry"; promote = false, source = "$(path)?bathymetry") + ccall(_fn(:gmtvtk_set_object_visible), Cint, (Ptr{Cvoid}, Cstring, Cint), scene, "bathymetry", Cint(0)) + skipvars = Set(lowercase.(varnames)); push!(skipvars, "bathymetry") + for v in _netcdf_subdatasets(path) + lowercase(v.name) in skipvars && continue + try + G = GMT.gmtread("$(path)?$(v.name)") + _add_grid_to_scene(scene, G, v.name; promote = false, source = "$(path)?$(v.name)") + ccall(_fn(:gmtvtk_set_object_visible), Cint, (Ptr{Cvoid}, Cstring, Cint), scene, v.name, Cint(0)) + catch e + @warn "Aquamoto: could not load variable '$(v.name)'" exception=e + end + end + + _AQUA[scene] = _AquaState(String(path), varname, varnames, scans, bat, nsteps, geog, Array{UInt8}(undef, 0, 0, 0), true) + print(nsteps, "|", varname, "|", join(varnames, ",")) + return nothing +end + +# Switch the ACTIVE quantity variable for an already-open file (the Stage/Xmoment/Ymoment/Or… +# picker). Every variable was already scanned up front in `_aquamoto_open` (`st.scans`), so this +# is a plain lookup, never a rescan. The caller (C++) re-renders the current slice right after this +# returns. Throws if `varname` was not among the ones `_aquamoto_open` already found in the file. +function _aquamoto_set_var(scene::Ptr{Cvoid}, varname::String) + st = get(_AQUA, scene, nothing) + (st === nothing) && error("Aquamoto: no file open in this window") + (varname == st.varname) && return nothing # no-op: already active + haskey(st.scans, varname) || error("Aquamoto: '$varname' is not one of this file's quantity variables") + st.varname = varname + # st.imgbat (the cached land colourisation) depends only on the static bathymetry, never on the + # active quantity variable -- left untouched here on purpose. + ccall(_fn(:gmtvtk_aqua_set_var_label_h), Cint, (Ptr{Cvoid}, Cstring), scene, varname) + return nothing +end + +# Prior-session state for `scene` (if any), so a freshly (re)opened Aquamoto panel on a window that +# already had a file loaded restores that state instead of starting blank. Prints +# "path|nsteps|activevar|var1,var2,…", or nothing (empty) if this scene has no cached session. +function _aquamoto_state(scene::Ptr{Cvoid}) + st = get(_AQUA, scene, nothing) + (st === nothing) && return nothing + print(st.path, "|", st.nsteps, "|", st.varname, "|", join(st.varnames, ",")) + return nothing +end + +# The whole-cube WET-cell min/max, derived from the per-layer arrays `_aquamoto_open` already +# scanned up front (an entirely-dry layer is flagged in `wetany` and excluded here). +function _aqua_global_minmax(st::_AquaState) + sc = st.scans[st.varname] + any(sc.wetany) || return (0.0, 1.0) + lo = sc.wetlo[sc.wetany]; hi = sc.wethi[sc.wetany] + return (minimum(lo), maximum(hi)) +end + +# The colour-scale range for a slice: the whole-cube global min/max when `useglobal`, else the +# extrema of `vals` (already the wet-only values when splitDryWet, the whole slice otherwise). A +# degenerate (all-equal) range is nudged so `_cpt_nodes_range` never sees zlo==zhi. +function _aqua_range(vals::Vector{Float64}, useglobal::Bool, globalmin::Float64, globalmax::Float64) + if useglobal + return globalmin, globalmax + end + isempty(vals) && return (0.0, 1.0) + lo, hi = extrema(vals) + # A near-zero-width span (not just an EXACT lo==hi) must be caught too -- a tsunami's very first + # timestep is essentially all-zero water, so the wet-cell extrema can come out as floating-point + # noise like (-1e-14, 2e-15): that passed the old `lo == hi` check untouched and left the + # colourbar showing "-0 / 0 / 0" (its tick formatter rounding both ends to zero). Reset to a + # clean, symmetric fallback span whenever the real span is negligible, rather than nudging just + # one end (which would keep the confusing near-zero OTHER end as-is). + if (hi - lo) < 1e-6 + mid = (lo + hi) / 2 + lo, hi = mid - 0.1, mid + 0.1 + end + return (lo, hi) +end + +# The pure compositing step (aqua_suppfuns.m coards_sliceShow's IamTSU branch, do_imgWater/ +# do_imgBat/mixe_images): given the bathymetry + this slice's quantity (both ny x nx, same shape), +# returns `(rgb, imgbat)` — `imgbat` is the land colourisation, passed back so the caller can cache +# it (only depends on the static bathymetry, never the slice). No I/O, no scene state -- everything +# a caller needs is an argument, so this is exactly what the unit tests exercise directly. +# `shadeWater`/`shadeLand` (only meaningful when `splitDryWet`) let the "Shade Water"/"Shade Land" +# toggles hide one side's colour scale at a time (flat mid-grey instead) without touching the +# CACHED real `imgbat` -- so re-enabling a toggle never needs a bathymetry recolour. `landhi` is the +# LAND-ONLY colour-scale top (max land elevation) -- the CALLER already knows this from the +# bathymetry grid's own `.range` (see _aquamoto_open/_aquamoto_slice), so this pure helper is not +# asked to rediscover it by filtering + scanning `bat` on every first call. +function _aqua_composite_rgb(bat::Matrix{Float32}, Z::Matrix{Float32}, splitDryWet::Bool, + waterlo::Float64, waterhi::Float64, transparency::Float64, + imgbat::Array{UInt8,3}, landhi::Float64, + shadeWater::Bool=true, shadeLand::Bool=true) + ny, nx = size(Z) + if !splitDryWet + return _aqua_colorize(Z, waterlo, waterhi, :polar), imgbat + end + indland = abs.(bat .- Z) .< 1e-2 + Zc = copy(Z) + Zc[indland] .= 0.0 + if isempty(imgbat) # cache: only depends on the (static) bathymetry + # LAND-ONLY range, same as the colorbar (_aquamoto_open) -- colorizing over the FULL bathymetry + # range (incl. ocean depths) wasted most of the 256-node ramp on sea floor, leaving land itself + # with only a handful of distinct colours (blocky look, and mismatched vs the legend). + blo = 0.0 + bhi = max(landhi, blo + 0.1) + imgbat = _aqua_colorize(bat, blo, bhi, :geo) # :geo already has its own land/sea break + end + # ALWAYS colour BOTH sides -- land from the cached bathymetry, water from the wet stage. NEVER grey a + # side out (that made land show up grey when Water was the selected radio). Both images are always + # shown; the Shade Water/Shade Land radio only selects which side's LIGHT the Shading dock edits + # (aquaShowWater, applied per-side by bakeAquaShade in the viewer), it does not hide either colour. + imgwater = _aqua_colorize(Zc, waterlo, waterhi, :polar) # diverging: trough/calm/crest + landrgb = imgbat + alfa = clamp(transparency, 0.0, 1.0) + rgb = similar(imgwater) + if alfa > 0.01 # mixe_images' addweighted cross-blend + for idx in eachindex(rgb) + rgb[idx] = round(UInt8, clamp((1 - alfa) * imgwater[idx] + alfa * landrgb[idx], 0, 255)) + end + else + rgb = imgwater + end + @inbounds for j in 1:nx, i in 1:ny # hard land overwrite (mixe_images) + indland[i, j] || continue + rgb[i, j, 1] = landrgb[i, j, 1] + rgb[i, j, 2] = landrgb[i, j, 2] + rgb[i, j, 3] = landrgb[i, j, 3] + end + return rgb, imgbat +end + +# Compute + display slice `k` (0-based). `splitDryWet` toggles the dry/wet composite; `globalMM` +# picks the whole-cube min/max over the slice's own; `transparency` (0..1) is the Water- +# transparency slider (mixe_images' cross-blend fraction — land pixels are always hard-overwritten +# with the land colour regardless of this value, matching Mirone). `shadeWater`/`shadeLand` are the +# "Shade Water"/"Shade Land" toggle buttons — see `_aqua_composite_rgb`. +function _aquamoto_slice(scene::Ptr{Cvoid}, k::Int, splitDryWet::Bool, globalMM::Bool, transparency::Float64, + shadeWater::Bool=true, shadeLand::Bool=true) + st = get(_AQUA, scene, nothing) + (st === nothing) && error("Aquamoto: no file open in this window") + (0 <= k < st.nsteps) || error("Aquamoto: slice $k out of range (0..$(st.nsteps - 1))") + G = GMT.gmtread("$(st.path)?$(st.varname)[$(k)]") + #Z = eltype(G.z) === Float64 ? G.z : Float64.(G.z) # WTF is that Float64 doing here? + Z = G.z + bat = st.bat.z + ny, nx = size(Z) + (size(bat) == size(Z)) || error("Aquamoto: '$(st.varname)' ($(size(Z))) and bathymetry ($(size(bat))) sizes differ") + + # Colourbar min/max = the real min/max of the WATER being displayed, i.e. the actual data range of + # exactly the cells this slice colours as water. In Split Dry/Wet that is the WET cells only (the dry + # land cells store the land elevation, up to +200 m in `z` here -- they are painted as land, never on + # the water scale, so they must NOT enter the water colourbar). No borrowed global, no nudge. The + # "Scale colour to global min/max" checkbox is the only override. Colouring uses this SAME range. + # Every range below is a plain lookup into the per-layer arrays `_aquamoto_open` already scanned + # up front -- no rescan of `Z` needed here. + sc = st.scans[st.varname] + if globalMM + waterlo, waterhi = _aqua_global_minmax(st) + elseif splitDryWet + if sc.wetany[k+1] + waterlo, waterhi = sc.wetlo[k+1], sc.wethi[k+1] + else + waterlo, waterhi = 0.0, 1.0 # this layer is entirely dry + end + else + waterlo, waterhi = sc.alllo[k+1], sc.allhi[k+1] + end + (waterhi > waterlo) || (waterhi = waterlo + 1.0) # guard an exactly-flat layer (div-by-zero only) + landhi = st.bat.range[6] # max land elevation, straight from the grid's OWN known range + rgb, st.imgbat = _aqua_composite_rgb(bat, Z, splitDryWet, waterlo, waterhi, transparency, st.imgbat, landhi, + shadeWater, shadeLand) + + rgba = _aqua_pack_rgba(rgb) + zhover = G.z # native GMT column-major layout -- passed as-is + cz, crgb, n = _cpt_nodes_range(waterlo, waterhi, :polar) # colourbar legend = the water scale + r = st.bat.range + name = basename(st.path) # handle named after the file, like every other layer + ok = ccall(_fn(:gmtvtk_show_layer_rgba_h), Cint, + (Ptr{Cvoid}, Ptr{Cuchar}, Cint, Cint, Cdouble, Cdouble, Cdouble, Cdouble, Cint, + Ptr{Cdouble}, Ptr{Cdouble}, Cint, Ptr{Cfloat}, Cstring), + scene, rgba, Cint(nx), Cint(ny), r[1], r[2], r[3], r[4], Cint(st.geog), cz, crgb, Cint(n), zhover, name) + (ok == 0) && error("Aquamoto: the viewer rejected the update (window closed?)") + if st.first + _remember_object!(scene, :grid, name, st.bat) + _session_record!(scene, :basegrid, :file, st.path; name = name) + st.first = false + end + return nothing +end + +# "Plot Run In": scan every timestep once (progress bar) building the "ever wet" mask (any step +# where a cell wasn't dry), intersect with dry land (bathymetry >= 0) for the inundation zone, then +# contour its boundary and draw it as a line overlay — the existing overlay C export +# (gmtvtk_add_overlay_h, driven the same way grid.jl's `_add_overlay!`/`add!` do), no new drawing +# mechanism needed. +function _aquamoto_runin(scene::Ptr{Cvoid}) + st = get(_AQUA, scene, nothing) + st === nothing && error("Aquamoto: no file open in this window") + bat = st.bat.z + everwet = falses(size(bat)) + _progress_show_async(st.nsteps, "Aquamoto — computing inundation…") + for k in 0:st.nsteps-1 + Z = GMT.gmtread("$(st.path)?$(st.varname)[$(k)]").z + @inbounds for i in eachindex(Z) + everwet[i] |= abs(bat[i] - Z[i]) >= 1e-2 + end + _progress_status(k + 1, "Aquamoto — computing inundation… ($(k + 1)/$(st.nsteps))") # raw count, see _aquamoto_open + end + _progress_close() + inund = everwet .& (bat .>= 0) + any(inund) || error("Aquamoto: no inundation zone found (nothing was ever both dry land and wet at some step)") + G = GMT.mat2grid(Float64.(inund); x = st.bat.x, y = st.bat.y) + D = try + GMT.grdcontour(G, cont = 0.5, dump = true) + catch e + error("Aquamoto: could not contour the inundation mask ($(sprint(showerror, e)))") + end + (D === nothing) && error("Aquamoto: the inundation zone has no traceable boundary") + xyz, segoff, nseg, npts = _pack_dataset(D, st.bat) + cr, cg, cb = _ovl_color(nothing, :lines) + ok = ccall(_fn(:gmtvtk_add_overlay_h), Cint, + (Ptr{Cvoid}, Ptr{Cdouble}, Cint, Ptr{Cint}, Cint, Cint, Cdouble, Cdouble, Cdouble, Cdouble, Cdouble, Cstring), + scene, xyz, Cint(npts), segoff, Cint(nseg), Cint(1), cr, cg, cb, 0.0, 0.0, "Run-in") + (ok == 0) && error("Aquamoto: could not draw the inundation boundary (window closed?)") + return nothing +end diff --git a/src/libgmtvtk.jl b/src/libgmtvtk.jl index 995e0b7..cc80264 100644 --- a/src/libgmtvtk.jl +++ b/src/libgmtvtk.jl @@ -57,7 +57,9 @@ const _LIB_SYMBOLS = ( :gmtvtk_error_box, :gmtvtk_get_xfac, :gmtvtk_save_png, :gmtvtk_orbit, :gmtvtk_set_stereo, :gmtvtk_open_empty, :gmtvtk_set_drop_callback, :gmtvtk_add_surface_h, - :gmtvtk_promote_surface_h, :gmtvtk_replace_base_grid_h, :gmtvtk_show_layer_image_h, :gmtvtk_remove_grid_h, :gmtvtk_set_transplant_undo, + :gmtvtk_promote_surface_h, :gmtvtk_replace_base_grid_h, :gmtvtk_show_layer_image_h, :gmtvtk_show_layer_rgba_h, + :gmtvtk_aqua_set_land_cpt_h, :gmtvtk_aqua_set_bathy_h, :gmtvtk_aqua_set_var_label_h, + :gmtvtk_remove_grid_h, :gmtvtk_set_transplant_undo, :gmtvtk_has_surface, :gmtvtk_close, :gmtvtk_add_recent, :gmtvtk_set_cpt, :gmtvtk_set_cpt_grid, :gmtvtk_grid_rgb_at, :gmtvtk_raise, :gmtvtk_set_crs, :gmtvtk_set_title_h, :gmtvtk_set_surface_name_h, diff --git a/test/test-aquamoto-unit.jl b/test/test-aquamoto-unit.jl new file mode 100644 index 0000000..f1b4708 --- /dev/null +++ b/test/test-aquamoto-unit.jl @@ -0,0 +1,105 @@ +# CI-safe unit tests for the Aquamoto dry/wet compositing math (aquamoto.jl). These call the pure +# helpers directly — no netCDF file, no Qt+VTK window — so they run anywhere `using InteractiveGMT` +# succeeds. The file I/O (_aquamoto_open/_aquamoto_slice/_aquamoto_runin) and the C++ dialog are +# exercised live (see the plan's smoke-test step), not here. +# +# What they lock down: +# * `_aqua_composite_rgb`'s indLand mask (abs(bat-Z) < tol) correctly separates dry from wet, and +# the HARD land overwrite always wins regardless of the transparency slider (Mirone's +# mixe_images: land never bleeds water colour, no matter how much of the water tint was +# cross-blended first). +# * `_aqua_colorize` produces the right shape/dtype and spans the colour ramp (no NaN handling -- +# this file class is guaranteed clean Float32 data). +# * `_aqua_pack_rgba`'s row-major/south-first/west-east/opaque packing (the exact convention +# bakeLayerRGBA's own texture output uses, 40_shading.cpp) — a silent row/column swap here would +# misdraw the whole texture without ever throwing. +# * `_aqua_range` picks the right value on both branches (global vs local) and never returns a +# degenerate (zlo==zhi) range. + +@testitem "aquamoto helpers present" tags=[:unit, :fast] begin + for s in (:_aqua_find_all_varnames, :_aqua_colorize, :_aqua_pack_rgba, :_aqua_range, + :_aqua_composite_rgb, :_aquamoto_open, :_aquamoto_slice, :_aquamoto_runin, + :_aqua_global_minmax, :_aquamoto_set_var) + @test isdefined(InteractiveGMT, s) + end +end + +@testitem "aqua_range: local extrema vs global, degenerate range nudged" tags=[:unit, :fast] begin + IG = InteractiveGMT + @test IG._aqua_range([1.0, 5.0, 3.0], false, -99.0, -99.0) == (1.0, 5.0) + @test IG._aqua_range([1.0, 5.0, 3.0], true, -2.0, 8.0) == (-2.0, 8.0) + @test IG._aqua_range(Float64[], false, -1.0, -1.0) == (0.0, 1.0) # empty -> safe default + # All-equal -> reset to a clean SYMMETRIC span around the value (not a one-sided nudge, which + # left a confusing near-zero end untouched in the near-noise case below). + lo, hi = IG._aqua_range([4.0, 4.0, 4.0], false, 0.0, 0.0) + @test lo < 4.0 < hi && isapprox(hi - lo, 0.2) + # REGRESSION (colourbar showed "-0 / 0 / 0"): a t=0 tsunami frame's wet cells are essentially + # zero but not EXACTLY equal (floating-point noise) -- must still be caught, not just lo==hi. + lo2, hi2 = IG._aqua_range([-1e-14, 2e-15, 5e-15], false, 0.0, 0.0) + @test (hi2 - lo2) >= 0.19 # widened to the clean fallback span, not left near-zero-width +end + +@testitem "aqua_colorize: shape, dtype, distinct colours across the ramp" tags=[:unit, :fast] begin + IG = InteractiveGMT + Z = Float32[0.0 1.0 2.0; 3.0 4.0 5.0] # 2x3, the real dtype tsunami grids carry (no NaN -- never occurs) + rgb = IG._aqua_colorize(Z, 0.0, 5.0, :turbo) + @test size(rgb) == (2, 3, 3) + @test eltype(rgb) === UInt8 + @test rgb[1, 1, :] != rgb[2, 3, :] # low end vs high end of the ramp actually differ +end + +@testitem "aqua_pack_rgba: row-major south-first west-east, opaque" tags=[:unit, :fast] begin + IG = InteractiveGMT + # 2 (ny) x 3 (nx) x 3, GMT native layout: row 1 = south, row 2 = north; col 1..3 = west->east. + rgb = Array{UInt8}(undef, 2, 3, 3) + for j in 1:3, i in 1:2 + rgb[i, j, 1] = UInt8(10i); rgb[i, j, 2] = UInt8(20j); rgb[i, j, 3] = UInt8(i + j) + end + buf = IG._aqua_pack_rgba(rgb) + @test length(buf) == 2 * 3 * 4 + # Output row 0 (bytes 1..12) must be GMT row 1 (south), west->east; row 1 (bytes 13..24) = GMT row 2. + for j in 1:3 + b = (j - 1) * 4 + @test buf[b+1] == UInt8(10 * 1) && buf[b+2] == UInt8(20j) && buf[b+3] == UInt8(1 + j) && buf[b+4] == 0xff + end + for j in 1:3 + b = 12 + (j - 1) * 4 + @test buf[b+1] == UInt8(10 * 2) && buf[b+2] == UInt8(20j) && buf[b+3] == UInt8(2 + j) && buf[b+4] == 0xff + end +end + +@testitem "aqua_composite_rgb: dry/wet split, hard land overwrite beats transparency" tags=[:unit, :fast] begin + IG = InteractiveGMT + # 3x3: a dry strip (row 1, matches bathymetry exactly -> land) and wet cells elsewhere. + bat = Float32[ 10.0 10.0 10.0; -5.0 -5.0 -5.0; -5.0 -5.0 -5.0 ] + Z = Float32[ 10.0 10.0 10.0; 2.0 3.0 4.0; 2.0 3.0 4.0 ] + + noimg = Array{UInt8}(undef, 0, 0, 0) # "no cache yet" sentinel (see _AquaState.imgbat) + landhi = 10.0 # known max land elevation for `bat` (no scan -- caller's job now) + + # Not split: plain colourisation of Z over its own extrema, no land/water distinction. + rgb_flat, _ = IG._aqua_composite_rgb(bat, Z, false, 2.0, 4.0, 0.5, noimg, landhi) + @test size(rgb_flat) == (3, 3, 3) + + # Split, alfa=0 (opaque water, no land tint blended in) -- land row still shows the LAND colour, + # never Z's own colourisation (Z there was clamped to 0 before colourising in Mirone's mixe_images; + # land pixels are hard-overwritten regardless of alfa). + rgb0, imgbat = IG._aqua_composite_rgb(bat, Z, true, 2.0, 4.0, 0.0, noimg, landhi) + @test !isempty(imgbat) + @test rgb0[1, 1, :] == imgbat[1, 1, :] + @test rgb0[1, 2, :] == imgbat[1, 2, :] + @test rgb0[1, 3, :] == imgbat[1, 3, :] + + # Split, alfa=1 (fully cross-blended toward land tint EVERYWHERE) -- land pixels are STILL exactly + # the land colour (the hard overwrite runs after the blend), proving land never depends on alfa. + rgb1, imgbat2 = IG._aqua_composite_rgb(bat, Z, true, 2.0, 4.0, 1.0, imgbat, landhi) + @test rgb1[1, 1, :] == imgbat2[1, 1, :] + @test rgb1[2, 1, :] == imgbat2[2, 1, :] # a WET cell at alfa=1 also equals the land colour (100% blend) + + # The cached imgbat is reused byte-for-byte across calls (only depends on bathymetry). + @test imgbat === imgbat2 + + # A wet cell at alfa=0 must NOT equal the land colour (no blending at all towards land). + rgb_wet0, _ = IG._aqua_composite_rgb(bat, Z, true, 2.0, 4.0, 0.0, imgbat, landhi) + @test rgb_wet0[2, 1, :] != imgbat[2, 1, :] +end