Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/07-brushes-and-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -901,14 +901,19 @@ 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
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
Expand Down
8 changes: 7 additions & 1 deletion include/clay/brush/mask_extrude.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -119,6 +123,8 @@ std::optional<field::FieldVolume> mask_extrude(const std::function<float(kernel:
//
// The two agree to within a voxel, which is the point: what a document means
// must not depend on which representation it is stored in.
// The mask chooses surface seed cells; grown layers do not have to remain
// inside the painted mask volume.
//
// `cell_size` and `band` are ignored here — the grid's own resolution is the
// only one available. Neither the source nor the mask is modified.
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/honor-mask-extrude-thickness/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-29
3 changes: 3 additions & 0 deletions openspec/changes/honor-mask-extrude-thickness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# honor-mask-extrude-thickness

Extrude masked surface patches to the requested thickness independent of the mask volume
7 changes: 7 additions & 0 deletions openspec/changes/honor-mask-extrude-thickness/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## 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.

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.

## 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.
10 changes: 10 additions & 0 deletions openspec/changes/honor-mask-extrude-thickness/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Why
A painted mask is a thin volume around the source surface. Intersecting the extruded shell with that volume caps a requested wall at the paint depth. A 0.6-unit wall currently stops near 0.11 units.

## What Changes
- Evaluate the mask at the source surface under each sampled point so its footprint extends through the requested wall.
- Grow voxel extracts from masked surface cells for the requested number of layers, independent of mask depth.
- Measure wall height at multiple positions and thicknesses in regression tests.

## Impact
Mask extrudes produce their requested thickness for both field and voxel layers. Existing calls and saved document formats are unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## ADDED Requirements

### Requirement: Mask extrude thickness follows the surface
The library SHALL resolve a source field, a mask and a thickness into a new field holding only the masked patch of the source's surface, thickened. The mask SHALL select points on the source surface; its own depth away from that surface SHALL NOT cap the wall. The result SHALL be an ordinary sampled volume, so meshing, evaluation, picking, serialization and every backend apply to it unchanged.

#### Scenario: Thickness exceeds paint depth
- **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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## ADDED Requirements

### Requirement: Voxel mask extrude thickness follows the surface
A voxel layer SHALL extrude the masked cells of the source's surface by the requested amount in cell space. The mask SHALL select surface seeds; its own depth away from the surface SHALL NOT cap the grown wall. The new grid SHALL carry the source's colours, and the source and mask SHALL remain unchanged.

#### Scenario: Wall exceeds mask depth
- **WHEN** a voxel surface is masked in a thin painted band and extruded outward farther than the band reaches
- **THEN** the new wall reaches the requested thickness to within one voxel throughout the selected patch
6 changes: 6 additions & 0 deletions openspec/changes/honor-mask-extrude-thickness/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
## Implementation
- [x] Project field samples to the source surface before reading mask distance.
- [x] Let voxel extracts grow beyond the painted mask volume.
- [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.
92 changes: 52 additions & 40 deletions src/brush/mask_extrude.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,23 @@ int layers_for(float thickness, float cell) {
return std::max(static_cast<int>(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<float(cfloat3)>& 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 ----------------------------------------------------
Expand Down Expand Up @@ -283,8 +300,9 @@ std::optional<field::FieldVolume> mask_extrude(const std::function<float(cfloat3
bool cancelled = false;
field::FieldVolume out = field::FieldVolume::sample(
[&](cfloat3 p) {
const float shell = shell_of(source(p), settings.side, settings.thickness);
const float region = md->eval(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);
Expand Down Expand Up @@ -325,8 +343,8 @@ std::optional<voxel::VoxelGrid> mask_extrude(const voxel::VoxelGrid& grid,

std::optional<VoxelCoord> 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::int32_t>(std::floor(world / vs));
Expand Down Expand Up @@ -395,44 +413,38 @@ std::optional<voxel::VoxelGrid> 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<std::pair<VoxelCoord, std::uint8_t>> 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<std::pair<VoxelCoord, std::uint8_t>> 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<float>(dx), static_cast<float>(dy),
static_cast<float>(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<std::pair<VoxelCoord, std::uint8_t>> frontier = seeds;
for (int layer = 0; layer < out_layers; ++layer) {
std::vector<std::pair<VoxelCoord, std::uint8_t>> 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<float>(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<float>(layer) * vs);
if (grid.get(cell) == 0 && out.get(cell) == 0) out.set(cell, colour_of(idx));
}
}

Expand Down
85 changes: 84 additions & 1 deletion tests/unit/test_mask_extrude.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<FieldVolume> 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<float> 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();
Expand Down Expand Up @@ -315,6 +350,22 @@ TEST_CASE("mask extrude: a plate comes off a voxel ball") {
CHECK(plate->get({0, static_cast<std::int32_t>(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<VoxelGrid> plate =
brush::mask_extrude(source, mask, plate_settings(thickness));
REQUIRE(plate.has_value());
const std::optional<VoxelCoord> top = plate->bounds_max();
REQUIRE(top.has_value());
const float height = (static_cast<float>(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();
Expand Down Expand Up @@ -375,6 +426,38 @@ TEST_CASE("mask extrude: the two representations agree") {
CHECK(static_cast<float>(agreeing) / static_cast<float>(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<VoxelGrid> voxels = brush::mask_extrude(grid, mask, settings);
const std::optional<FieldVolume> 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<float>(x) + 0.5f,
static_cast<float>(y) + 0.5f,
static_cast<float>(z) + 0.5f) * cell;
if (field->eval(point) < cell) ++agreeing;
}
REQUIRE(total > 0);
CAPTURE(total);
CAPTURE(agreeing);
CHECK(static_cast<float>(agreeing) / static_cast<float>(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());
Expand Down Expand Up @@ -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.
Expand Down
Loading