From d25f6831f0e3ddbee420c188e248cb76c62911d6 Mon Sep 17 00:00:00 2001 From: Leonardo Araujo dos Santos Date: Tue, 6 Oct 2026 00:37:03 -0400 Subject: [PATCH 1/2] Evaluate mask extrude's surface projection only where the mask can matter #667 anchored the mask to the source surface by projecting every field sample along a six-tap central-difference gradient, so each sample called the source seven times. The v0.126.0 device gate measured mask_extrude 5.1-6.9x slower and the gallery's mask_extract 6.2x. The gradient now comes from the sample lattice: a central difference of the distances one cell either side, taken from the window of bricks the fill already evaluated. Only a neighbour outside the window calls the source, at the exact lattice position its own brick samples, so shared halo samples stay bit-equal across brick faces. The projection is also skipped where the shell alone decides the stored value: where it exceeds a max-pyramid bound on what the mask distance can report within |distance| of the sample (by the quadratic blend's support when border_round is set), and in bricks lying wholly beyond the band. The skip is exact, and tests hold it bit-identical to projecting every sample. Source calls per lattice sample on the device fixture: 7 -> 1.009. --- docs/07-brushes-and-features.md | 13 + include/clay/brush/mask_extrude.h | 10 + .../honor-mask-extrude-thickness/design.md | 12 +- .../specs/sdf-kernels/spec.md | 6 + .../honor-mask-extrude-thickness/tasks.md | 7 + src/brush/mask_extrude.cpp | 285 ++++++++++++++++-- tests/unit/test_mask_extrude.cpp | 212 +++++++++++++ 7 files changed, 522 insertions(+), 23 deletions(-) diff --git a/docs/07-brushes-and-features.md b/docs/07-brushes-and-features.md index 6da6527c..796a5b68 100644 --- a/docs/07-brushes-and-features.md +++ b/docs/07-brushes-and-features.md @@ -1006,6 +1006,19 @@ thin: the wall still reaches the requested thickness along the surface normal. The SDF path reads the mask at the source surface under each wall sample; the voxel path grows from masked surface cells for the requested number of layers. +Finding "the surface under a sample" needs the source's gradient, and it is +taken from the lattice the extrude already samples: a central difference of the +distances one cell either side. It costs no source evaluations of its own, +except for a neighbour outside the window of bricks being filled. The extrude +also skips the projection wherever the shell alone decides the stored value. +That is wherever the shell exceeds the largest distance the mask could report +within reach of the sample (by the smooth rim's support when `border_round` is +set), and in any brick lying wholly beyond the band. The skip is exact: the +volume is bit-identical to projecting every sample. The SDF path therefore +evaluates the source about once a sample, as it did before the surface anchor +was added. The first version of the anchor used six extra taps a sample, and the +v0.126.0 device gate measured `mask_extrude` 5-7x slower for it. + The one thing it needed that did not already exist is `brush::mask_to_field`. A mask is a `[0,1]` scalar on a lattice and **not a distance field**: composing one into a field expression directly puts a near-vertical step in the result and the diff --git a/include/clay/brush/mask_extrude.h b/include/clay/brush/mask_extrude.h index 3e9b9db1..164c977e 100644 --- a/include/clay/brush/mask_extrude.h +++ b/include/clay/brush/mask_extrude.h @@ -117,6 +117,16 @@ std::optional mask_extrude(const std::function mask_extrude_unculled( + const std::function& source, const voxel::MaskField& mask, + const MaskExtrudeSettings& settings); +} // namespace detail + // The same verb on a voxel grid, in CELL space rather than by sampling a field. // A grid already knows which of its cells are on its surface, so going through a // volume would cost a conversion and lose the palette for nothing. diff --git a/openspec/changes/honor-mask-extrude-thickness/design.md b/openspec/changes/honor-mask-extrude-thickness/design.md index fc0f2171..77b2e766 100644 --- a/openspec/changes/honor-mask-extrude-thickness/design.md +++ b/openspec/changes/honor-mask-extrude-thickness/design.md @@ -1,7 +1,17 @@ ## Surface-anchored region -The mask describes which points on the source surface are selected. For a field sample p, estimate the source normal by centered differences, project p to the source surface using its signed distance, then evaluate the measured mask distance at that projection. Compose that region with the existing shell and rim rounding. +The mask describes which points on the source surface are selected. For a field sample p, estimate the source normal by centered differences over the sample lattice, project p to the source surface using its signed distance, then evaluate the measured mask distance at that projection. Compose that region with the existing shell and rim rounding. For a voxel grid, select masked surface cells once and grow into adjacent cells up to the requested layer count without reapplying the mask to each newly reached cell. Preserve palette indices through the existing colour remap. +## Cost of the anchor +The first implementation estimated the normal with six off-lattice taps per sample, so every sample called the source seven times. On the v0.126.0 device gate (iPad15,5, iOS 27.0.1, against v0.120.1's engine) `mask_extrude` went from 51.6 to 288.3 ms at 10 stamps (5.58x), 374.5 to 2566.1 ms at 100 (6.85x) and 5297 to 26834 ms at 1000 (5.07x), with the growth exponent unchanged at 0.98. The gallery's one-shot `mask_extract` went from 75.3 to 468.5 ms (6.22x). Both device cases take the field path through `clay_document_mask_extrude`. The voxel path is not on the device gate. + +Two changes bring the field path back to about one source call per sample: + +- The gradient is a central difference over the sample lattice, using the distances one cell either side. The fill takes a whole window of bricks before projecting any of them. A neighbour across a brick face is read from the brick that sampled it, and only a neighbour outside the window calls the source. A lattice point is the same float position whichever brick names it, so samples shared by two bricks store the same bits. +- The projection is skipped wherever the shell alone decides the stored value. A max pyramid over the measured mask distances bounds what the mask could report at any point within |distance| of the sample. Where the shell exceeds that bound, max(shell, region) is the shell. With `border_round` the shell must exceed the bound by the quadratic blend's support (4k), where the blend term is exactly zero. A brick whose every shell is beyond the band is dropped by the volume regardless of the region. The skip is exact: the volume is bit-identical to projecting every sample. + +The lattice gradient changes stored values against the six-tap version. On the test fixtures the largest change is 0.002-0.01 cells on smooth surfaces, about 0.13 cells at a box edge, and 0.21 cells for a 0.6 inward wall that reaches the sphere's centre, where the source has no gradient. The brick layout is identical on every fixture. + ## Verification Measure outer wall height along several normals on a spherical cap at 0.05, 0.1, and 0.6 world units. Compare voxel and field extracts within one voxel and preserve the existing refusal, rounding, cancellation, and colour tests. diff --git a/openspec/changes/honor-mask-extrude-thickness/specs/sdf-kernels/spec.md b/openspec/changes/honor-mask-extrude-thickness/specs/sdf-kernels/spec.md index 9b3cc6ec..2c576ccd 100644 --- a/openspec/changes/honor-mask-extrude-thickness/specs/sdf-kernels/spec.md +++ b/openspec/changes/honor-mask-extrude-thickness/specs/sdf-kernels/spec.md @@ -7,3 +7,9 @@ The library SHALL resolve a source field, a mask and a thickness into a new fiel - **WHEN** a thin mask on a sphere is extruded outward by 0.05, 0.1 or 0.6 world units - **THEN** the wall height along each sampled surface normal is within 10% of the requested thickness - **AND** the wall top remains even across the masked patch + +#### Scenario: Anchoring the mask does not multiply source evaluations +- **WHEN** a dabbed mask on a 0.8 sphere is extruded outward by 0.05 at a 0.04 cell +- **THEN** the source is evaluated at most 1.5 times per lattice sample on average +- **AND** the result is bit-identical to projecting every sample onto the surface +- **AND** every sample two neighbouring bricks share holds the same value in both diff --git a/openspec/changes/honor-mask-extrude-thickness/tasks.md b/openspec/changes/honor-mask-extrude-thickness/tasks.md index 8f3dd253..a2fb50d3 100644 --- a/openspec/changes/honor-mask-extrude-thickness/tasks.md +++ b/openspec/changes/honor-mask-extrude-thickness/tasks.md @@ -4,3 +4,10 @@ - [x] Add measured height and evenness regression tests for both paths. - [x] Update the API header and feature documentation. - [x] Run focused C++ and C ABI regression tests. + +## Restore the field path's cost (v0.126.0 device gate) +- [x] Take the projection's gradient from the sample lattice instead of six off-lattice taps. +- [x] Skip the projection where the shell alone decides the stored value, and prove the skip bit-identical to projecting every sample. +- [x] Assert the halo stays seamless across brick faces. +- [x] Gate source calls per lattice sample at <= 1.5 on the device fixture (7 before, 1.009 after). +- [x] A/B the C ABI on the device fixture against main and the pre-anchor engine. diff --git a/src/brush/mask_extrude.cpp b/src/brush/mask_extrude.cpp index 4fdb4e64..6fa1c776 100644 --- a/src/brush/mask_extrude.cpp +++ b/src/brush/mask_extrude.cpp @@ -239,20 +239,236 @@ int layers_for(float thickness, float cell) { // A painted mask describes a patch ON the source, not a volume that must // itself extend through the requested wall. Read it at the nearest point on // the source surface so the wall keeps the same footprint at every height. -cfloat3 project_to_surface(const std::function& source, cfloat3 p, - float distance, float cell) { - const float h = cell * 0.5f; - const cfloat3 dx = cf3(h, 0.0f, 0.0f); - const cfloat3 dy = cf3(0.0f, h, 0.0f); - const cfloat3 dz = cf3(0.0f, 0.0f, h); - const cfloat3 gradient = cf3(source(p + dx) - source(p - dx), - source(p + dy) - source(p - dy), - source(p + dz) - source(p - dz)); +// `gradient` need only point the right way; its length is divided out. +cfloat3 project_to_surface(cfloat3 p, cfloat3 gradient, float distance) { const float length = kernel::clength(gradient); if (length < 1e-6f) return p; return p - gradient * (distance / length); } +// -- where the mask cannot matter --------------------------------------------- +// +// The extrude stores max(shell, region), or the quadratic smooth max, which +// equals max(shell, region) wherever the two are at least its support apart. +// `region` is the mask read at the sample's projection onto the source, which +// costs a gradient and a trilinear read per sample. Most samples do not need +// it: where the shell already exceeds anything the +// region could be, the stored value IS the shell, bit for bit, whatever the +// projection would have said. These pieces decide that without projecting. + +// The most MaskDistance::eval can return at any point within a given reach of +// a sample, from a max pyramid over the dense distances. Conservative by +// construction: eval is a convex combination of eight stored distances, and +// the pyramid answers with the largest stored distance over a box containing +// every corner eval could read. +class RegionCeiling { + public: + explicit RegionCeiling(const MaskDistance& md) : md_(md) { + float largest = 0.0f; + for (float v : md.d) largest = std::max(largest, std::abs(v)); + // Trilinear interpolation in float can land a few ulps above its + // largest corner; this covers it with two orders of magnitude to spare. + slack_ = largest * 1e-5f; + levels_.push_back(Level{md.nx, md.ny, md.nz, md.d}); + while (levels_.back().nx > 1 || levels_.back().ny > 1 || levels_.back().nz > 1) + levels_.push_back(halve(levels_.back())); + } + + // An upper bound on md.eval(q) for every q within `reach` of p. Infinite + // when the query cannot be placed, which makes the caller evaluate. + float above(cfloat3 p, float reach) const { + // In cells, as eval measures them, widened past the rounding in both + // the projection and the index arithmetic. + const float r = reach / md_.cell * 1.001f + 0.01f; + int x0, x1, y0, y1, z0, z1; + if (!span(p.x / md_.cell - 0.5f - static_cast(md_.lo.x), r, md_.nx, &x0, &x1) || + !span(p.y / md_.cell - 0.5f - static_cast(md_.lo.y), r, md_.ny, &y0, &y1) || + !span(p.z / md_.cell - 0.5f - static_cast(md_.lo.z), r, md_.nz, &z0, &z1)) + return std::numeric_limits::infinity(); + // The finest level at which the span covers at most two entries a side. + int k = 0; + while (((x1 >> k) - (x0 >> k)) > 1 || ((y1 >> k) - (y0 >> k)) > 1 || + ((z1 >> k) - (z0 >> k)) > 1) + ++k; + const Level& level = levels_[static_cast(k)]; + float most = -std::numeric_limits::infinity(); + for (int z = z0 >> k; z <= z1 >> k; ++z) + for (int y = y0 >> k; y <= y1 >> k; ++y) + for (int x = x0 >> k; x <= x1 >> k; ++x) most = std::max(most, level.at(x, y, z)); + return most + slack_; + } + + private: + struct Level { + int nx, ny, nz; + std::vector m; + float at(int x, int y, int z) const { + return m[(static_cast(z) * ny + y) * nx + x]; + } + }; + + static Level halve(const Level& fine) { + Level coarse{(fine.nx + 1) / 2, (fine.ny + 1) / 2, (fine.nz + 1) / 2, {}}; + coarse.m.assign(static_cast(coarse.nx) * coarse.ny * coarse.nz, + -std::numeric_limits::infinity()); + for (int z = 0; z < fine.nz; ++z) + for (int y = 0; y < fine.ny; ++y) + for (int x = 0; x < fine.nx; ++x) { + const std::size_t at = + (static_cast(z / 2) * coarse.ny + y / 2) * coarse.nx + x / 2; + coarse.m[at] = std::max(coarse.m[at], fine.at(x, y, z)); + } + return coarse; + } + + // The lattice indices eval's corners can take for a coordinate anywhere in + // [f - r, f + r], clamped exactly as eval clamps. + static bool span(float f, float r, int n, int* lo, int* hi) { + const float a = f - r, b = f + r; + if (!std::isfinite(a) || !std::isfinite(b)) return false; + const float top = static_cast(n - 1); + *lo = static_cast(std::floor(std::clamp(a, 0.0f, top))); + *hi = std::min(static_cast(std::floor(std::clamp(b, 0.0f, top))) + 1, n - 1); + return true; + } + + const MaskDistance& md_; + std::vector levels_; + float slack_ = 0.0f; +}; + +// The stored value, given the region. +float extrude_value(float shell, float region, float round) { + return round > 0.0f ? kernel::op_sintersect_quadratic(shell, region, round) + : kernel::op_intersect(shell, region); +} + +// True when every region at or below `ceiling` makes extrude_value return +// `shell` exactly. cmax(shell, region) is shell once shell > region. The smooth +// form is -(cmin(-shell, -region) - h*h*s/4) with h = max(s - |shell - region|, +// 0) / s, which is exactly zero once shell - region >= s; float subtraction +// keeps that ordering because rounding is monotone. +bool shell_decides(float shell, float ceiling, float round) { + if (!(shell > ceiling)) return false; + return !(round > 0.0f) || shell - ceiling >= kernel::csmin_quadratic_support(round); +} + +// The field extrude's per-brick fill. With `cull` off every sample is +// projected, which is the reference the culled fill must match bit for bit. +// +// The projection's gradient is a central difference over the LATTICE: the +// distances one cell either side, which the fill has already taken for every +// neighbour in the window it was handed. A lattice point is the same float +// position whichever brick names it (BrickGrid::sample_position adds integers +// before converting), so a neighbour across a brick face is read from the +// brick that sampled it, and only one outside the window costs a source call +// at that same position. Either way a sample two bricks share gets the same +// gradient in each, and the halo stays seamless. #667's six off-lattice taps +// cost six calls on every sample. +class ExtrudeFill { + public: + ExtrudeFill(const std::function& source, const MaskDistance& md, + const RegionCeiling& ceiling, const MaskExtrudeSettings& settings, float round, + bool cull) + : source_(source), md_(md), ceiling_(ceiling), side_(settings.side), + thickness_(settings.thickness), round_(round), cull_(cull) {} + + void operator()(const field::FieldVolume::BrickGrid& grid, std::size_t first, + std::size_t count, float* out) { + first_ = first; + count_ = count; + distance_.resize(count * field::kBrickSamples); + // The shell first, one source call a sample, for the whole window. + for (std::size_t s = 0; s < count; ++s) + for (int i = 0; i < field::kBrickSamples; ++i) { + const std::size_t at = s * field::kBrickSamples + static_cast(i); + distance_[at] = source_(grid.sample_position(first + s, i)); + out[at] = shell_of(distance_[at], side_, thickness_); + } + for (std::size_t s = 0; s < count; ++s) + project_brick(grid, s, out + s * field::kBrickSamples); + } + + private: + static constexpr int kSide = field::kBrickDim + 1; + static constexpr int kStride[3] = {1, kSide, kSide * kSide}; + + void project_brick(const field::FieldVolume::BrickGrid& grid, std::size_t s, + float* block) const { + // A brick whose every shell lies beyond the band is dropped by the + // volume whatever the region says, because the stored value is never + // below the shell, and all that survives of it is a sign the shell + // already has. + if (cull_ && std::all_of(block, block + field::kBrickSamples, + [&grid](float shell) { return shell > grid.band; })) + return; + const float* distance = distance_.data() + s * field::kBrickSamples; + for (int i = 0; i < field::kBrickSamples; ++i) { + const cfloat3 p = grid.sample_position(first_ + s, i); + if (cull_ && shell_decides(block[i], ceiling_.above(p, std::abs(distance[i])), round_)) + continue; + const cfloat3 q = project_to_surface(p, gradient(grid, s, i), distance[i]); + block[i] = extrude_value(block[i], md_.eval(q), round_); + } + } + + cfloat3 gradient(const field::FieldVolume::BrickGrid& grid, std::size_t s, int i) const { + float g[3]; + for (int axis = 0; axis < 3; ++axis) + g[axis] = neighbour(grid, s, i, axis, +1) - neighbour(grid, s, i, axis, -1); + return cf3(g[0], g[1], g[2]); + } + + // The source's distance at sample `i` of window brick `s`, stepped one cell + // along `axis`. + float neighbour(const field::FieldVolume::BrickGrid& grid, std::size_t s, int i, int axis, + int step) const { + const int local[3] = {i % kSide, (i / kSide) % kSide, i / (kSide * kSide)}; + const int moved = local[axis] + step; + if (moved >= 0 && moved < kSide) + return distance_[s * field::kBrickSamples + + static_cast(i + step * kStride[axis])]; + // Across the face: the neighbouring brick holds this lattice point one + // cell in from its own opposite face. + const std::size_t slot = first_ + s; + const std::int32_t brick[3] = { + static_cast(slot % static_cast(grid.bcount[0])), + static_cast((slot / static_cast(grid.bcount[0])) % + static_cast(grid.bcount[1])), + static_cast(slot / (static_cast(grid.bcount[0]) * + static_cast(grid.bcount[1])))}; + const std::int64_t slot_stride[3] = { + 1, grid.bcount[0], static_cast(grid.bcount[0]) * grid.bcount[1]}; + const std::int64_t next = static_cast(slot) + step * slot_stride[axis]; + const bool in_lattice = brick[axis] + step >= 0 && brick[axis] + step < grid.bcount[axis]; + if (in_lattice && next >= static_cast(first_) && + next < static_cast(first_ + count_)) { + const int there = step > 0 ? 1 : field::kBrickDim - 1; + const std::size_t brick_at = static_cast(next) - first_; + return distance_[brick_at * field::kBrickSamples + + static_cast(i + (there - local[axis]) * kStride[axis])]; + } + // Outside the window: the source, at the expression sample_position + // uses, so the value is the one the neighbouring brick's fill takes. + int g[3]; + grid.sample_cell(slot, i, g); + g[axis] += step; + return source_(grid.origin + cf3(static_cast(g[0]), static_cast(g[1]), + static_cast(g[2])) * + grid.cell_size); + } + + const std::function& source_; + const MaskDistance& md_; + const RegionCeiling& ceiling_; + ExtrudeSide side_; + float thickness_; + float round_; + bool cull_; + std::size_t first_ = 0, count_ = 0; + std::vector distance_; // the window's source distances, brick-major +}; + } // namespace // -- the public conversion ---------------------------------------------------- @@ -271,10 +487,12 @@ std::optional mask_to_field(const voxel::MaskField& mask, fl // -- the extrude, on a field -------------------------------------------------- -std::optional mask_extrude(const std::function& source, - const voxel::MaskField& mask, - const MaskExtrudeSettings& settings, - parallel::CancelToken* token) { +namespace { + +std::optional extrude_field(const std::function& source, + const voxel::MaskField& mask, + const MaskExtrudeSettings& settings, + parallel::CancelToken* token, bool cull) { if (!(settings.thickness > 0.0f) || mask.empty() || !source) return std::nullopt; const float cell = settings.cell_size > 0.0f ? settings.cell_size : mask.cell_size(); if (!(cell > 0.0f)) return std::nullopt; @@ -296,17 +514,21 @@ std::optional mask_extrude(const std::functioneval(project_to_surface(source, p, distance, cell)); - const float v = round > 0.0f ? kernel::op_sintersect_quadratic(shell, region, round) - : kernel::op_intersect(shell, region); - deepest = std::min(deepest, v); - return v; + field::FieldVolume out = field::FieldVolume::sample_blocks( + [&](const field::FieldVolume::BrickGrid& grid, std::size_t first, std::size_t count, + float* values) { + fill(grid, first, count, values); + for (std::size_t i = 0; i < count * field::kBrickSamples; ++i) + deepest = std::min(deepest, values[i]); }, md->bounds(), cell, band, token, &cancelled); @@ -328,6 +550,25 @@ std::optional mask_extrude(const std::function mask_extrude(const std::function& source, + const voxel::MaskField& mask, + const MaskExtrudeSettings& settings, + parallel::CancelToken* token) { + return extrude_field(source, mask, settings, token, true); +} + +namespace detail { + +std::optional mask_extrude_unculled( + const std::function& source, const voxel::MaskField& mask, + const MaskExtrudeSettings& settings) { + return extrude_field(source, mask, settings, nullptr, false); +} + +} // namespace detail + // -- the extrude, on voxels --------------------------------------------------- std::optional mask_extrude(const voxel::VoxelGrid& grid, diff --git a/tests/unit/test_mask_extrude.cpp b/tests/unit/test_mask_extrude.cpp index 7c7d06b5..c06487c7 100644 --- a/tests/unit/test_mask_extrude.cpp +++ b/tests/unit/test_mask_extrude.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include "clay/brush/mask_extrude.h" @@ -596,3 +598,213 @@ TEST_CASE("mask extrude: border_smooth rounds the rim it is asked to round") { last = v; } } + +// -- the projection is paid only where the mask can matter -------------------- +// +// #667 reads the mask at each sample's projection onto the source, and its +// six-tap gradient cost six more source calls a sample. That took the device +// gate's mask_extrude from 51.6 to 288.3 ms at 10 stamps. The extrude now takes +// the gradient from the lattice it already sampled, and projects only where the +// region can change a stored value. These hold it to that: skipping is +// BIT-IDENTICAL to projecting every sample, the halo stays seamless, and the +// source is called about once a sample. #667's own tests above hold the wall +// to its requested thickness. + +namespace { + +// The device gate's mask_extrude fixture, on the host: a 0.8 shell, 24 dabs on +// it at the gate's stamp spread, a 0.05 wall at a 0.04 cell. +constexpr float kShellRadius = 0.8f; + +cfloat3 stamp_direction(int i) { + const double m[3] = {0.4142135624, 0.7320508076, 0.2360679775}; + float c[3]; + for (int a = 0; a < 3; ++a) { + const double frac = std::fmod(static_cast(i) * m[a], 1.0); + c[a] = static_cast(frac) * 1.6f - 0.8f; + } + const cfloat3 p = cf3(c[0], c[1], c[2]); + return p / std::max(kernel::clength(p), 1e-6f); +} + +MaskField dabbed_shell_mask(float cell = 0.04f) { + MaskField m(cell); + voxel::BrushParams b; + b.size = 6; + b.shape = voxel::BrushShape::Sphere; + b.falloff = voxel::BrushFalloff::Smooth; + b.strength = 1.0f; + b.seed = 1; + for (int i = 0; i < 24; ++i) m.paint(stamp_direction(i) * kShellRadius, b, 1.0f); + return m; +} + +// A mask along one edge of a box: the source's gradient turns a corner under it. +MaskField box_edge_mask(float half, float cell = 0.02f) { + MaskField m(cell); + const auto to_cell = [cell](float w) { return static_cast(std::floor(w / cell)); }; + for (std::int32_t z = to_cell(-0.3f); z <= to_cell(0.3f); ++z) + for (std::int32_t y = to_cell(half - 0.15f); y <= to_cell(half + 0.15f); ++y) + for (std::int32_t x = to_cell(half - 0.15f); x <= to_cell(half + 0.15f); ++x) + m.set({x, y, z}, 1.0f); + return m; +} + +auto box_field(float half) { + return [half](cfloat3 p) { + const cfloat3 q = cf3(std::abs(p.x) - half, std::abs(p.y) - half, std::abs(p.z) - half); + const cfloat3 out = cf3(std::max(q.x, 0.0f), std::max(q.y, 0.0f), std::max(q.z, 0.0f)); + return kernel::clength(out) + std::min(std::max(q.x, std::max(q.y, q.z)), 0.0f); + }; +} + +// Every sample the lattice visits, stored or not: bricks times samples a brick. +double lattice_samples(const FieldVolume& v) { + double bricks = 1.0; + for (int a = 0; a < 3; ++a) + bricks *= static_cast((v.sample_extent(a) - 1) / field::kBrickDim); + return bricks * static_cast(field::kBrickSamples); +} + +bool same_bits(const std::vector& a, const std::vector& b) { + return a.size() == b.size() && + (a.empty() || std::memcmp(a.data(), b.data(), a.size() * sizeof(float)) == 0); +} + +struct Case { + const char* name; + std::function source; + MaskField mask; + MaskExtrudeSettings settings; +}; + +MaskExtrudeSettings with(float thickness, ExtrudeSide side, float round, float cell = 0.0f) { + MaskExtrudeSettings s; + s.thickness = thickness; + s.side = side; + s.border_round = round; + s.cell_size = cell; + return s; +} + +std::vector identity_cases() { + std::vector cases; + const MaskField cap = cap_mask(); + for (const ExtrudeSide side : {ExtrudeSide::Outward, ExtrudeSide::Inward, ExtrudeSide::Centred}) + for (const float round : {0.0f, 0.06f}) + cases.push_back({"sphere cap", sphere_field(), cap, with(0.12f, side, round)}); + // #660's walls: the requested thickness beyond the painted mask. + cases.push_back({"#660 0.05", sphere_field(), cap, with(0.05f, ExtrudeSide::Outward, 0.0f, 0.01f)}); + cases.push_back({"#660 0.1", sphere_field(), cap, with(0.1f, ExtrudeSide::Outward, 0.0f, 0.01f)}); + cases.push_back({"#660 0.6", sphere_field(), cap, with(0.6f, ExtrudeSide::Outward, 0.0f, 0.03f)}); + cases.push_back({"#660 0.6 rounded inward", sphere_field(), cap, + with(0.6f, ExtrudeSide::Inward, 0.05f, 0.03f)}); + for (const ExtrudeSide side : {ExtrudeSide::Outward, ExtrudeSide::Centred}) + for (const float round : {0.0f, 0.04f}) + cases.push_back({"box edge", box_field(0.4f), box_edge_mask(0.4f), + with(0.08f, side, round, 0.02f)}); + MaskExtrudeSettings smoothed = with(0.09f, ExtrudeSide::Outward, 0.03f, 0.03f); + smoothed.border_smooth = 2; + cases.push_back({"serrated, smoothed", sphere_field(), serrated_cap(9), smoothed}); + cases.push_back({"device shell", sphere_field(kShellRadius), dabbed_shell_mask(), + with(0.05f, ExtrudeSide::Outward, 0.0f, 0.04f)}); + cases.push_back({"device shell rounded inward", sphere_field(kShellRadius), + dabbed_shell_mask(), with(0.05f, ExtrudeSide::Inward, 0.02f, 0.04f)}); + return cases; +} + +} // namespace + +TEST_CASE("mask extrude: skipping the projection changes no stored bit") { + for (const Case& c : identity_cases()) { + CAPTURE(c.name); + CAPTURE(static_cast(c.settings.side)); + CAPTURE(c.settings.border_round); + const std::optional fast = brush::mask_extrude(c.source, c.mask, c.settings); + const std::optional reference = + brush::detail::mask_extrude_unculled(c.source, c.mask, c.settings); + REQUIRE(fast.has_value() == reference.has_value()); + if (!fast) continue; + // The whole serialized volume: lattice, index, far bounds, every stored + // sample and the measured Lipschitz, compared as bits. + CHECK(fast->brick_count() > 0); + CHECK(same_bits(fast->to_blob(), reference->to_blob())); + } +} + +TEST_CASE("mask extrude: neighbouring bricks agree on every sample they share") { + // The projection's gradient is a lattice difference, and a sample on a brick + // face takes its outside neighbour from the source rather than from the + // brick. Both bricks must still store the same bits there, or the halo that + // makes a brick self-contained becomes a seam. + for (const Case& c : identity_cases()) { + CAPTURE(c.name); + const std::optional v = brush::mask_extrude(c.source, c.mask, c.settings); + REQUIRE(v.has_value()); + const std::vector blob = v->to_blob(); + const int count[3] = {static_cast(blob[5]), static_cast(blob[6]), + static_cast(blob[7])}; + const auto index_base = static_cast(blob[8]); + const auto data_base = static_cast(blob[10]); + const auto offset = [&](int x, int y, int z) { + return static_cast( + blob[index_base + static_cast((z * count[1] + y) * count[0] + x)]); + }; + constexpr int n = field::kBrickDim + 1; + const auto sample = [&](std::int32_t brick, int x, int y, int z) { + return blob[data_base + static_cast(brick) + + static_cast((z * n + y) * n + x)]; + }; + std::size_t faces = 0, mismatched = 0; + for (int z = 0; z < count[2]; ++z) + for (int y = 0; y < count[1]; ++y) + for (int x = 0; x < count[0]; ++x) { + const std::int32_t here = offset(x, y, z); + if (here < 0) continue; + for (int axis = 0; axis < 3; ++axis) { + const int next[3] = {x + (axis == 0), y + (axis == 1), z + (axis == 2)}; + if (next[axis] >= count[axis]) continue; + const std::int32_t there = offset(next[0], next[1], next[2]); + if (there < 0) continue; + ++faces; + for (int a = 0; a < n; ++a) + for (int b = 0; b < n; ++b) { + // `here`'s far face against `there`'s near one. + int p[3], q[3]; + p[axis] = field::kBrickDim; + q[axis] = 0; + p[(axis + 1) % 3] = q[(axis + 1) % 3] = a; + p[(axis + 2) % 3] = q[(axis + 2) % 3] = b; + const float u = sample(here, p[0], p[1], p[2]); + const float w = sample(there, q[0], q[1], q[2]); + if (std::memcmp(&u, &w, sizeof(float)) != 0) ++mismatched; + } + } + } + CHECK(faces > 0); + CHECK(mismatched == 0); + } +} + +TEST_CASE("mask extrude: the source is called about once a sample") { + // Counted, not timed. Per lattice sample rather than per stored one, so + // sparsity cannot flatter it: #667's formula was exactly 7 here (the + // distance and a six-tap gradient), and the extrude before it exactly 1. + const MaskField mask = dabbed_shell_mask(); + const auto shell = sphere_field(kShellRadius); + std::size_t calls = 0; + const std::function counted = [&](cfloat3 p) { + ++calls; + return shell(p); + }; + const std::optional v = + brush::mask_extrude(counted, mask, with(0.05f, ExtrudeSide::Outward, 0.0f, 0.04f)); + REQUIRE(v.has_value()); + const double per_sample = static_cast(calls) / lattice_samples(*v); + CAPTURE(calls); + CAPTURE(per_sample); + MESSAGE("source calls per lattice sample: " << per_sample); + CHECK(per_sample >= 1.0); + CHECK(per_sample <= 1.5); +} + From 3642a77ad3db2079e148a203762f0475daa9fd65 Mon Sep 17 00:00:00 2001 From: Leonardo Araujo dos Santos Date: Tue, 6 Oct 2026 01:48:01 -0400 Subject: [PATCH 2/2] Tighten the mask extrude skip: alias, one position function, a tally Review follow-ups to the skip and lattice gradient. - RegionCeiling's level 0 now reads MaskDistance::d in place instead of copying it, so the bound adds about a seventh of the dense distances rather than doubling them while sampling. This verb has jetsam history on the tablet. - BrickGrid::cell_position is now the one function every sample position goes through, and the extrude's off-window neighbour uses it too. The two were separate copies of the same float expression in different translation units, which contraction could have split. - The unculled reference moves out of the public header into src/brush/mask_extrude_internal.h, with a tally of what the fill skipped. New tests assert the skip fires on whole bricks beyond the band (device fixture) and on stored samples under a painted interior (sphere cap). Turning either skip off fails them. - Two buried-mask fixtures (a masked volume starting just under an unpainted surface, deep inward walls) make a bound that reads only half the projection's reach change stored bits. With the reach halved, bit-identity now fails on both and on the box edge. The pyramid reads up to twice the span it is asked for, which hides most half-reach errors; these variants were measured to show it anyway. --- include/clay/brush/mask_extrude.h | 10 -- include/clay/field/volume.h | 7 ++ .../honor-mask-extrude-thickness/design.md | 2 + .../honor-mask-extrude-thickness/tasks.md | 4 + src/brush/mask_extrude.cpp | 78 ++++++++----- src/brush/mask_extrude_internal.h | 35 ++++++ src/field/volume.cpp | 16 +-- tests/unit/test_mask_extrude.cpp | 107 +++++++++++++++++- 8 files changed, 212 insertions(+), 47 deletions(-) create mode 100644 src/brush/mask_extrude_internal.h diff --git a/include/clay/brush/mask_extrude.h b/include/clay/brush/mask_extrude.h index 164c977e..3e9b9db1 100644 --- a/include/clay/brush/mask_extrude.h +++ b/include/clay/brush/mask_extrude.h @@ -117,16 +117,6 @@ std::optional mask_extrude(const std::function mask_extrude_unculled( - const std::function& source, const voxel::MaskField& mask, - const MaskExtrudeSettings& settings); -} // namespace detail - // The same verb on a voxel grid, in CELL space rather than by sampling a field. // A grid already knows which of its cells are on its surface, so going through a // volume would cost a conversion and lose the palette for nothing. diff --git a/include/clay/field/volume.h b/include/clay/field/volume.h index abe85ee8..415547ca 100644 --- a/include/clay/field/volume.h +++ b/include/clay/field/volume.h @@ -111,6 +111,13 @@ class FieldVolume { // them from the world position would be inverting arithmetic that is // right here. void sample_cell(std::size_t slot, int i, int out[3]) const; + // The world point of a GLOBAL cell coordinate: the one expression every + // position above goes through. A fill that needs a lattice point no + // brick handed it (a neighbour across the window's edge) calls this + // rather than rebuilding the arithmetic, so the point is the one the + // brick that owns it samples, bit for bit, whatever the compiler + // contracts. + kernel::cfloat3 cell_position(const int cell[3]) const; // The box the brick's samples span, halo face included. math::Aabb brick_box(std::size_t slot) const; diff --git a/openspec/changes/honor-mask-extrude-thickness/design.md b/openspec/changes/honor-mask-extrude-thickness/design.md index 77b2e766..1b2958cf 100644 --- a/openspec/changes/honor-mask-extrude-thickness/design.md +++ b/openspec/changes/honor-mask-extrude-thickness/design.md @@ -11,6 +11,8 @@ Two changes bring the field path back to about one source call per sample: - The gradient is a central difference over the sample lattice, using the distances one cell either side. The fill takes a whole window of bricks before projecting any of them. A neighbour across a brick face is read from the brick that sampled it, and only a neighbour outside the window calls the source. A lattice point is the same float position whichever brick names it, so samples shared by two bricks store the same bits. - The projection is skipped wherever the shell alone decides the stored value. A max pyramid over the measured mask distances bounds what the mask could report at any point within |distance| of the sample. Where the shell exceeds that bound, max(shell, region) is the shell. With `border_round` the shell must exceed the bound by the quadratic blend's support (4k), where the blend term is exactly zero. A brick whose every shell is beyond the band is dropped by the volume regardless of the region. The skip is exact: the volume is bit-identical to projecting every sample. +The pyramid's finest level is the measured distances themselves, read in place; only the coarser levels are allocated, about a seventh of the dense array. The pyramid reads up to twice the span it is asked for. That is safe, but it also hides a bound that looked only half as far as it should. The buried-mask test fixtures are built so that a half-reach bound changes stored bits there anyway. + The lattice gradient changes stored values against the six-tap version. On the test fixtures the largest change is 0.002-0.01 cells on smooth surfaces, about 0.13 cells at a box edge, and 0.21 cells for a 0.6 inward wall that reaches the sphere's centre, where the source has no gradient. The brick layout is identical on every fixture. ## Verification diff --git a/openspec/changes/honor-mask-extrude-thickness/tasks.md b/openspec/changes/honor-mask-extrude-thickness/tasks.md index a2fb50d3..f6b62ef7 100644 --- a/openspec/changes/honor-mask-extrude-thickness/tasks.md +++ b/openspec/changes/honor-mask-extrude-thickness/tasks.md @@ -11,3 +11,7 @@ - [x] Assert the halo stays seamless across brick faces. - [x] Gate source calls per lattice sample at <= 1.5 on the device fixture (7 before, 1.009 after). - [x] A/B the C ABI on the device fixture against main and the pre-anchor engine. +- [x] Read the bound's finest level in place from the measured distances rather than copying them (peak memory). +- [x] Build an off-window neighbour's position through `BrickGrid::cell_position`, the function every sample position goes through. +- [x] Keep the unculled reference and a skip tally in a src-private header; assert the skip fires on whole bricks beyond the band and on stored samples under a painted interior. +- [x] Add a buried-mask fixture that catches a bound reading only half the projection's reach. diff --git a/src/brush/mask_extrude.cpp b/src/brush/mask_extrude.cpp index 6fa1c776..c67cc0f7 100644 --- a/src/brush/mask_extrude.cpp +++ b/src/brush/mask_extrude.cpp @@ -3,6 +3,7 @@ // measuring a mask as a distance, and why the mask needs no region parameter. #include "clay/brush/mask_extrude.h" +#include "mask_extrude_internal.h" #include #include @@ -269,9 +270,20 @@ class RegionCeiling { // Trilinear interpolation in float can land a few ulps above its // largest corner; this covers it with two orders of magnitude to spare. slack_ = largest * 1e-5f; - levels_.push_back(Level{md.nx, md.ny, md.nz, md.d}); - while (levels_.back().nx > 1 || levels_.back().ny > 1 || levels_.back().nz > 1) - levels_.push_back(halve(levels_.back())); + // Level 0 IS the dense distances, read in place rather than copied: + // this verb has been killed for memory on the tablet, and a copy would + // double the largest array it holds while sampling. The coarser levels + // together add about a seventh of it. + levels_.push_back(Level{md.nx, md.ny, md.nz, md.d.data()}); + while (levels_.back().nx > 1 || levels_.back().ny > 1 || levels_.back().nz > 1) { + const Level fine = levels_.back(); + Level coarse{(fine.nx + 1) / 2, (fine.ny + 1) / 2, (fine.nz + 1) / 2, nullptr}; + // A vector's buffer survives the outer vector growing, so the + // pointer taken here stays valid. + owned_.push_back(halve(fine, coarse)); + coarse.m = owned_.back().data(); + levels_.push_back(coarse); + } } // An upper bound on md.eval(q) for every q within `reach` of p. Infinite @@ -299,26 +311,28 @@ class RegionCeiling { } private: + // One level of the pyramid, as a view: level 0 points into the + // MaskDistance, the rest into owned_. struct Level { int nx, ny, nz; - std::vector m; + const float* m; float at(int x, int y, int z) const { return m[(static_cast(z) * ny + y) * nx + x]; } }; - static Level halve(const Level& fine) { - Level coarse{(fine.nx + 1) / 2, (fine.ny + 1) / 2, (fine.nz + 1) / 2, {}}; - coarse.m.assign(static_cast(coarse.nx) * coarse.ny * coarse.nz, - -std::numeric_limits::infinity()); + // The maxima of `fine` over 2x2x2 blocks, laid out as `coarse` describes. + static std::vector halve(const Level& fine, const Level& coarse) { + std::vector m(static_cast(coarse.nx) * coarse.ny * coarse.nz, + -std::numeric_limits::infinity()); for (int z = 0; z < fine.nz; ++z) for (int y = 0; y < fine.ny; ++y) for (int x = 0; x < fine.nx; ++x) { const std::size_t at = (static_cast(z / 2) * coarse.ny + y / 2) * coarse.nx + x / 2; - coarse.m[at] = std::max(coarse.m[at], fine.at(x, y, z)); + m[at] = std::max(m[at], fine.at(x, y, z)); } - return coarse; + return m; } // The lattice indices eval's corners can take for a coordinate anywhere in @@ -334,6 +348,7 @@ class RegionCeiling { const MaskDistance& md_; std::vector levels_; + std::vector> owned_; // levels 1.., which have no other home float slack_ = 0.0f; }; @@ -387,26 +402,34 @@ class ExtrudeFill { } for (std::size_t s = 0; s < count; ++s) project_brick(grid, s, out + s * field::kBrickSamples); + tally_.samples += count * field::kBrickSamples; } + const detail::MaskExtrudeTally& tally() const { return tally_; } + private: static constexpr int kSide = field::kBrickDim + 1; static constexpr int kStride[3] = {1, kSide, kSide * kSide}; - void project_brick(const field::FieldVolume::BrickGrid& grid, std::size_t s, - float* block) const { + void project_brick(const field::FieldVolume::BrickGrid& grid, std::size_t s, float* block) { // A brick whose every shell lies beyond the band is dropped by the // volume whatever the region says, because the stored value is never // below the shell, and all that survives of it is a sign the shell // already has. if (cull_ && std::all_of(block, block + field::kBrickSamples, - [&grid](float shell) { return shell > grid.band; })) + [&grid](float shell) { return shell > grid.band; })) { + ++tally_.bricks_beyond_band; return; + } const float* distance = distance_.data() + s * field::kBrickSamples; for (int i = 0; i < field::kBrickSamples; ++i) { const cfloat3 p = grid.sample_position(first_ + s, i); - if (cull_ && shell_decides(block[i], ceiling_.above(p, std::abs(distance[i])), round_)) + if (cull_ && shell_decides(block[i], ceiling_.above(p, std::abs(distance[i])), round_)) { + ++tally_.bound_skips; + if (std::abs(block[i]) <= grid.band) ++tally_.bound_skips_in_band; continue; + } + ++tally_.projected; const cfloat3 q = project_to_surface(p, gradient(grid, s, i), distance[i]); block[i] = extrude_value(block[i], md_.eval(q), round_); } @@ -448,14 +471,12 @@ class ExtrudeFill { return distance_[brick_at * field::kBrickSamples + static_cast(i + (there - local[axis]) * kStride[axis])]; } - // Outside the window: the source, at the expression sample_position - // uses, so the value is the one the neighbouring brick's fill takes. + // Outside the window: the source, at the position the neighbouring + // brick's own fill is handed, through the one function that makes it. int g[3]; grid.sample_cell(slot, i, g); g[axis] += step; - return source_(grid.origin + cf3(static_cast(g[0]), static_cast(g[1]), - static_cast(g[2])) * - grid.cell_size); + return source_(grid.cell_position(g)); } const std::function& source_; @@ -467,6 +488,7 @@ class ExtrudeFill { bool cull_; std::size_t first_ = 0, count_ = 0; std::vector distance_; // the window's source distances, brick-major + detail::MaskExtrudeTally tally_; }; } // namespace @@ -492,7 +514,8 @@ namespace { std::optional extrude_field(const std::function& source, const voxel::MaskField& mask, const MaskExtrudeSettings& settings, - parallel::CancelToken* token, bool cull) { + parallel::CancelToken* token, bool cull, + detail::MaskExtrudeTally* tally) { if (!(settings.thickness > 0.0f) || mask.empty() || !source) return std::nullopt; const float cell = settings.cell_size > 0.0f ? settings.cell_size : mask.cell_size(); if (!(cell > 0.0f)) return std::nullopt; @@ -516,9 +539,9 @@ std::optional extrude_field(const std::function extrude_field(const std::functionbounds(), cell, band, token, &cancelled); + if (tally) *tally = fill.tally(); // A cancel and "the mask never reached the surface" both return nullopt, // and a host must not be shown the second when the user did the first — @@ -556,15 +580,15 @@ std::optional mask_extrude(const std::function mask_extrude_unculled( +std::optional mask_extrude_field( const std::function& source, const voxel::MaskField& mask, - const MaskExtrudeSettings& settings) { - return extrude_field(source, mask, settings, nullptr, false); + const MaskExtrudeSettings& settings, bool cull, MaskExtrudeTally* tally) { + return extrude_field(source, mask, settings, nullptr, cull, tally); } } // namespace detail diff --git a/src/brush/mask_extrude_internal.h b/src/brush/mask_extrude_internal.h new file mode 100644 index 00000000..351ea6bf --- /dev/null +++ b/src/brush/mask_extrude_internal.h @@ -0,0 +1,35 @@ +// Mask extrude internals, for tests only. Not installed and not part of the +// library's API: hosts call brush::mask_extrude. +#pragma once + +#include +#include +#include + +#include "clay/brush/mask_extrude.h" + +namespace clay { +namespace brush { +namespace detail { + +// What the field extrude's fill did, counted over every lattice sample it +// visited. The skip is exact, so a volume cannot show whether it fired; this +// can. +struct MaskExtrudeTally { + std::size_t samples = 0; // lattice samples visited + std::size_t bricks_beyond_band = 0; // bricks skipped whole: every shell past the band + std::size_t bound_skips = 0; // samples skipped because the shell beat the bound + std::size_t bound_skips_in_band = 0; // ...of which the volume stores (|shell| <= band) + std::size_t projected = 0; // samples that were projected onto the source +}; + +// The field extrude with the skip on (`cull`) or off. Off projects every +// sample: the reference the culled extrude must match bit for bit. `tally`, +// when given, receives the counts above. +std::optional mask_extrude_field( + const std::function& source, const voxel::MaskField& mask, + const MaskExtrudeSettings& settings, bool cull, MaskExtrudeTally* tally = nullptr); + +} // namespace detail +} // namespace brush +} // namespace clay diff --git a/src/field/volume.cpp b/src/field/volume.cpp index 93411879..830e9d6b 100644 --- a/src/field/volume.cpp +++ b/src/field/volume.cpp @@ -103,9 +103,13 @@ cfloat3 FieldVolume::BrickGrid::sample_position(std::size_t slot, int i) const { // is how that stops being true. int g[3]; sample_cell(slot, i, g); - return origin + - cf3(static_cast(g[0]), static_cast(g[1]), static_cast(g[2])) * - cell_size; + return cell_position(g); +} + +cfloat3 FieldVolume::BrickGrid::cell_position(const int cell[3]) const { + return origin + cf3(static_cast(cell[0]), static_cast(cell[1]), + static_cast(cell[2])) * + cell_size; } void FieldVolume::BrickGrid::sample_positions(std::size_t first, std::size_t count, @@ -119,10 +123,8 @@ void FieldVolume::BrickGrid::sample_positions(std::size_t first, std::size_t cou for (int x = 0; x <= kBrickDim; ++x) { // Add in integer cell space before converting, as the scalar // path does. A rounded world-space brick origin is not exact. - const cfloat3 p = - origin + cf3(static_cast(base[0] + x), - static_cast(base[1] + y), - static_cast(base[2] + z)) * cell_size; + const int cell[3] = {base[0] + x, base[1] + y, base[2] + z}; + const cfloat3 p = cell_position(cell); out_xyz[at++] = p.x; out_xyz[at++] = p.y; out_xyz[at++] = p.z; diff --git a/tests/unit/test_mask_extrude.cpp b/tests/unit/test_mask_extrude.cpp index c06487c7..a6ea27a7 100644 --- a/tests/unit/test_mask_extrude.cpp +++ b/tests/unit/test_mask_extrude.cpp @@ -8,8 +8,10 @@ #include #include #include +#include #include +#include "../../src/brush/mask_extrude_internal.h" #include "clay/brush/mask_extrude.h" #include "clay/field/volume.h" #include "clay/voxel/grid.h" @@ -687,6 +689,50 @@ MaskExtrudeSettings with(float thickness, ExtrudeSide side, float round, float c return s; } +// A fixture tight on the bound's REACH. The bound must cover every point the +// projection can land on, |distance| from the sample; one that looked only half +// as far would pass most fixtures. This one has a masked volume BURIED under +// the surface: it starts `top` below it, runs to near the centre under a +// 0.6-radius column, and nothing is painted on the surface above it. A sample +// D deep in an inward wall of thickness t (shell D - t once D > t/2) sits +// inside that volume. Everything within D/2 of it reads as masked once +// 1.5 D > t + top, so a half-reach bound skips it. But its projection lands on +// the unmasked surface D above, where the region decides the stored value. A +// small cap painted on the surface elsewhere keeps the extrude from being +// refused. +// +// Catching the half-reach error at all depends on lattice alignment. The +// pyramid reads up to twice the span it is asked for, which absorbs most of a +// halved reach. A quarter reach fails on most variants; a half reach fails on +// some. The two variants in identity_cases are ones measured to fail at half +// reach. +constexpr float kBuriedRadius = 1.2f; + +MaskField buried_mask(float top, float cell) { + MaskField m(cell); + // The column runs along +Y, a lattice axis: the bound reads an axis-aligned + // box, and a diagonal column would let the box's corners reach the + // unmasked surface early and hide the defect this fixture exists for. + const cfloat3 column = cf3(0.0f, 1.0f, 0.0f); + const cfloat3 cap_pole = cf3(-1.0f, 0.0f, 0.0f) * kBuriedRadius; + const auto to_cell = [cell](float w) { return static_cast(std::floor(w / cell)); }; + const std::int32_t n = to_cell(kBuriedRadius) + 2; + for (std::int32_t z = -n; z <= n; ++z) + for (std::int32_t y = -n; y <= n; ++y) + for (std::int32_t x = -n; x <= n; ++x) { + const cfloat3 c = cf3(static_cast(x) + 0.5f, static_cast(y) + 0.5f, + static_cast(z) + 0.5f) * + cell; + const float depth = kBuriedRadius - kernel::clength(c); + const float along = kernel::cdot(c, column); + const float lateral = kernel::clength(c - column * along); + const bool buried = depth >= top && depth <= 1.15f && along > 0.0f && lateral <= 0.6f; + const bool cap = kernel::clength(c - cap_pole) <= 0.25f && std::abs(depth) <= 0.06f; + if (buried || cap) m.set({x, y, z}, 1.0f); + } + return m; +} + std::vector identity_cases() { std::vector cases; const MaskField cap = cap_mask(); @@ -710,6 +756,10 @@ std::vector identity_cases() { with(0.05f, ExtrudeSide::Outward, 0.0f, 0.04f)}); cases.push_back({"device shell rounded inward", sphere_field(kShellRadius), dabbed_shell_mask(), with(0.05f, ExtrudeSide::Inward, 0.02f, 0.04f)}); + cases.push_back({"buried mask, 0.6 inward wall", sphere_field(kBuriedRadius), + buried_mask(0.06f, 0.02f), with(0.6f, ExtrudeSide::Inward, 0.0f, 0.02f)}); + cases.push_back({"buried mask, 0.45 inward wall", sphere_field(kBuriedRadius), + buried_mask(0.06f, 0.04f), with(0.45f, ExtrudeSide::Inward, 0.0f, 0.04f)}); return cases; } @@ -717,12 +767,13 @@ std::vector identity_cases() { TEST_CASE("mask extrude: skipping the projection changes no stored bit") { for (const Case& c : identity_cases()) { - CAPTURE(c.name); + const std::string name = c.name; + CAPTURE(name); CAPTURE(static_cast(c.settings.side)); CAPTURE(c.settings.border_round); const std::optional fast = brush::mask_extrude(c.source, c.mask, c.settings); const std::optional reference = - brush::detail::mask_extrude_unculled(c.source, c.mask, c.settings); + brush::detail::mask_extrude_field(c.source, c.mask, c.settings, false); REQUIRE(fast.has_value() == reference.has_value()); if (!fast) continue; // The whole serialized volume: lattice, index, far bounds, every stored @@ -732,13 +783,63 @@ TEST_CASE("mask extrude: skipping the projection changes no stored bit") { } } +TEST_CASE("mask extrude: the skip fires where it is meant to") { + // The skip is exact, so a volume cannot show whether it ran; a skip that + // silently stopped firing would cost the device gate and pass every + // identity test. The tally says what the fill did, and the unculled + // reference must report no skips of either kind. + using brush::detail::MaskExtrudeTally; + const auto tally = [](const std::function& source, const MaskField& mask, + const MaskExtrudeSettings& s, bool cull) { + MaskExtrudeTally t; + REQUIRE(brush::detail::mask_extrude_field(source, mask, s, cull, &t).has_value()); + return t; + }; + + SUBCASE("bricks wholly beyond the band, on the device fixture") { + // The device's dabs are small, so its interior is thin and almost all + // of what is skipped there is whole bricks far from the wall. + const MaskField mask = dabbed_shell_mask(); + const MaskExtrudeSettings s = with(0.05f, ExtrudeSide::Outward, 0.0f, 0.04f); + const MaskExtrudeTally culled = tally(sphere_field(kShellRadius), mask, s, true); + const MaskExtrudeTally unculled = tally(sphere_field(kShellRadius), mask, s, false); + CAPTURE(culled.samples); + CAPTURE(culled.bricks_beyond_band); + CAPTURE(culled.projected); + CHECK(culled.bricks_beyond_band > 0); + // Most of the lattice never projects: that is where the time went back. + CHECK(static_cast(culled.projected) < 0.6 * static_cast(culled.samples)); + CHECK(unculled.bricks_beyond_band == 0); + CHECK(unculled.projected == unculled.samples); + CHECK(culled.samples == unculled.samples); + } + + SUBCASE("stored samples under the painted interior, on the sphere cap") { + // A wide painted cap: inside the wall and well in from the rim, the + // mask reads deeply inside everywhere the projection could land, so + // the shell beats the bound on samples the volume STORES. + const MaskField mask = cap_mask(); + const MaskExtrudeSettings s = with(0.12f, ExtrudeSide::Outward, 0.0f); + const MaskExtrudeTally culled = tally(sphere_field(), mask, s, true); + const MaskExtrudeTally unculled = tally(sphere_field(), mask, s, false); + CAPTURE(culled.bound_skips); + CAPTURE(culled.bound_skips_in_band); + CAPTURE(culled.projected); + // 631 here when written; an order of magnitude under it, so a change to + // the bound's slack does not trip it and a bound that stopped firing does. + CHECK(culled.bound_skips_in_band > 60); + CHECK(unculled.bound_skips == 0); + } +} + TEST_CASE("mask extrude: neighbouring bricks agree on every sample they share") { // The projection's gradient is a lattice difference, and a sample on a brick // face takes its outside neighbour from the source rather than from the // brick. Both bricks must still store the same bits there, or the halo that // makes a brick self-contained becomes a seam. for (const Case& c : identity_cases()) { - CAPTURE(c.name); + const std::string name = c.name; + CAPTURE(name); const std::optional v = brush::mask_extrude(c.source, c.mask, c.settings); REQUIRE(v.has_value()); const std::vector blob = v->to_blob();