Status: implemented, in code review, not pushed. Designed and approved
2026-10-03 (Ola ruled: nodes only, the rule in the C++ sampler); red 6605dfe,
pins on the red step's choices 80990e0, green 6fd4076 and a0817ba; 13 net
production lines; no @perf run needed (see "@perf"). The branch has master,
with 25 (#157), merged in. Decisions for Ola are in "For Ola".
The defect. Without --tolerance, z comes from _core.sample, which is
terrain::raster::bilinear over every mesh vertex
(include/terrain/raster/sample.hpp:26-59). bilinear refuses a point when
any of the four corners of its cell is NoData, also a corner whose weight is
zero (sample.hpp:42-44). A vertex exactly on a DEM node that has a value
then gets no height when a neighbour has none, and the trim
(src_python/tin_engine/elevation.py@c277ce10:52-55) removes it with its triangles.
So a ring of valid data, one cell wide, is lost along every NoData area. On
increment 25's fixture at stride 1 that is 36 vertices removed where 25 are
NoData (25-plain-output.md, "NoData on the no-tolerance path").
Increment 12 accepted this as a stopgap ("accepted for now ... changing the
sampler is not this increment's job", 12-dem-to-mesh.md, R2). Increment 25
pinned today's behaviour in a test, and said a later C++ increment would
change it back. This is that increment.
The method is ordinary bilinear interpolation on a regular grid (Press, Teukolsky, Vetterling and Flannery, Numerical Recipes, 3rd ed., 2007, §3.6, "Interpolation on a grid in multidimensions"). At a grid node the bilinear weights are 1 for that node and 0 for the other three corners, so the interpolant equals the node value there and the other corners do not enter. The method says nothing about missing values. That is a convention each tool picks.
GDAL is a prohibited dependency (CLAUDE.md §2), but its convention is prior
art: its warper leaves NoData source pixels out of its resampling kernels, and
ticket #3658 records that its cubic-spline and Lanczos kernels renormalise
the weights when NoData pixels lower the total weight below 1
(https://trac.osgeo.org/gdal/ticket/3658). I did not read GDAL's bilinear
kernel source and make no claim about it.
What differs here, and why. This increment does not renormalise. It skips only corners whose weight is exactly zero, and that happens only when the point is a node. The value returned is then the bilinear value in exact arithmetic, so no new surface is invented over missing data. Renormalising would give a height wherever any corner has data. That is a larger change in what a NoData area means for the mesh, and nobody has asked for it.
Novelty: none is claimed. Searched: "GDAL warp kernel bilinear nodata source pixels excluded weights renormalized" and "scipy RegularGridInterpolator nan neighbour zero weight". Found: the GDAL ticket above. Nothing found changes the design.
The legacy sampler had no NoData handling at all (raster.hpp:58-59 says so).
Grep against the archive tag:
$ git grep -l -i -E 'nodata|no_data|bilinear|interpolat' legacy-archive -- legacy
legacy-archive:legacy/bindings.cpp
legacy-archive:legacy/rasputin/globcov_repository.py
legacy-archive:legacy/rasputin/triangulate_dem.h
At the tag, lines 375-388 of legacy/rasputin/triangulate_dem.h are
get_interpolated_value_at_point, bilinear with no sentinel test, and line 182
of legacy/bindings.cpp binds it; globcov_repository.py uses
no_data only as a land-cover class, with class code 230. Nothing is carried across:
there is no legacy NoData rule to keep.
A point that is a DEM node, bit for bit, reads that node and nothing else.
In floating point, "zero weight" is defined by node identity, not by the computed weights:
- Let
fc = clamp((p.x - x_min) / delta_x, 0, cols - 1)andfr = clamp((y_max - p.y) / delta_y, 0, rows - 1), andnthe node(round(fr), round(fc)). The point is on a node whenRasterGeometry::node(n) == p, both coordinates bit for bit. - On a node, the other three corners of the cell have weight zero and are not
read. z is
value_at(n), refused only whennitself is NoData or NaN. - Every other point reads all four corners of its cell, exactly as today: refused if any of the four is NoData, and the same expression, so the same bits, as today.
That "every other point" includes:
- A point one ulp from a node. Its far-corner weights are about 1e-16, not zero. Strictly, those corners contribute, so a NoData there refuses the point. Exactness is the rule; there is no tolerance band.
- A point on a cell side (one coordinate on a node line, the other not). Two corners have weight zero, but this increment does not skip them; see "Why cell sides are not in this increment".
Why this test, and not tx == 0.0 on the computed weights: cell_of can put
a node into the cell before it. When (p.x - x_min) / delta_x rounds just
below the integer, tx comes out as 1 - 1e-16 instead of 0, and the zero
weight is never seen. Measured with a test program that includes today's
sample.hpp (c++ -std=c++20 -O2, Apple clang 21) and calls bilinear at
g.node(c) for every node of a 200 x 300 grid of values drawn uniformly from [0, 1000) (std::mt19937, seed 1, so
the counts depend on the draws and the standard library):
with origin 500000.3 / 7900000.7 and spacing 0.7 / 0.3, 38 580 of 60 000
samples are not bit-equal to the node value (largest difference about
6.6e-7 m); with origin 0.1 / 100.1 and spacing 0.1, about 31 000. @reviewer's
rerun with other draws got 31 116 and 6.72e-7. With origin 0 and spacing 1,
with a dyadic grid (500000.5 / 7900000.25, spacing 0.5 / 0.25), and with the
Kartverket fixture's geometry, there are none. Node identity is also the relation the producers use:
subsample builds stride vertices with node's expression
(src_python/tin_engine/grid_domain.py@44fa7f5:68-69), and refine decides that a start vertex is a node by
the same node(round) == p test (lattice_position, include/terrain/refinement/refine.hpp@ed125121:132-141).
RasterGeometry::node_at(const Point2&) -> std::optional<CellIndex>, new, ininclude/terrain/raster/geometry.hpp: the nodepis, bit for bit, or nullopt. nullopt also for a non-finitepand for a point outside the node rectangle (cell_of's test). The clamp-and-round is written the waylattice_positionwrites it, sonode_atsays "node" exactly where refine's whole test atinclude/terrain/refinement/refine.hpp@ed125121:417-426does; a test pins that (S5).node_atanswers on any raster, a 1 x N one included; it isbilinear's guard, notnode_at, that keeps such a raster at nullopt.bilinear(sample.hpp): after the existingbilinear_cell_ofguard,if (const auto n = g.node_at(p)) return is_nodata(*n) ? nullopt : value_at(*n);, then the existing four-corner code, unchanged. The guard stays first, so a raster with fewer than 2 rows or columns still answers nullopt everywhere, nodes included, as today.bilinear_batch, thesamplebinding and the Python side change in no code. The binding's docstring (bindings/core.cpp@44fa7f5:935-940), the stub's (src_python/tin_engine/_core.pyi,sample) and the comment abovebilinearsay the new rule.lattice_positionis not changed to callnode_athere. That would touchrefine.hppfor a refactor with no behaviour in it. It is a one-line follow-up once this lands, if wanted.
- No vertex on the path being fixed lies on a cell side. Without
--tolerancethere is no--domainand no--features(src_python/tin_engine/cli.py@44fa7f5:845refuses--domainwithout--tolerance). The vertices are the stride nodes and the ring through them. The noder makes no crossings there, and the triangulation adds no points. Every vertex is a node. - On the tolerance path the two samplers must agree. refine measures and
carves with
vertex_z(include/terrain/refinement/scan.hpp@da3d1d63:75-99) and writes the output z of an off-node start vertex withraster::bilinear(include/terrain/refinement/refine.hpp@ed125121:425-426). If onlybilinearskipped cell-side corners, a domain vertex on a lattice line next to NoData would be void to the scan but valid in the output. A cell-side rule must change both. That changes refine: carving, the golden digests, and@perfacceptance. Increment 23 cuts pieces along lattice lines, so at basin scale such vertices are common. - The frames disagree on what "on a side" means. In the world frame it is
p.x == node x; in the fractional frame it is "col is an integer". Increment 16 records that the two disagree within an ulp of a cell line (16-domain-polygon.md, under T-real). A cell-side rule has to choose a frame, which is design work of its own.
If Ola wants it, it is a separate increment touching scan.hpp and
sample.hpp together, with @perf. Recommended: propose it only if a
measured run shows ragged seams along NoData.
| path | changes? | why |
|---|---|---|
no --tolerance, stride grid (12's R6) |
yes | every vertex is a node; a valid node next to NoData keeps its height and its triangles |
no --tolerance, mosaic tile (15) |
yes, the same way | same sample call on the assembled tile |
--tolerance, start-boundary / domain output z (include/terrain/refinement/refine.hpp@ed125121:425-426) |
no, by construction | bilinear is called there only for a start vertex that is not a node by refine's whole test: lattice_position, then node && g.node(c) == p (!given || (node && g.node(c) == p) ? vertex_z : bilinear). node_at answers the same as that whole test (S5), so the new branch is never taken from refine |
--tolerance, vertex_z (scan, carving, feet, edge strip) |
no | not touched; it already reads a node with value_at (include/terrain/refinement/scan.hpp@da3d1d63:86-87) |
--tolerance, reprojected (15c: resample, refine_points) |
no | resample is Python with its own four-corner rule (src_python/tin_engine/target_grid.py@44fa7f5:163-196); refine_points calls vertex_z, not bilinear |
a reprojected tile meshed without --tolerance, if a run does so |
the stride sampling of the resampled tile follows the new rule; resample does not change |
the target grid's nodes are col0 * h with integer h (src_python/tin_engine/target_grid.py@44fa7f5:48), so they are exact |
What changes in the stride output, exactly. (a) Valid nodes next to NoData
keep their z and their triangles; nodata_vertices_removed becomes the number
of NoData vertices. (b) A node vertex whose old sample was a few ulps off
(the measurement above) now gets the node value exactly. Where neither applies,
the output is byte-identical. On the Kartverket fixture
(tests/fixtures/dem_archive/7908_3_10m_z33.tif, 5051 x 5051, origin
799750 / 7950250, 10 m) both are empty. Sampling all 25 512 601 nodes with
today's extension: 42 476 refused, all of them NoData nodes, so no valid node
is refused; and every valid sample is bit-equal to the node value. Its stride
output should therefore be byte-identical before and after (test S8). The
measurement used the extension installed in the main checkout's venv, built
2026-09-29. sample.hpp has not changed since 3e2e9ec, and geometry.hpp
has gained only operator== (67df94c), so that build samples as master
does.
The noder writes vertices at SnapGrid::world(snap(p)), multiples of 1 mm
(include/terrain/noding/node.hpp:355-358, DEFAULT_SNAP_SPACING = 1e-3).
Where a node's coordinate is not already the double nearest a multiple of 1 mm,
the stride vertex moves off the node by up to 0.5 mm, is not a node any more,
and keeps today's four-corner rule. A NumPy emulation of the snap on the
Kartverket fixture's stride-1 nodes: 0 of 25 512 601 move. On made-up grids:
origin 500000.3 / spacing 0.7, 36 000 of 90 000 move; origin 1234.5678 /
spacing 30, all of them; origin 0 / spacing 30.87, 9 911.
A second source is in the C++ itself. Apple clang contracts
x_min_ + col * delta_x_ in RasterGeometry::node into a fused multiply-add,
and NumPy (subsample) does not. With origin 0.1 and spacing 0.1, 95 of 300
node x coordinates differ from NumPy's (a test program built with
c++ -std=c++20 -O2, compared with Python's math.fma and with NumPy). On
such grids the stride vertex is not node(n) bit for bit, even before the
noder.
Real inputs are not like this. Kartverket and 1 m grids have integer origins
and spacings, and the reprojected target grid is col0 * h with integer h.
Both effects need a non-integer, non-dyadic origin or spacing. The
subsample docstring says that using node's expression keeps the bits equal.
Under contraction that is not true, so this increment corrects the docstring
(a documentation defect found here, fixed in this PR). Test fixtures for this
increment use integer or dyadic geometry; see S1.
The same FMA effect means that lattice_position classifies the stride start
vertices of such a grid as off-node on the tolerance path. That was true before
this increment. It is reported to Ola under "For Ola" and not changed here.
Red first, on this branch (master, with 25, merged in). No mutation round:
the change is one early return, and the golden digests are the backstop for
the one invariant that matters (S5, S7). The C++ cases go in
tests/cpp/unit/test_raster_view.cpp (batch and view) and
tests/cpp/unit/test_raster.cpp (geometry and bilinear), except S5, which
has its own target, tests/cpp/unit/test_raster_node_at.cpp, so that its
compile failure before green does not hide S1-S4 and S6.
New:
- S1. On a node next to NoData, valid and exact (integer geometry:
origin 500000 / 7900000, spacing 10 / 5; S4 covers a non-dyadic grid). For a NoData node at each
of its eight neighbours in turn, sentinel and NaN both: the node is valid
and
z == value_atbit for bit. Include an interior node, a node on the last row, one on the last column, and all four grid corners, because the clamped last cell makes the NoData corner sit on the other side there. In C++ the points areg.node(c), computed by the samenodethatnode_atcompares against, so any geometry works. A Python test that builds coordinates in NumPy uses integer or dyadic geometry (see "A limit of the exact rule"). - S2. On a NoData node, refused. Sentinel and NaN.
- S3. Off-node points keep today's rule. Refused when any corner is NoData:
a cell-interior point; a point on a cell side with NoData across the side
(this pins the deferral);
std::nextafterof a node toward the NoData neighbour, in x and in y (this pins exactness). On a NoData-free raster, a seeded set of off-node points giveszbit-equal to today's formula. The test keeps a copy of today's expression as its reference, so a reordered expression insample.hppfails. - S4. Exact at every node, on a non-dyadic grid. All nodes
g.node(c)of a 200 x 300 grid with origin 500000.3 / 7900000.7 and spacing 0.7 / 0.3:z == value_ateverywhere, far edges included. Today about 38 500 of 60 000 fail (38 580 in the measurement above; 38 520 with@tester's values). The assertion does not depend on the count. The count may differ with another compiler or standard library, but the green assertion holds everywhere: the test's points andnode_at's comparison both come fromRasterGeometry::node, whatever that compiles to. A dyadic grid does not work here: it is exact today too (measured: 0 failures). - S5.
node_atagrees with refine's node test (invariant-critical for "the tolerance path is unchanged"). The reference is refine's whole test atinclude/terrain/refinement/refine.hpp@ed125121:417-426:v = lattice_position(g, p), thenv.is_node() && g.node(c) == pwithcthe node ofv.lattice_position(g, p).is_node()alone is not the reference. It is true for an off-node point whose fractional coordinates round to integers, so it disagrees with a correctnode_at(@testerfound 1300 such probes on a 0.1 grid, 350 of them fromnextafter). Over finite points in the node rectangle (nodes,nextafterneighbours of nodes, cell-side points, random points, and NumPy-style unfused node coordinates), on an integer grid, a dyadic grid and the non-dyadic "tenths" grid (origin 0.1 / 100.1, spacing 0.1, 120 x 120):g.node_at(p).has_value()equals the reference, and the indices agree when both say node. Alsonode_atreturns nullopt for NaN, infinities and points outside the rectangle. - S6. Degenerate rasters unchanged. A 1 x N and an N x 1 raster still give nullopt at their nodes. Points outside, and non-finite points, still give nullopt.
- S7. Tolerance-path digests, default flags. In
test_refine_golden.py, add digests forrasputin mesh --dem KARTVERKET --tolerance 1with default flags (start quality 25, constraint feet on), tile and quarter circle, through_cli_outcome's spy. They are recorded in the red commit, from the pre-change production code, and the green commit must not change them. The existing increment-17 digests stay as they are and must stay green. - S8. Stride-path digest on Kartverket. SHA-256 of the
.vtkthatrasputin mesh --dem KARTVERKETwrites at its default stride, with the version string kept out of the bytes. Again recorded in the red commit from pre-change code. It must be unchanged after green (see "Which paths change"). The red step also pinsnodata_vertices_removed= 397 at that stride, and it stays 397 after green: every removed vertex there is a NoData node.@needs_codecs.
Changed (one amendment commit on top of 25's tests, the reason in the message):
test_raster_view.cpp, "bilinear_batch: a zero-weight NoData corner still makes the point invalid" and "bilinear_batch: NaN corners are NoData even with no sentinel": flip to valid, with the node value, and rename them. S1 covers the cases; keep or fold them as@testerjudges.test_raster_view.cpp, "z is the node value, exactly inside, to 1e-9 on the far edges": exact everywhere (==), renamed.test_core_raster.py,TestSample::test_sentinel_and_nan_corners_are_not_validandTestToCore::test_forwards_the_sentinel: each samples a node next to a sentinel or NaN and expects it to be refused. Move the sentinel or NaN onto the sampled node, or sample an off-node point in that cell, so both tests still prove what their names say.- Increment 25's
test_cli_mesh_plain_output.py,test_nodata_vertices_removed_counts_the_strided_nodes_without_data: the expected count goes back to "picked nodes that are NoData" (25 at stride 1 on theholedfixture), and the docstring says so. Steps 1, 2 and 3 stay, with literal counts 25, 9 and 4. Only stride 1 is red before green; the trim reaches no picked node at strides 2 and 3. Droptrimmed_by_the_samplerand its assertion that at stride 1 the trim removes more than the NoData nodes (both done in the red step,6605dfe). test_the_no_tolerance_summary_says_on_or_next_to, in both files that had it: renamedtest_the_no_tolerance_summary_says_on_nodata_cells(tests/python/test_cli_mesh_plain_output.py@80990e0:279,test_run_record.py:443). Both expectvertices on NoData cells were removed, and also check that "next to" is absent from stderr.test_cli_mesh_dem.py,test_a_nodata_edge_row_is_dropped_and_counted: the wording goes back to6 vertices on NoData cells. The count stays 6: that fixture's NoData row is row 0, and under today's rule a node's cell reaches down and right, not up.test_run_record.py(25's): the stride summary sayson NoData cells, and the parametrisation overprojected/stridecollapses to one wording.
@tester greps for any other test that pins a zero-weight refusal
(grep -rn -i 'zero.weight\|next to NoData\|on or next to' tests). The scan
test "a NoData corner with zero weight still voids an off-node vertex"
(test_refinement_scan_offnode.cpp:302) is about vertex_z on a cell side.
It does not change.
geometry.hpp:node_at, as specified above.sample.hpp: the early return inbilinear; the header comment states the rule.bindings/core.cppand_core.pyi: thesampledocstrings.- 25's
run_record.py: the stride summary goes back toon NoData cells, one wording for both paths (thetolerance is not Nonechoice and its comment go). grid_domain.py: thesubsampledocstring correction above.
Docs in the code PR, each a line saying increment 27 changed it:
12-dem-to-mesh.md R2, the "NoData" and "On the last row or column" bullets;
12-dem-to-mesh.md:345 (test 4, "next to a NoData corner even at zero
weight", and the 1e-9 on the last row and column) and :443 (the exclusion
"Changing bilinear's NoData rule"). In 25-plain-output.md: the inventory
row for vertices without data dropped (around line 121), the
"nodata_vertices_removed on the path without --tolerance" bullet (around
751), and the "Until that fix" block in "NoData on the no-tolerance path"
(around 858-894). The 16 text on input vertices on a node (16-domain-polygon.md,
around "An input vertex that sits exactly on a node") is still true and is not
touched. ROADMAP.md: the proposed sampler row that 25 adds becomes row 27,
updated at merge as the README requires.
Not required. The README's rule covers diffs that touch
include/terrain/refinement/, include/terrain/mesh/, or what drives them.
This diff touches include/terrain/raster/. refine calls bilinear only in its
output loop, once per off-node start vertex, and S5/S7 show the result is
unchanged. The added cost per call is one division, one round and one compare
per axis. The stride path's sample phase is O(vertices) and was never a
bottleneck. @reviewer may quote the sample phase from --stats on the
Kartverket stride run before and after, as a sanity figure, not as
acceptance.
About 15 production lines: node_at about 10, the early return 3, and
run_record.py a net -1. Docstrings, raw literals and comments are excluded
under CLAUDE.md §2. Far under the ceiling.
- Scope: nodes only, cell sides later if ever (recommended). It fixes the
reported defect completely, since every vertex without
--toleranceis a node. The tolerance path stays byte-identical, and no@perfround is needed. The alternative, cell sides too, means changing refine'svertex_zwith it, new golden digests, and@perfacceptance. Increment 23's lattice-line cuts are where that would matter. - Where the rule lives: in the C++ sampler (recommended) rather than in
the stride path. The alternative is that the stride path reads
value_atby rounded index in Python. That works on any grid geometry, including the ones in "A limit of the exact rule", but it leavesbilinear's trap in place for the next caller and adds a second "is this a node" relation. Real DEMs (integer origin and spacing) are not affected by the limit. - For information, not a decision now. Apple clang fuses
RasterGeometry::node's expression and NumPy does not, so on a non-dyadic grid geometry C++ and Python disagree on node coordinates in the last bit. That bites refine's start-vertex classification too, today. It is invisible on real DEMs. If it ever matters, the fix is project-wide (-ffp-contract=off, ornodewritten so it cannot be fused). That touches every geometric computation, so it would be its own increment with@perf.
Recommended defaults: 1 and 2 as recommended; 3 noted.
Design review, round 1, 2026-10-03. Commit c277ce1. Verdict: CHANGES REQUESTED. LOC: 0 (design only); ~15 plausible (a node_at prototype is 8 lines). The rule, the unchanged --tolerance path, the 38,580 / 60,000 and 95 / 300 measurements, Kartverket's 0 off-node samples and the test plan all hold; every 25 test named exists on master. Blocking: (1) merge master; src_python/tin_engine/cli.py@1477da0b:811-812 is now :830; (2) cite grid_domain.py 66-67 for the stride expression; (3) the status line and line 219 assume 25 not yet merged; (4) test_the_no_tolerance_summary_says_on_or_next_to exists in two files: name both. Not pushed; no CI.
Design review, round 2, 2026-10-03. Range 1477da0..8d8e394 (merge 9fb2218, prose 8d8e394). Verdict: CHANGES REQUESTED, one line: the stride expression is at src_python/tin_engine/grid_domain.py@44fa7f5:68-69, not 66-67 (the round-1 number was the reviewer's slip). Everything else checked: the merge is clean, the new citations read as quoted, the round-1 items done. APPROVED once 68-69 is in, with no further round. Not pushed; no CI.
Design review, round 2 condition met, 2026-10-03. b50c8d0 cites grid_domain.py lines 68-69, unpinned, as the reviewer required (the same lines, pinned since: src_python/tin_engine/grid_domain.py@44fa7f5:68-69); per round 2 the design is APPROVED with no further round. Not pushed; no CI.
Ola: "yes to both", to the two decisions as recommended. (1) Nodes only now: a point exactly on a DEM node reads that node; cell sides are deferred until a measured run shows ragged edges along NoData. (2) The rule lives in the C++ sampler (bilinear with RasterGeometry::node_at), not only in the Python stride path.
27, code review, round 1, 2026-10-04. Range 7eb0b79..a0817ba (red 6605dfe, pins 80990e0, green 6fd4076 and a0817ba). Verdict: CHANGES REQUESTED. LOC: 13 net (node_at in geometry.hpp 11, the early return in sample.hpp 3, run_record.py -1) against about 15. Code sound; the notes in 12-dem-to-mesh.md and 25-plain-output.md, the binding docstring and the stub match the code; citations into this increment's files re-read and hold. Blocking: (1) red-step text in tests/cpp/unit/test_raster_node_at.cpp@9a47922:16-18 and tests/cpp/CMakeLists.txt@9a47922:389-390 (say why the target is separate now, without the red-step story); (2) this file's status line says "designed … Not started"; (3) ROADMAP.md:50's sampler row still says "not designed", and this design assigns it to become row 27 in this PR. Not pushed; no CI.
27, code review, round 2, 2026-10-04. Range 9a47922..11759aa (6c1369a test comments, 11759aa status line and ROADMAP row 27); whole increment 7eb0b79..11759aa. Verdict: APPROVED. LOC: 13 net, unchanged. All three round-1 blockers closed: both comments now give the reason the target is separate (it includes refine.hpp, so it needs the backend target and Threads), the status line and ROADMAP.md:50 match the branch; citations unchanged in line count and hold. Not pushed; no CI. At merge, ROADMAP row 27 says shipped.