diff --git a/bindings/c/clay.h b/bindings/c/clay.h index c685bf4c..c70c4dd2 100644 --- a/bindings/c/clay.h +++ b/bindings/c/clay.h @@ -4931,8 +4931,14 @@ clay_result clay_mesh_concat(const clay_mesh* const* meshes, size_t count, * * The attribute-drop rule of clay_mesh_concat applies: the meshed field * carries normals, so a mesh layer without them costs the result its normals. - * A document with no visible mesh layer returns exactly what - * clay_document_mesh would. Free the result with clay_mesh_destroy. */ + * + * AN EMPTY FIELD CONTRIBUTES NOTHING when a mesh layer is visible: every SDF + * layer hidden, none at all, or every surface group hidden, and the result is + * the placed mesh layers alone, where clay_document_mesh would refuse. A + * document with no visible mesh layer returns exactly what clay_document_mesh + * would, its refusals included -- so a document with nothing visible at all is + * still CLAY_ERROR_INVALID_ARGUMENT. `params` is validated either way. Free the + * result with clay_mesh_destroy. */ clay_result clay_document_mesh_combined(const clay_document* doc, const clay_mesh_params* params, clay_mesh** out_mesh); diff --git a/bindings/c/clay_c.cpp b/bindings/c/clay_c.cpp index 7eefe372..8140b2bb 100644 --- a/bindings/c/clay_c.cpp +++ b/bindings/c/clay_c.cpp @@ -10081,16 +10081,13 @@ clay_result clay_layer_selection_bounds(const clay_document* doc, clay_layer_id out_max, out_has_bounds); } -// The mesher, over whatever tape the caller has. Shared by the whole-document -// form and the one-layer form so the two cannot drift on the resolution -// ceiling, the hidden-group drop or the decimation. -clay_result mesh_tape(const clay_document* doc, const scene::Tape& tape, - const clay_mesh_params& p, const char* empty_what, clay_mesh** out_mesh) { - clay_result r = CLAY_OK; - if (tape.empty()) return fail(CLAY_ERROR_INVALID_ARGUMENT, empty_what); - math::Aabb region = tape.bounds; - if (region.empty() || region.is_infinite()) - return fail(CLAY_ERROR_INVALID_ARGUMENT, "unbounded scene"); +namespace { + +// The voxel size a meshing call will sample at: the caller's, or the region's +// longest side over the resolution. Refused when it is not a size or would +// price a grid past the mesher's ceiling. +clay_result price_mesh_voxel(const math::Aabb& region, const clay_mesh_params& p, + float* out_voxel) { float voxel = p.voxel_size; if (voxel <= 0) { int res = p.resolution > 0 ? p.resolution : 128; @@ -10115,6 +10112,37 @@ clay_result mesh_tape(const clay_document* doc, const scene::Tape& tape, "the requested resolution needs more than " + std::to_string(mesh::kMaxGridSamples) + " grid samples"); } + *out_voxel = voxel; + return CLAY_OK; +} + +} // namespace + +// What an empty field means to the caller. The standalone meshing calls refuse +// it: they were asked for a surface and there is none. The combined export +// does not, when there are mesh layers beside the field: there an empty field +// contributes nothing, and *out_mesh comes back null with CLAY_OK. +enum class EmptyField { Refuse, ContributesNothing }; + +// The mesher, over whatever tape the caller has. Shared by the whole-document +// form, the one-layer form and the combined export so they cannot drift on the +// resolution ceiling, the hidden-group drop or the decimation. +clay_result mesh_tape(const clay_document* doc, const scene::Tape& tape, + const clay_mesh_params& p, const char* empty_what, clay_mesh** out_mesh, + EmptyField empty = EmptyField::Refuse) { + clay_result r = CLAY_OK; + const bool empty_ok = empty == EmptyField::ContributesNothing; + if (tape.empty() && empty_ok) { + *out_mesh = nullptr; + return CLAY_OK; + } + if (tape.empty()) return fail(CLAY_ERROR_INVALID_ARGUMENT, empty_what); + math::Aabb region = tape.bounds; + if (region.empty() || region.is_infinite()) + return fail(CLAY_ERROR_INVALID_ARGUMENT, "unbounded scene"); + float voxel = 0.0f; + r = price_mesh_voxel(region, p, &voxel); + if (r != CLAY_OK) return r; mesh::Mesh m; r = mesh_with(p.mesher, p.experimental != 0, tape, region, voxel, &m); if (r != CLAY_OK) return r; @@ -10122,6 +10150,11 @@ clay_result mesh_tape(const clay_document* doc, const scene::Tape& tape, // nothing is hidden, so a document that never named a region meshes to the // bytes it always did. if (doc->doc.groups) voxel::drop_hidden(m, *doc->doc.groups); + // Every surface group hidden is the same "nothing" as an empty tape. + if (m.empty() && empty_ok) { + *out_mesh = nullptr; + return CLAY_OK; + } if (m.empty()) return fail(CLAY_ERROR_BACKEND, "meshing produced no triangles"); // The report is taken on EVERY decimated call rather than on request: a // caller cannot ask for it in advance -- clay_mesh_params predates it and @@ -11740,6 +11773,45 @@ mesh::Mesh concat_meshes(const std::vector& parts) { return out; } +// A mesh layer's triangles under its own layer transform, scale axes included. +mesh::Mesh place_mesh_layer(const scene::Layer& layer, mesh::Mesh m) { + const kernel::cfloat3 axes = layer.scale_axes; + for (kernel::cfloat3& v : m.positions) + v = layer.xform.apply(kernel::cf3(v.x * axes.x, v.y * axes.y, v.z * axes.z)); + // Normals through the INVERSE TRANSPOSE of the linear part, which for + // rotation-times-diagonal is the rotation times the reciprocal scale — + // the same rule and the same code shape clay_mesh_transform_nonuniform + // states. Rotating a normal is right for a similarity and wrong for a + // squash: it tilts every normal off the surface and takes the shading + // with it. + const bool squashed = scene::layer_is_squashed(layer); + for (kernel::cfloat3& n : m.normals) { + if (!squashed) { + n = layer.xform.rotation.rotate(n); + continue; + } + kernel::cfloat3 t = layer.xform.rotation.rotate( + kernel::cf3(n.x / axes.x, n.y / axes.y, n.z / axes.z)); + const float len2 = kernel::cdot2(t); + n = len2 > 0.0f ? t / kernel::csqrt(len2) : n; + } + return m; +} + +// Every VISIBLE mesh layer, placed, in stack order. Hidden means contributes +// nothing. Ghost and lock deliberately do NOT filter here: neither changes what +// a document evaluates to, so neither may change what it exports. +std::vector place_visible_mesh_layers(const clay_document& doc) { + std::vector placed; + for (const scene::Layer& layer : doc.doc.document.layers) { + if (layer.kind != scene::LayerKind::Mesh || !layer.visible) continue; + auto it = doc.doc.mesh_layers.find(layer.id); + if (it == doc.doc.mesh_layers.end()) continue; + placed.push_back(place_mesh_layer(layer, it->second)); + } + return placed; +} + } // namespace extern "C" { @@ -11844,54 +11916,27 @@ clay_result clay_document_mesh_combined(const clay_document* doc, if (!doc || !params || !out_mesh) return fail(CLAY_ERROR_INVALID_ARGUMENT, "null argument"); *out_mesh = nullptr; - // The field first, through the untouched call, so a document with no - // visible mesh layer gets exactly what clay_document_mesh gives. + // The mesh layers first, because whether there are any decides what an + // empty field means. With none visible this is exactly clay_document_mesh, + // its refusals included. + std::vector placed = place_visible_mesh_layers(*doc); + if (placed.empty()) return clay_document_mesh(doc, params, out_mesh); + + // With some, an empty field -- every SDF layer hidden, none at all, or + // every surface group hidden -- contributes nothing rather than vetoing the + // export of the layers beside it (#662). + clay_mesh_params p; + clay_result r = read_desc(params, kMeshParamsOriginal, &p); + if (r != CLAY_OK) return r; + std::shared_ptr tape_ref = doc->tape(); clay_mesh* field = nullptr; - clay_result r = clay_document_mesh(doc, params, &field); + r = mesh_tape(doc, *tape_ref, p, "empty document", &field, + EmptyField::ContributesNothing); if (r != CLAY_OK) return r; - std::vector placed; - for (const scene::Layer& layer : doc->doc.document.layers) { - if (layer.kind != scene::LayerKind::Mesh) continue; - // Hidden means contributes nothing. Ghost and lock deliberately do - // NOT filter here: neither changes what a document evaluates to, so - // neither may change what it exports. - if (!layer.visible) continue; - auto it = doc->doc.mesh_layers.find(layer.id); - if (it == doc->doc.mesh_layers.end()) continue; - - mesh::Mesh m = it->second; - const kernel::cfloat3 axes = layer.scale_axes; - for (kernel::cfloat3& v : m.positions) - v = layer.xform.apply(kernel::cf3(v.x * axes.x, v.y * axes.y, v.z * axes.z)); - // Normals through the INVERSE TRANSPOSE of the linear part, which for - // rotation-times-diagonal is the rotation times the reciprocal scale — - // the same rule and the same code shape clay_mesh_transform_nonuniform - // states. Rotating a normal is right for a similarity and wrong for a - // squash: it tilts every normal off the surface and takes the shading - // with it. - const bool squashed = scene::layer_is_squashed(layer); - for (kernel::cfloat3& n : m.normals) { - if (!squashed) { - n = layer.xform.rotation.rotate(n); - continue; - } - kernel::cfloat3 t = layer.xform.rotation.rotate( - kernel::cf3(n.x / axes.x, n.y / axes.y, n.z / axes.z)); - const float len2 = kernel::cdot2(t); - n = len2 > 0.0f ? t / kernel::csqrt(len2) : n; - } - placed.push_back(std::move(m)); - } - - if (placed.empty()) { - *out_mesh = field; - return CLAY_OK; - } - std::vector parts; parts.reserve(placed.size() + 1); - parts.push_back(&field->data); + if (field) parts.push_back(&field->data); for (const mesh::Mesh& m : placed) parts.push_back(&m); auto* handle = new clay_mesh(); diff --git a/docs/08-mesh-readback.md b/docs/08-mesh-readback.md index 77b9a0d9..840cc759 100644 --- a/docs/08-mesh-readback.md +++ b/docs/08-mesh-readback.md @@ -122,7 +122,7 @@ form at three target counts, with requested against actual printed for each. | | Python | C | C++ | |---|---|---|---| | mesh the field | `doc.mesh(...)` | `clay_document_mesh` | `mesh::mesh_tape(compile_document(doc), …)` | -| field + visible mesh layers | — | `clay_document_mesh_combined` | compose by hand | +| field + visible mesh layers (an empty field contributes nothing) | — | `clay_document_mesh_combined` | compose by hand | | load a file (.obj/.ply/.fbx) | `clay.load_mesh(path)` | `clay_mesh_load` | `io::load_obj_file` / `load_ply_file` / `load_fbx_file` | | a mesh layer's triangles | `doc.mesh_layer(name)` | `clay_document_mesh_layer`, `clay_document_mesh_layer_by_id` | `io::ClaySpaceDoc::mesh_layers` | | a voxel grid | `grid.mesh()` | `clay_voxel_mesh` | `VoxelGrid::mesh_greedy()` | diff --git a/openspec/changes/export-mesh-layers-without-a-field/.openspec.yaml b/openspec/changes/export-mesh-layers-without-a-field/.openspec.yaml new file mode 100644 index 00000000..f98e139f --- /dev/null +++ b/openspec/changes/export-mesh-layers-without-a-field/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-04 diff --git a/openspec/changes/export-mesh-layers-without-a-field/README.md b/openspec/changes/export-mesh-layers-without-a-field/README.md new file mode 100644 index 00000000..a0220751 --- /dev/null +++ b/openspec/changes/export-mesh-layers-without-a-field/README.md @@ -0,0 +1,3 @@ +# export-mesh-layers-without-a-field + +`clay_document_mesh_combined` exports the visible mesh layers of a document whose field is empty, instead of returning the field's "empty document" refusal (#662). diff --git a/openspec/changes/export-mesh-layers-without-a-field/design.md b/openspec/changes/export-mesh-layers-without-a-field/design.md new file mode 100644 index 00000000..299b3e37 --- /dev/null +++ b/openspec/changes/export-mesh-layers-without-a-field/design.md @@ -0,0 +1,25 @@ +## Context + +`clay_document_mesh_combined` is documented as "mesh the field, then append every visible mesh layer". It was implemented literally: field first, and any failure of the field returned. The field's failures are right for `clay_document_mesh`, which was asked for a surface and has none to give, and wrong for the combined call when the mesh layers are the surface. + +## Decisions + +### Gather the mesh layers first + +Whether any mesh layer is visible decides what an empty field means, so the layers are placed before the field is meshed. With none visible the call delegates to `clay_document_mesh` unchanged, which keeps the header's "returns exactly what clay_document_mesh would" literally true, refusals included. + +### An `EmptyField` mode on `mesh_tape`, not a second mesher path + +The field can come out empty in two places inside `mesh_tape`: an empty tape (refused before any grid is priced) and a mesh that `drop_hidden` filtered to no triangles (every surface group hidden). Both must read as "nothing" for the combined call, and both must stay refusals for `clay_document_mesh` and `clay_document_mesh_sdf_layer`. A mode argument, defaulting to `Refuse`, keeps the one shared mesher -- resolution ceiling, hidden-group drop, decimation -- rather than duplicating its front half. + +Rejected: calling `clay_document_mesh` and treating its errors as "empty". `CLAY_ERROR_INVALID_ARGUMENT` also means a malformed `clay_mesh_params` or an over-fine voxel size, and `CLAY_ERROR_BACKEND` also means a mesher failure; swallowing either would turn a real error into a silent export of the mesh layers alone. + +### Parameters are still validated + +`read_desc` runs before the field is considered, so a malformed parameter block is refused even when the field is empty and would not have used it. An unbounded but non-empty tape still refuses: that is a field that cannot be meshed, not an empty one. + +## What building it found + +- The two empty checks took `mesh_tape` from cognitive complexity 15 to 19. Its voxel-size pricing (caller's size or extent over resolution, the finite check, the grid-sample ceiling) moved verbatim into `price_mesh_voxel`; `mesh_tape` is 14 and `clay_document_mesh_combined` drops from 22 to 7 with the layer placement pulled out. + +- The plan named the empty tape only. The hidden-group case (`clay_groups_set_visible` over the whole surface) reaches a different refusal, `CLAY_ERROR_BACKEND`, from a non-empty tape; the regression test for it failed on the pre-fix code with code 8 rather than 1, which is how it was confirmed to be the second path rather than the first one again. diff --git a/openspec/changes/export-mesh-layers-without-a-field/proposal.md b/openspec/changes/export-mesh-layers-without-a-field/proposal.md new file mode 100644 index 00000000..204e2fd8 --- /dev/null +++ b/openspec/changes/export-mesh-layers-without-a-field/proposal.md @@ -0,0 +1,23 @@ +## Why + +`clay_document_mesh_combined` meshed the field first, through `clay_document_mesh`, and returned that call's error before it looked at a mesh layer. `clay_document_mesh` refuses an empty tape with `CLAY_ERROR_INVALID_ARGUMENT` ("empty document") and a field filtered to nothing by hidden surface groups with `CLAY_ERROR_BACKEND` ("meshing produced no triangles"). So a document whose only visible geometry is mesh layers -- every SDF layer hidden, or none at all -- could not be exported through the one call that exists to export mesh layers (#662). ClaySpaceDesktop worked around it by transforming and concatenating each visible mesh layer itself (CyberdyneCorp/ClaySpaceDesktop#213). + +## What Changes + +- With at least one visible mesh layer, an empty field contributes nothing to the combined export: the result is the placed mesh layers alone. "Empty" covers an empty tape and a tape whose mesh the hidden-group drop leaves with no triangles. +- With no visible mesh layer the call still returns exactly what `clay_document_mesh` returns, refusals included -- a document with nothing visible at all is still refused with `CLAY_ERROR_INVALID_ARGUMENT`. +- A malformed `clay_mesh_params` is refused whether or not the field is empty. +- No signature changes and no entry point is added: the C ABI version lines do not move. + +## Capabilities + +### Modified Capabilities + +- `c-abi`: "Exporting a document with its imported meshes is explicit" gains the rule that an empty field contributes nothing when mesh layers are visible, and a scenario for it. + +## Impact + +- `bindings/c/clay_c.cpp`: `clay_document_mesh_combined` places the visible mesh layers first (`place_visible_mesh_layers`, `place_mesh_layer`), and `mesh_tape` takes an `EmptyField` mode the combined call sets to `ContributesNothing`. The two standalone meshing calls keep the default `Refuse`. +- `bindings/c/clay.h`: the comment above `clay_document_mesh_combined`. +- `docs/08-mesh-readback.md`: the "field + visible mesh layers" row. +- Hosts that fell back to `clay_mesh_transform_nonuniform` + `clay_mesh_concat` on refusal can drop the fallback; keeping it is harmless, since the call no longer refuses those documents. diff --git a/openspec/changes/export-mesh-layers-without-a-field/specs/c-abi/spec.md b/openspec/changes/export-mesh-layers-without-a-field/specs/c-abi/spec.md new file mode 100644 index 00000000..ac520557 --- /dev/null +++ b/openspec/changes/export-mesh-layers-without-a-field/specs/c-abi/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: Exporting a document with its imported meshes is explicit +Meshing a document SHALL continue to mean meshing its field, unchanged: it prices a dense grid from the tape's own bounds, and geometry that is not in the tape would either inflate that grid or fall outside it, and would change what an existing call returns for an existing document. Voxel layers are already outside it for the same reason. + +The ABI SHALL instead expose transforming a mesh and concatenating meshes, plus one call that meshes the field and appends every visible mesh layer under its layer transform. Concatenation SHALL rebase indices. An attribute present on some inputs and absent on others SHALL be dropped from the result, because no mesh may be returned whose normals, colors or uvs are non-empty and a different length than its positions; the drop SHALL be documented at the call rather than discovered afterwards. + +A mesh layer that is not visible SHALL be excluded from the combined export. Ghost and lock SHALL NOT change what is exported, consistent with neither flag changing what a document evaluates to. + +When at least one mesh layer is visible, an empty field -- no visible SDF geometry, or a field whose every surface group is hidden -- SHALL contribute nothing to the combined export rather than refuse it; the result SHALL be the placed mesh layers alone. When no mesh layer is visible, the combined export SHALL return exactly what meshing the field returns, refusals included. + +#### Scenario: Meshing a document is what it always was +- **WHEN** a document containing mesh layers is meshed with the existing call +- **THEN** the result is bit-identical to the same document without them + +#### Scenario: A sculpt exports beside its reference model +- **WHEN** a consumer asks for the combined export of a document holding both an SDF layer and a mesh layer +- **THEN** the result contains both, with the imported triangles placed under their layer transform and their indices rebased + +#### Scenario: A mismatched attribute is dropped, not truncated +- **WHEN** a mesh carrying uvs is concatenated with one that carries none +- **THEN** the result carries no uvs, rather than an array shorter than its positions + +#### Scenario: A hidden mesh layer is not exported +- **WHEN** a mesh layer is hidden and the document is exported +- **THEN** its triangles are absent, while ghosting or locking it changes nothing + +#### Scenario: A document whose only visible geometry is mesh layers exports those layers +- **WHEN** every SDF layer of a document is hidden, or it has none, and a consumer asks for the combined export while a mesh layer is visible +- **THEN** the call succeeds and the result is that mesh layer's triangles under its layer transform, where meshing the field alone is still refused + +#### Scenario: A document with nothing visible is still refused +- **WHEN** a document has an empty field and no visible mesh layer, and a consumer asks for the combined export +- **THEN** the call refuses with the same error meshing the field returns, and writes no mesh diff --git a/openspec/changes/export-mesh-layers-without-a-field/tasks.md b/openspec/changes/export-mesh-layers-without-a-field/tasks.md new file mode 100644 index 00000000..e2cbc17c --- /dev/null +++ b/openspec/changes/export-mesh-layers-without-a-field/tasks.md @@ -0,0 +1,8 @@ +## Implementation +- [x] `tests/unit/test_c_mesh_layer.cpp`: a hidden SDF layer, no SDF item at all, and every surface group hidden each export the placed mesh layer alone (4 vertices, 12 indices, bounds under the layer transform); nothing visible is still `CLAY_ERROR_INVALID_ARGUMENT`; a malformed parameter block is refused with a mesh layer present. +- [x] Proved the three export cases fail on the pre-fix code (`CLAY_ERROR_INVALID_ARGUMENT` twice, `CLAY_ERROR_BACKEND` once). +- [x] `bindings/c/clay_c.cpp`: `place_mesh_layer` / `place_visible_mesh_layers` pulled out of `clay_document_mesh_combined`; `mesh_tape` takes an `EmptyField` mode, and its voxel-size pricing moves unchanged into `price_mesh_voxel` so `mesh_tape` stays at cognitive complexity 14 (it was 15 on main; the two empty checks alone took it to 19). +- [x] `bindings/c/clay.h`: the combined call's comment states that an empty field contributes nothing. +- [x] `docs/08-mesh-readback.md`: the "field + visible mesh layers" row. +- [x] No ABI version change (stays 0.124.0): no signature or entry point moves. +- [x] `ctest --preset cpu-only` and `tools/release_check.py --skip-slow`. diff --git a/tests/unit/test_c_mesh_layer.cpp b/tests/unit/test_c_mesh_layer.cpp index 64d55992..b795b3a2 100644 --- a/tests/unit/test_c_mesh_layer.cpp +++ b/tests/unit/test_c_mesh_layer.cpp @@ -59,7 +59,7 @@ std::string temp_path(const char* name) { // A document with something to evaluate, so "the tape is unchanged" is a // claim about a document that has a tape. -void add_sphere(clay_document* doc) { +clay_layer_id add_sphere(clay_document* doc) { clay_layer_id layer = 0; REQUIRE(clay_add_sdf_layer(doc, "body", &layer) == CLAY_OK); clay_item_desc item; @@ -70,6 +70,7 @@ void add_sphere(clay_document* doc) { item.scale = 1.0f; item.rotation[3] = 1.0f; REQUIRE(clay_add_item(doc, layer, &item, nullptr) == CLAY_OK); + return layer; } } // namespace @@ -480,6 +481,110 @@ TEST_CASE("c mesh combine: a sculpt exports beside its reference model") { clay_mesh_destroy(combined); } +// #662: the combined export meshed the field first and returned its refusal, +// so a document whose only visible geometry is mesh layers could not be +// exported at all. An empty field contributes nothing; it does not veto the +// layers beside it. +namespace { + +clay_layer_id add_tetrahedron_at_x5(clay_document* doc) { + clay_mesh* tet = tetrahedron(); + clay_mesh_layer_desc desc = layer_desc("reference"); + clay_layer_id layer = 0; + REQUIRE(clay_document_add_mesh_layer(doc, tet, &desc, &layer, nullptr) == CLAY_OK); + clay_mesh_destroy(tet); + const float position[3] = {5.0f, 0.0f, 0.0f}; + const float axis[3] = {0.0f, 1.0f, 0.0f}; + REQUIRE(clay_document_set_layer_transform(doc, layer, position, axis, 0.0f, 1.0f) == + CLAY_OK); + return layer; +} + +clay_mesh_params combined_params() { + clay_mesh_params params; + std::memset(¶ms, 0, sizeof params); + params.struct_size = static_cast(sizeof params); + params.resolution = 24; + return params; +} + +// The combined result is the tetrahedron alone, under its layer transform. +void check_is_placed_tetrahedron(const clay_document* doc) { + const clay_mesh_params params = combined_params(); + clay_mesh* combined = nullptr; + REQUIRE(clay_document_mesh_combined(doc, ¶ms, &combined) == CLAY_OK); + REQUIRE(combined != nullptr); + CHECK(clay_mesh_vertex_count(combined) == 4); + CHECK(clay_mesh_index_count(combined) == 12); + float lo[3], hi[3]; + REQUIRE(clay_mesh_bounds(combined, lo, hi) == CLAY_OK); + CHECK(lo[0] > 4.0f); + CHECK(hi[0] > 4.0f); + clay_mesh_destroy(combined); +} + +} // namespace + +TEST_CASE("c mesh combine: a hidden SDF layer leaves the mesh layers to export") { + Doc d; + const clay_layer_id sdf = add_sphere(d.doc); + add_tetrahedron_at_x5(d.doc); + REQUIRE(clay_document_set_layer_visible(d.doc, sdf, 0) == CLAY_OK); + + // the field alone is still refused, exactly as before + const clay_mesh_params params = combined_params(); + clay_mesh* field = nullptr; + CHECK(clay_document_mesh(d.doc, ¶ms, &field) == CLAY_ERROR_INVALID_ARGUMENT); + + check_is_placed_tetrahedron(d.doc); +} + +TEST_CASE("c mesh combine: a document with no SDF item exports its mesh layer") { + Doc d; + add_tetrahedron_at_x5(d.doc); + check_is_placed_tetrahedron(d.doc); +} + +TEST_CASE("c mesh combine: a field that meshes to nothing contributes nothing") { + // Every surface group hidden: the tape is not empty, but the mesher's + // output is filtered to no triangles, which clay_document_mesh refuses. + Doc d; + add_sphere(d.doc); + add_tetrahedron_at_x5(d.doc); + clay_groups* groups = nullptr; + REQUIRE(clay_document_groups(d.doc, 0.05f, &groups) == CLAY_OK); + const float lo[3] = {-1.0f, -1.0f, -1.0f}, hi[3] = {1.0f, 1.0f, 1.0f}; + REQUIRE(clay_groups_fill(groups, lo, hi, 1) == CLAY_OK); + REQUIRE(clay_groups_set_visible(groups, 1, 0) == CLAY_OK); + clay_groups_destroy(groups); + + const clay_mesh_params params = combined_params(); + clay_mesh* field = nullptr; + CHECK(clay_document_mesh(d.doc, ¶ms, &field) == CLAY_ERROR_BACKEND); + + check_is_placed_tetrahedron(d.doc); +} + +TEST_CASE("c mesh combine: nothing visible at all is still refused like the field call") { + Doc d; + const clay_layer_id mesh_layer = add_tetrahedron_at_x5(d.doc); + REQUIRE(clay_document_set_layer_visible(d.doc, mesh_layer, 0) == CLAY_OK); + + const clay_mesh_params params = combined_params(); + clay_mesh* field = nullptr; + CHECK(clay_document_mesh(d.doc, ¶ms, &field) == CLAY_ERROR_INVALID_ARGUMENT); + clay_mesh* out = reinterpret_cast(0x1); + CHECK(clay_document_mesh_combined(d.doc, ¶ms, &out) == CLAY_ERROR_INVALID_ARGUMENT); + CHECK(out == nullptr); + + // and a malformed parameter block is refused even when a mesh layer shows + REQUIRE(clay_document_set_layer_visible(d.doc, mesh_layer, 1) == CLAY_OK); + clay_mesh_params bad = params; + bad.struct_size = 0; + CHECK(clay_document_mesh_combined(d.doc, &bad, &out) == CLAY_ERROR_INVALID_ARGUMENT); + CHECK(out == nullptr); +} + // #365: the id-addressed route back to a mesh layer's geometry. A reopened // document could reach it only through the name, which answers with the first // layer in stack order carrying it, so a held id — the thing this ABI calls