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
10 changes: 8 additions & 2 deletions bindings/c/clay.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
151 changes: 98 additions & 53 deletions bindings/c/clay_c.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -10115,13 +10112,49 @@ 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;
// What the artist put away does not come back in the export. A no-op when
// 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
Expand Down Expand Up @@ -11740,6 +11773,45 @@ mesh::Mesh concat_meshes(const std::vector<const mesh::Mesh*>& 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<mesh::Mesh> place_visible_mesh_layers(const clay_document& doc) {
std::vector<mesh::Mesh> 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" {
Expand Down Expand Up @@ -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<mesh::Mesh> 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<const scene::Tape> 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<mesh::Mesh> 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<const mesh::Mesh*> 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();
Expand Down
2 changes: 1 addition & 1 deletion docs/08-mesh-readback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-04
3 changes: 3 additions & 0 deletions openspec/changes/export-mesh-layers-without-a-field/README.md
Original file line number Diff line number Diff line change
@@ -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).
25 changes: 25 additions & 0 deletions openspec/changes/export-mesh-layers-without-a-field/design.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions openspec/changes/export-mesh-layers-without-a-field/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading