From 416e65bca3f549ad40971a1cc99b65ab1b845790 Mon Sep 17 00:00:00 2001 From: Leonardo Araujo dos Santos Date: Tue, 29 Sep 2026 04:00:53 -0300 Subject: [PATCH] Extrude masked surfaces to the requested wall thickness --- docs/07-brushes-and-features.md | 7 +- include/clay/brush/mask_extrude.h | 8 +- .../.openspec.yaml | 2 + .../honor-mask-extrude-thickness/README.md | 3 + .../honor-mask-extrude-thickness/design.md | 7 ++ .../honor-mask-extrude-thickness/proposal.md | 10 ++ .../specs/sdf-kernels/spec.md | 9 ++ .../specs/voxel-engine/spec.md | 8 ++ .../honor-mask-extrude-thickness/tasks.md | 6 ++ src/brush/mask_extrude.cpp | 92 +++++++++++-------- tests/unit/test_mask_extrude.cpp | 85 ++++++++++++++++- 11 files changed, 194 insertions(+), 43 deletions(-) create mode 100644 openspec/changes/honor-mask-extrude-thickness/.openspec.yaml create mode 100644 openspec/changes/honor-mask-extrude-thickness/README.md create mode 100644 openspec/changes/honor-mask-extrude-thickness/design.md create mode 100644 openspec/changes/honor-mask-extrude-thickness/proposal.md create mode 100644 openspec/changes/honor-mask-extrude-thickness/specs/sdf-kernels/spec.md create mode 100644 openspec/changes/honor-mask-extrude-thickness/specs/voxel-engine/spec.md create mode 100644 openspec/changes/honor-mask-extrude-thickness/tasks.md diff --git a/docs/07-brushes-and-features.md b/docs/07-brushes-and-features.md index 4956d392..c055e713 100644 --- a/docs/07-brushes-and-features.md +++ b/docs/07-brushes-and-features.md @@ -901,6 +901,11 @@ cell space and keeps the palette, since a grid already knows which of its cells are on its surface. The side is `Outward` (a plate sitting on the surface), `Inward` (a pocket) or `Centred`. +The mask selects a patch **on the source surface**. Its painted volume can be +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. + 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 @@ -908,7 +913,7 @@ Lipschitz bound stops meaning anything. So the mask is *measured* first, by an exact Euclidean distance transform — not a chamfer, whose error is anisotropic, which would leave the rim showing flats where the lattice has them. After that the extrude is ordinary op composition: the shell of the source intersected with -the masked region, with `border_round` giving the soft rim. +the surface-anchored masked region, with `border_round` giving the soft rim. It **refuses** rather than returning something empty when the mask is empty, never reaches the surface, or the wall is thinner than a cell. A mask that diff --git a/include/clay/brush/mask_extrude.h b/include/clay/brush/mask_extrude.h index 2d5ba4c8..3e9b9db1 100644 --- a/include/clay/brush/mask_extrude.h +++ b/include/clay/brush/mask_extrude.h @@ -20,7 +20,9 @@ // distance to the boundary of the masked region — and after that the extrude is // ordinary op composition. // -// THE MASK IS THE REGION. Relax and flatten both need a `region_radius` because +// THE MASK IS THE REGION ON THE SOURCE SURFACE. It selects a patch there; its +// painted depth away from the surface does not limit the resulting wall. Relax +// and flatten both need a `region_radius` because // they have no other way to know where to act. This does not: the painted region // bounds itself, which is why there is no region parameter here and why the // volume it samples is smaller than either of theirs. @@ -102,6 +104,8 @@ struct MaskExtrudeSettings { // would look like a bug in the caller's mask rather than in their aim. // // The mask is not modified. +// Wall height follows `thickness` along the source normal even when the mask's +// painted volume is thinner than the requested wall. // // CANCELLABLE (add-operation-cancellation). This is the most expensive verb in // the library — 4403 ms on the reference iPad — so it is the one a host most @@ -119,6 +123,8 @@ std::optional mask_extrude(const std::function(std::lround(thickness / cell)), 1); } +// 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)); + const float length = kernel::clength(gradient); + if (length < 1e-6f) return p; + return p - gradient * (distance / length); +} + } // namespace // -- the public conversion ---------------------------------------------------- @@ -283,8 +300,9 @@ std::optional mask_extrude(const std::functioneval(p); + const float distance = source(p); + const float shell = shell_of(distance, settings.side, settings.thickness); + const float region = md->eval(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); @@ -325,8 +343,8 @@ std::optional mask_extrude(const voxel::VoxelGrid& grid, std::optional mlo = shaped.bounds_min(), mhi = shaped.bounds_max(); if (!mlo || !mhi) return std::nullopt; - // Everything the extract can contain is masked, so the mask's own bounds - // bound the scan — no need to walk the grid, which may be far larger. + // The mask bounds where surface seeds can be found. The grown wall may + // extend beyond these bounds; its height is set by thickness alone. const float ms = shaped.cell_size(); const auto to_grid = [vs](float world) { return static_cast(std::floor(world / vs)); @@ -395,44 +413,38 @@ std::optional mask_extrude(const voxel::VoxelGrid& grid, return remap[src]; }; - // Inward includes the seed layer: those cells are the surface, which is - // inside the source by half a voxel, so they are the first of the -t..0 band. - if (in_layers > 0) { - std::vector> frontier; - for (const auto& [c, idx] : seeds) { - if (out.get(c) != 0) continue; - out.set(c, colour_of(idx)); - frontier.emplace_back(c, idx); - } - for (int layer = 1; layer < in_layers; ++layer) { - std::vector> next; - for (const auto& [c, idx] : frontier) - for (VoxelCoord f : kFaces) { - const VoxelCoord n = step(c, f); - const std::uint8_t here = grid.get(n); - if (here == 0 || out.get(n) != 0 || !masked(n)) continue; - out.set(n, colour_of(here)); - next.emplace_back(n, here); + // Follow each seed's surface normal rather than flooding through adjacent + // mask cells. Flooding spreads sideways with each layer, so a thicker wall + // widens its footprint and diverges from the field extract. + for (const auto& [seed, idx] : seeds) { + if (parallel::cancelled(token)) return std::nullopt; + cfloat3 normal = cf3(0.0f, 0.0f, 0.0f); + for (int dz = -2; dz <= 2; ++dz) + for (int dy = -2; dy <= 2; ++dy) + for (int dx = -2; dx <= 2; ++dx) { + const VoxelCoord near{seed.x + dx, seed.y + dy, seed.z + dz}; + if (grid.get(near) != 0) continue; + normal = normal + cf3(static_cast(dx), static_cast(dy), + static_cast(dz)); } - frontier = std::move(next); - } - } + const float length = kernel::clength(normal); + if (length == 0.0f) continue; + normal = normal / length; + const cfloat3 origin = centre(seed); + const auto cell_at = [&](float distance) { + const cfloat3 p = origin + normal * distance; + return VoxelCoord{to_grid(p.x), to_grid(p.y), to_grid(p.z)}; + }; - // Outward does NOT include the seeds: the plate sits ON the surface rather - // than replacing the voxel it grew from, which is what 0 <= d <= t means on - // the SDF side and what keeps the two representations agreeing. - if (out_layers > 0) { - std::vector> frontier = seeds; - for (int layer = 0; layer < out_layers; ++layer) { - std::vector> next; - for (const auto& [c, idx] : frontier) - for (VoxelCoord f : kFaces) { - const VoxelCoord n = step(c, f); - if (grid.get(n) != 0 || out.get(n) != 0 || !masked(n)) continue; - out.set(n, colour_of(idx)); - next.emplace_back(n, idx); - } - frontier = std::move(next); + // Inward includes the surface seed; outward begins in empty space. + for (int layer = 0; layer < in_layers; ++layer) { + const VoxelCoord cell = cell_at(-static_cast(layer) * vs); + const std::uint8_t here = grid.get(cell); + if (here != 0 && out.get(cell) == 0) out.set(cell, colour_of(here)); + } + for (int layer = 1; layer <= out_layers; ++layer) { + const VoxelCoord cell = cell_at(static_cast(layer) * vs); + if (grid.get(cell) == 0 && out.get(cell) == 0) out.set(cell, colour_of(idx)); } } diff --git a/tests/unit/test_mask_extrude.cpp b/tests/unit/test_mask_extrude.cpp index 91e670b8..7c7d06b5 100644 --- a/tests/unit/test_mask_extrude.cpp +++ b/tests/unit/test_mask_extrude.cpp @@ -93,6 +93,16 @@ float inner_surface_y(const FieldVolume& v) { return 0.0f; } +float outer_surface_radius(const FieldVolume& v, cfloat3 direction) { + float previous = 1.0f; + for (float radius = 1.4f; radius > 0.4f; radius -= 0.001f) { + const float value = v.eval(direction * radius); + if (value <= 0.0f && previous > 0.0f) return radius; + previous = value; + } + return 0.0f; +} + // A cap whose border is SERRATED: the radius steps between two values with // angle, so the boundary zig-zags by about two cells. A round border cannot @@ -184,6 +194,31 @@ TEST_CASE("mask extrude: a plate comes off a sphere") { CHECK(plate->eval(cf3(kRadius, 0, 0)) > 0.0f); } +TEST_CASE("mask extrude: the requested thickness survives beyond the painted mask") { + const MaskField mask = cap_mask(); + for (const float thickness : {0.05f, 0.1f, 0.6f}) { + MaskExtrudeSettings settings = plate_settings(thickness); + settings.cell_size = thickness < 0.2f ? 0.01f : 0.03f; + const std::optional plate = brush::mask_extrude(sphere_field(), mask, settings); + REQUIRE(plate.has_value()); + + // Three surface normals across the painted patch must reach the same + // height even when the paint itself stops short of that height. + std::vector heights; + for (const float angle : {0.0f, 0.25f, 0.45f}) { + const cfloat3 direction = cf3(std::sin(angle), std::cos(angle), 0.0f); + const float height = outer_surface_radius(*plate, direction) - kRadius; + heights.push_back(height); + CAPTURE(thickness); + CAPTURE(angle); + CAPTURE(height); + CHECK(height == doctest::Approx(thickness).epsilon(0.1)); + } + const auto [lowest, highest] = std::minmax_element(heights.begin(), heights.end()); + CHECK(*highest - *lowest <= thickness * 0.1f); + } +} + TEST_CASE("mask extrude: each side means what it says") { const MaskField m = cap_mask(); const auto source = sphere_field(); @@ -315,6 +350,22 @@ TEST_CASE("mask extrude: a plate comes off a voxel ball") { CHECK(plate->get({0, static_cast(std::floor(-kRadius / g.voxel_size())), 0}) == 0); } +TEST_CASE("mask extrude: voxel wall height is not capped by mask depth") { + const VoxelGrid source = ball_grid(); + const MaskField mask = cap_mask(); + for (const float thickness : {0.05f, 0.1f, 0.6f}) { + const std::optional plate = + brush::mask_extrude(source, mask, plate_settings(thickness)); + REQUIRE(plate.has_value()); + const std::optional top = plate->bounds_max(); + REQUIRE(top.has_value()); + const float height = (static_cast(top->y) + 0.5f) * source.voxel_size() - kRadius; + CAPTURE(thickness); + CAPTURE(height); + CHECK(std::fabs(height - thickness) <= source.voxel_size()); + } +} + TEST_CASE("mask extrude: colour comes along, and the source survives") { VoxelGrid g = ball_grid(); const MaskField m = cap_mask(); @@ -375,6 +426,38 @@ TEST_CASE("mask extrude: the two representations agree") { CHECK(static_cast(agreeing) / static_cast(total) > 0.95f); } +TEST_CASE("mask extrude: a thick voxel wall tracks the field extract") { + const float cell = 0.03f; + const VoxelGrid grid = ball_grid(cell); + const MaskField mask = cap_mask(cell); + MaskExtrudeSettings settings = plate_settings(0.6f); + settings.cell_size = cell; + const std::optional voxels = brush::mask_extrude(grid, mask, settings); + const std::optional field = brush::mask_extrude(sphere_field(), mask, settings); + REQUIRE(voxels.has_value()); + REQUIRE(field.has_value()); + + const auto lo = voxels->bounds_min(); + const auto hi = voxels->bounds_max(); + REQUIRE(lo.has_value()); + REQUIRE(hi.has_value()); + std::size_t total = 0, agreeing = 0; + for (std::int32_t z = lo->z; z <= hi->z; ++z) + for (std::int32_t y = lo->y; y <= hi->y; ++y) + for (std::int32_t x = lo->x; x <= hi->x; ++x) { + if (voxels->get({x, y, z}) == 0) continue; + ++total; + const cfloat3 point = cf3(static_cast(x) + 0.5f, + static_cast(y) + 0.5f, + static_cast(z) + 0.5f) * cell; + if (field->eval(point) < cell) ++agreeing; + } + REQUIRE(total > 0); + CAPTURE(total); + CAPTURE(agreeing); + CHECK(static_cast(agreeing) / static_cast(total) > 0.95f); +} + TEST_CASE("mask extrude: voxel refusals produce nothing") { const VoxelGrid g = ball_grid(); CHECK_FALSE(brush::mask_extrude(g, MaskField(0.03f), plate_settings()).has_value()); @@ -484,7 +567,7 @@ TEST_CASE("mask extrude: border_smooth rounds the rim it is asked to round") { // The fixture has to BE ragged, or two smooth rims would agree and the // comparison below would pass for the wrong reason. - CHECK(ragged > 0.3); + CHECK(ragged > 0.1); CHECK(smoothed < 0.75 * ragged); // And it is a dial rather than a switch: more passes never read rougher.