OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / src / core / proc_mesh.cpp

656 lines · cpp ref:
#include "proc_mesh.h"
#include <godot_cpp/templates/hash_set.hpp>
#include <godot_cpp/classes/mesh.hpp>
#include <godot_cpp/variant/utility_functions.hpp>

namespace godot {

// IEEE 754 float32 → float16 (half) conversion.
static inline uint16_t _float_to_half(float p_val) {
	union {
		float f;
		uint32_t u;
	} v;
	v.f = p_val;
	uint32_t b = v.u;
	uint32_t sign = (b >> 16) & 0x8000;
	int32_t exponent = ((b >> 23) & 0xFF) - 127;
	uint32_t mantissa = b & 0x007FFFFF;

	if (exponent > 15) {
		// Overflow → infinity
		return sign | 0x7C00;
	} else if (exponent > -15) {
		// Normal range
		uint32_t hexp = (uint32_t)(exponent + 15) << 10;
		uint32_t hman = mantissa >> 13;
		return sign | hexp | hman;
	} else if (exponent > -25) {
		// Denormalized
		mantissa |= 0x00800000;
		uint32_t shift = (uint32_t)(-1 - exponent);
		return sign | (mantissa >> shift);
	}
	// Too small → zero
	return sign;
}

void ProcMesh::_bind_methods() {
	ClassDB::bind_method(D_METHOD("get_vertex_count"), &ProcMesh::vertex_count);
	ClassDB::bind_method(D_METHOD("get_triangle_count"), &ProcMesh::triangle_count);
	ClassDB::bind_method(D_METHOD("to_array_mesh"), &ProcMesh::to_array_mesh);
	ClassDB::bind_method(D_METHOD("update_array_mesh", "mesh"), &ProcMesh::update_array_mesh);
	ClassDB::bind_method(D_METHOD("recalculate_normals"), &ProcMesh::recalculate_normals);
	ClassDB::bind_method(D_METHOD("reverse_faces"), &ProcMesh::reverse_faces);
	ClassDB::bind_method(D_METHOD("get_indices"), &ProcMesh::get_indices);
	ClassDB::bind_method(D_METHOD("get_face_offsets"), &ProcMesh::get_face_offsets);
	ClassDB::bind_method(D_METHOD("get_corner_count"), &ProcMesh::get_corner_count);
	ClassDB::bind_method(D_METHOD("get_face_count"), &ProcMesh::get_face_count);
	ClassDB::bind_method(D_METHOD("get_domain_size", "domain"), &ProcMesh::get_domain_size);
	ClassDB::bind_method(D_METHOD("get_attribute_values", "domain", "name"), &ProcMesh::get_attribute_values);
	ClassDB::bind_method(D_METHOD("get_attribute_names", "domain"), &ProcMesh::get_attribute_names);
	ClassDB::bind_static_method("ProcMesh", D_METHOD("from_mesh_data", "data"), &ProcMesh::from_mesh_data);
	ClassDB::bind_method(D_METHOD("to_mesh_data"), &ProcMesh::to_mesh_data);
}

static const AttributeStorage *_storage_for(const ProcMesh *p_mesh, int p_domain) {
	switch (static_cast<AttrDomain>(p_domain)) {
		case AttrDomain::Point: return &p_mesh->point_attrs;
		case AttrDomain::Edge: return &p_mesh->edge_attrs;
		case AttrDomain::Face: return &p_mesh->face_attrs;
		case AttrDomain::Corner: return &p_mesh->corner_attrs;
		default: return nullptr;
	}
}

PackedStringArray ProcMesh::get_attribute_names(int p_domain) const {
	PackedStringArray out;
	const AttributeStorage *st = _storage_for(this, p_domain);
	if (st == nullptr) {
		return out;
	}
	for (const KeyValue<StringName, Ref<AttributeArray>> &kv : st->attributes) {
		out.push_back(String(kv.key));
	}
	return out;
}

Array ProcMesh::get_attribute_values(int p_domain, const StringName &p_name) const {
	Array out;
	const AttributeStorage *st = _storage_for(this, p_domain);
	if (st == nullptr || !st->has(p_name)) {
		return out;
	}
	Ref<AttributeArray> a = st->get_for_read(p_name);
	if (a.is_null()) {
		return out;
	}
	const int n = a->size();
	for (int i = 0; i < n; i++) {
		switch (a->type) {
			case AttrType::Float: out.push_back(a->data_ptr<float>()[i]); break;
			case AttrType::Int: out.push_back(a->data_ptr<int32_t>()[i]); break;
			case AttrType::Bool: out.push_back(a->data_ptr<uint8_t>()[i] != 0); break;
			case AttrType::Float2: {
				const float *f = a->data_ptr<float>();
				out.push_back(Vector2(f[i * 2], f[i * 2 + 1]));
			} break;
			case AttrType::Float3: {
				const float *f = a->data_ptr<float>();
				out.push_back(Vector3(f[i * 3], f[i * 3 + 1], f[i * 3 + 2]));
			} break;
			case AttrType::Color:
			case AttrType::Float4: {
				const float *f = a->data_ptr<float>();
				out.push_back(Color(f[i * 4], f[i * 4 + 1], f[i * 4 + 2], f[i * 4 + 3]));
			} break;
			default:
				// Deliberately not guessed. An unhandled type returns SHORT
				// rather than plausible numbers, so a probe cannot quietly
				// compare the wrong bytes.
				return out;
		}
	}
	return out;
}

int ProcMesh::face_count() const {
	if (face_offsets.size() > 1) {
		return face_offsets.size() - 1;
	}
	// No face offsets → assume all triangles
	return triangle_count();
}

PackedInt32Array ProcMesh::triangulated_indices() const {
	if (!has_ngons()) {
		return indices; // already triangles
	}
	PackedInt32Array out;
	const int faces = face_count();
	for (int f = 0; f < faces; f++) {
		const int begin = face_corner_begin(f);
		const int n = face_size(f);
		for (int k = 1; k + 1 < n; k++) {
			out.push_back(indices[begin]);
			out.push_back(indices[begin + k]);
			out.push_back(indices[begin + k + 1]);
		}
	}
	return out;
}

PackedInt32Array ProcMesh::compute_edges() const {
	HashSet<int64_t> seen;
	Vector<int64_t> keys;
	const int32_t *idx = indices.ptr();
	// PER FACE, not per triangle: a quad has FOUR edges, and fanning it first
	// would invent the diagonal as a fifth. That is exactly why a grid's edge
	// count is 4 in Blender and was 5 here.
	const int faces = face_count();
	for (int f = 0; f < faces; f++) {
		const int begin = face_corner_begin(f);
		const int n = face_size(f);
		if (begin + n > indices.size()) {
			break;
		}
		for (int e = 0; e < n; e++) {
			int a = idx[begin + e];
			int b = idx[begin + (e + 1) % n];
			if (a > b) {
				const int t = a;
				a = b;
				b = t;
			}
			const int64_t key = ((int64_t)a << 32) | (uint32_t)b;
			if (!seen.has(key)) {
				seen.insert(key);
				keys.push_back(key);
			}
		}
	}
	keys.sort();
	PackedInt32Array out;
	out.resize(keys.size() * 2);
	int32_t *w = out.ptrw();
	for (int i = 0; i < keys.size(); i++) {
		w[i * 2 + 0] = (int32_t)(keys[i] >> 32);
		w[i * 2 + 1] = (int32_t)(keys[i] & 0xffffffff);
	}
	return out;
}

int ProcMesh::edge_count() const {
	return compute_edges().size() / 2;
}

int ProcMesh::corner_count() const {
	return indices.size();
}

int ProcMesh::domain_size(AttrDomain p_domain) const {
	switch (p_domain) {
		case AttrDomain::Point: return vertex_count();
		case AttrDomain::Face: return face_count();
		case AttrDomain::Corner: return corner_count();
		case AttrDomain::Edge: return edge_count();
		default: return 0;
	}
}

Ref<ArrayMesh> ProcMesh::to_array_mesh() const {
	if (positions.size() == 0) {
		return Ref<ArrayMesh>();
	}

	Array arrays;
	arrays.resize(Mesh::ARRAY_MAX);
	arrays[Mesh::ARRAY_VERTEX] = positions;

	// ⛔ A DRAWN SURFACE NEEDS NORMALS, and normals are DERIVED data -- Blender
	// recomputes them on demand and does not store them on a mesh either. This
	// omitted ARRAY_NORMAL when the mesh had none, and Godot then shaded a
	// surface that has no normal at all: the bridged mountain came out black
	// with speckles the moment the cage stopped carrying per-vertex normals it
	// had no business carrying. Deriving them here, at the draw step, is the
	// same rule as triangulating here.
	if (normals.size() == positions.size()) {
		arrays[Mesh::ARRAY_NORMAL] = normals;
	} else if (indices.size() >= 3 && positions.size() > 0) {
		PackedVector3Array derived;
		compute_normals(derived);
		if (derived.size() == positions.size()) {
			arrays[Mesh::ARRAY_NORMAL] = derived;
		}
	}

	if (tangents.size() > 0 && tangents.size() == positions.size() * 4) {
		arrays[Mesh::ARRAY_TANGENT] = tangents;
	}

	if (uvs.size() > 0) {
		arrays[Mesh::ARRAY_TEX_UV] = uvs;
	}

	if (colors.size() == positions.size()) {
		arrays[Mesh::ARRAY_COLOR] = colors;
	}

	// TRIANGULATED for the renderer. `indices` is the corner array and may hold
	// quads or ngons; Godot's surface takes triangles. With no face_offsets
	// this returns `indices` unchanged, so nothing moves for existing meshes.
	const PackedInt32Array tri_indices = triangulated_indices();
	if (tri_indices.size() > 0) {
		arrays[Mesh::ARRAY_INDEX] = tri_indices;
	}

	Ref<ArrayMesh> mesh;
	mesh.instantiate();
	Mesh::PrimitiveType prim = (tri_indices.size() > 0) ? Mesh::PRIMITIVE_TRIANGLES : Mesh::PRIMITIVE_POINTS;
	mesh->add_surface_from_arrays(prim, arrays, Array(), Dictionary(),
			Mesh::ARRAY_FLAG_USE_DYNAMIC_UPDATE);
	return mesh;
}

bool ProcMesh::update_array_mesh(Ref<ArrayMesh> p_mesh) const {
	if (p_mesh.is_null() || p_mesh->get_surface_count() == 0) {
		return false;
	}

	// Check if topology matches (same vertex and index count)
	int existing_verts = p_mesh->surface_get_array_len(0);
	int existing_indices = p_mesh->surface_get_array_index_len(0);

	if (existing_verts != positions.size() || existing_indices != indices.size()) {
		return false; // Topology changed — caller should use to_array_mesh()
	}

	// Rebuild the surface arrays on the existing mesh resource.
	// This avoids allocating a new ArrayMesh and re-assigning it to
	// MeshInstance3D (which triggers scene-tree notifications, AABB recalc, etc.).
	p_mesh->clear_surfaces();

	Array arrays;
	arrays.resize(Mesh::ARRAY_MAX);
	arrays[Mesh::ARRAY_VERTEX] = positions;

	if (normals.size() == positions.size()) {
		arrays[Mesh::ARRAY_NORMAL] = normals;
	}

	if (tangents.size() > 0 && tangents.size() == positions.size() * 4) {
		arrays[Mesh::ARRAY_TANGENT] = tangents;
	}

	if (uvs.size() > 0) {
		arrays[Mesh::ARRAY_TEX_UV] = uvs;
	}

	if (colors.size() == positions.size()) {
		arrays[Mesh::ARRAY_COLOR] = colors;
	}

	// TRIANGULATED for the renderer. `indices` is the corner array and may hold
	// quads or ngons; Godot's surface takes triangles. With no face_offsets
	// this returns `indices` unchanged, so nothing moves for existing meshes.
	const PackedInt32Array tri_indices = triangulated_indices();
	if (tri_indices.size() > 0) {
		arrays[Mesh::ARRAY_INDEX] = tri_indices;
	}

	p_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays, Array(), Dictionary(),
			Mesh::ARRAY_FLAG_USE_DYNAMIC_UPDATE);
	return true;
}

void ProcMesh::reverse_faces() {
	const int fc = face_count();
	if (fc == 0 || indices.size() == 0) {
		return;
	}
	PackedInt32Array perm;
	perm.resize(indices.size());
	int32_t *cw = indices.ptrw();
	int32_t *pw = perm.ptrw();
	for (int f = 0; f < fc; f++) {
		const int b = face_corner_begin(f);
		const int n = face_size(f);
		// The face keeps its first corner and reverses the rest, so it still
		// starts at the same vertex and only its orientation changes.
		pw[b] = b;
		for (int k = 1; k < n; k++) {
			pw[b + k] = b + (n - k);
		}
		for (int k = 1; k < (n + 1) / 2; k++) {
			const int32_t tmp = cw[b + k];
			cw[b + k] = cw[b + n - k];
			cw[b + n - k] = tmp;
		}
	}
	// Corner values are indexed by corner, so they move with them. Reversing
	// one without the other shears them, silently, since the counts still match.
	for (const KeyValue<StringName, Ref<AttributeArray>> &kv : corner_attrs.attributes) {
		Ref<AttributeArray> a = kv.value;
		if (a.is_null() || a->size() != indices.size()) {
			continue;
		}
		const int esz = attr_type_size(a->type);
		const PackedByteArray src = a->data;
		uint8_t *dst = a->data.ptrw();
		const uint8_t *sp = src.ptr();
		for (int c = 0; c < indices.size(); c++) {
			memcpy(dst + (int64_t)pw[c] * esz, sp + (int64_t)c * esz, esz);
		}
	}
}

void ProcMesh::recalculate_normals() {
	compute_normals(normals);
}

// ⛔ ONE PLACE. recalculate_normals() and to_array_mesh() each had their own
// copy of this and they disagreed about the SIGN, which is the whole of the
// question: proc-nodes stores meshes in GODOT's winding, where the front face
// is clockwise, so the outward normal is the NEGATED cross product. One of the
// two copies knew that.
//
// ⛔ AND IT READ `indices` AS A FLAT TRIANGLE LIST -- tri_count = size / 3 --
// exactly as Subdivide Mesh did. `indices` is the CORNER array; face_offsets
// groups it. Once the bridge started carrying real topology and Subdivide Mesh
// started producing quads, this computed normals from corner triples that span
// three different faces: the bridged mountain rendered near-black with bright
// specks where a nonsense normal happened to face the light.
void ProcMesh::compute_normals(PackedVector3Array &r_out) const {
	const int vert_count = positions.size();
	if (vert_count == 0 || indices.size() < 3) {
		return;
	}
	r_out.resize(vert_count);
	Vector3 *nw = r_out.ptrw();
	const Vector3 *pw = positions.ptr();
	const int32_t *iw = indices.ptr();
	for (int i = 0; i < vert_count; i++) {
		nw[i] = Vector3();
	}
	const int fc = face_count();
	for (int f = 0; f < fc; f++) {
		const int b = face_corner_begin(f);
		const int n = face_size(f);
		for (int k = 1; k + 1 < n; k++) {
			const int32_t i0 = iw[b];
			const int32_t i1 = iw[b + k];
			const int32_t i2 = iw[b + k + 1];
			if (i0 >= vert_count || i1 >= vert_count || i2 >= vert_count) {
				continue;
			}
			// Area-weighted -- the cross product's length is twice the area --
			// and NEGATED for Godot's clockwise front face.
			const Vector3 fn = -(pw[i1] - pw[i0]).cross(pw[i2] - pw[i0]);
			nw[i0] += fn;
			nw[i1] += fn;
			nw[i2] += fn;
		}
	}
	for (int i = 0; i < vert_count; i++) {
		nw[i] = nw[i].length_squared() > 0.0f ? nw[i].normalized() : Vector3(0, 1, 0);
	}
}

PackedByteArray ProcMesh::pack_vertex_buffer(uint32_t p_stride,
		uint32_t p_pos_offset, uint32_t p_normal_offset,
		int32_t p_uv_offset) const {
	int count = positions.size();
	if (count == 0) {
		return PackedByteArray();
	}

	PackedByteArray buf;
	buf.resize(count * p_stride);
	// Zero-fill so unused padding/attributes are clean
	memset(buf.ptrw(), 0, count * p_stride);

	uint8_t *dst = buf.ptrw();
	const Vector3 *pos = positions.ptr();
	const Vector3 *nor = (normals.size() == count) ? normals.ptr() : nullptr;
	const Vector2 *uv_ptr = (uvs.size() == count) ? uvs.ptr() : nullptr;

	for (int i = 0; i < count; i++) {
		uint8_t *vert = dst + i * p_stride;

		// Position: float32 x 3
		float *fp = reinterpret_cast<float *>(vert + p_pos_offset);
		fp[0] = pos[i].x;
		fp[1] = pos[i].y;
		fp[2] = pos[i].z;

		// Normal: octahedral encode -> uint16 x 2
		uint16_t *np = reinterpret_cast<uint16_t *>(vert + p_normal_offset);
		if (nor) {
			Vector2 oct = nor[i].octahedron_encode();
			np[0] = (uint16_t)CLAMP(oct.x * 65535.0f, 0.0f, 65535.0f);
			np[1] = (uint16_t)CLAMP(oct.y * 65535.0f, 0.0f, 65535.0f);
		} else {
			// Default up normal
			Vector2 oct = Vector3(0, 1, 0).octahedron_encode();
			np[0] = (uint16_t)(oct.x * 65535.0f);
			np[1] = (uint16_t)(oct.y * 65535.0f);
		}

		// UV: float16 x 2 (half-float)
		if (p_uv_offset >= 0 && uv_ptr) {
			uint16_t *up = reinterpret_cast<uint16_t *>(vert + p_uv_offset);
			up[0] = _float_to_half(uv_ptr[i].x);
			up[1] = _float_to_half(uv_ptr[i].y);
		}
	}
	return buf;
}

Ref<ProcMesh> ProcMesh::duplicate_mesh() const {
	Ref<ProcMesh> dup;
	dup.instantiate();
	dup->positions = positions;
	dup->normals = normals;
	dup->tangents = tangents;
	dup->uvs = uvs;
	dup->colors = colors;
	dup->indices = indices;
	dup->face_offsets = face_offsets;
	dup->point_attrs.propagate_from(point_attrs);
	dup->corner_attrs.propagate_from(corner_attrs);
	dup->face_attrs.propagate_from(face_attrs);
	dup->edge_attrs.propagate_from(edge_attrs);
	return dup;
}


Ref<ProcMesh> ProcMesh::from_array_mesh(const Ref<Mesh> &p_mesh) {
	Ref<ProcMesh> out;
	if (p_mesh.is_null() || p_mesh->get_surface_count() == 0) {
		return out;
	}
	out.instantiate();
	// Surface 0 only. A graph input is one mesh; a multi-surface object would
	// need a material-aware merge, which is a different job.
	Array arrays = p_mesh->surface_get_arrays(0);
	if (arrays.size() <= Mesh::ARRAY_VERTEX) {
		return out;
	}
	out->positions = arrays[Mesh::ARRAY_VERTEX];
	if (arrays.size() > Mesh::ARRAY_NORMAL && arrays[Mesh::ARRAY_NORMAL].get_type() != Variant::NIL) {
		out->normals = arrays[Mesh::ARRAY_NORMAL];
	}
	if (arrays.size() > Mesh::ARRAY_TEX_UV && arrays[Mesh::ARRAY_TEX_UV].get_type() != Variant::NIL) {
		out->uvs = arrays[Mesh::ARRAY_TEX_UV];
	}
	if (arrays.size() > Mesh::ARRAY_INDEX && arrays[Mesh::ARRAY_INDEX].get_type() != Variant::NIL) {
		out->indices = arrays[Mesh::ARRAY_INDEX];
	} else {
		// ⛔ A NON-INDEXED SURFACE IS STILL TRIANGLES. Without an index array
		// this left `indices` empty, so triangle_count() was 0 and every node
		// downstream saw a mesh with no faces -- "Mesh to SDF Grid got no
		// triangles", on a cage that had 60516 corners. blender-bridge builds
		// exactly this: Blender exports per-loop-triangle CORNERS so modifier
		// normals survive, and never writes an index array. The fan IS the
		// index array in that case: 0, 1, 2, ...
		const int vcount = out->positions.size();
		if (vcount >= 3) {
			out->indices.resize(vcount - (vcount % 3));
			int32_t *iw = out->indices.ptrw();
			for (int i = 0; i < out->indices.size(); i++) {
				iw[i] = i;
			}
		}
	}
	return out;
}


// ── THE MESH CHANNEL ────────────────────────────────────────────────────────

static AttributeStorage *_storage_for_write(ProcMesh *p_mesh, AttrDomain p_domain) {
	switch (p_domain) {
		case AttrDomain::Point: return &p_mesh->point_attrs;
		case AttrDomain::Edge: return &p_mesh->edge_attrs;
		case AttrDomain::Face: return &p_mesh->face_attrs;
		case AttrDomain::Corner: return &p_mesh->corner_attrs;
		default: return nullptr;
	}
}

static inline uint64_t _edge_key(int32_t a, int32_t b) {
	const uint32_t lo = (uint32_t)(a < b ? a : b);
	const uint32_t hi = (uint32_t)(a < b ? b : a);
	return ((uint64_t)lo << 32) | (uint64_t)hi;
}

Ref<ProcMesh> ProcMesh::from_mesh_data(const Dictionary &p_data) {
	Ref<ProcMesh> out;
	out.instantiate();
	out->positions = p_data.get("points", PackedVector3Array());
	out->indices = p_data.get("corners", PackedInt32Array());
	PackedInt32Array faces = p_data.get("faces", PackedInt32Array());
	// A single trailing offset means "no faces declared" -- every face is a
	// triangle, which is what an empty face_offsets already means here.
	if (faces.size() > 1) {
		out->face_offsets = faces;
	}

	// ⛔ AN EDGE ATTRIBUTE CANNOT BE COPIED BY INDEX. ProcMesh does not store
	// edges; it derives them, each pair normalised low-to-high and the set
	// sorted, which is OUR order and not the sender's. Blender builds an
	// icosphere's edges in its own sequence: the counts agree and edge i is a
	// different edge. So the sender ships its edge array and every edge-domain
	// value is remapped through the vertex pair it belongs to. Without this the
	// values land on the wrong edges silently, because the counts match.
	PackedInt32Array sender_edges = p_data.get("edges", PackedInt32Array());
	HashMap<uint64_t, int32_t> our_edge_index;
	bool need_edge_map = false;
	Array attrs = p_data.get("attributes", Array());
	for (int i = 0; i < attrs.size(); i++) {
		Dictionary a = attrs[i];
		if ((int)a.get("domain", 0) == (int)AttrDomain::Edge) {
			need_edge_map = true;
			break;
		}
	}
	if (need_edge_map && sender_edges.size() >= 2) {
		PackedInt32Array ours = out->compute_edges();
		const int32_t *o = ours.ptr();
		for (int e = 0; e < ours.size() / 2; e++) {
			our_edge_index[_edge_key(o[e * 2], o[e * 2 + 1])] = e;
		}
	}

	for (int i = 0; i < attrs.size(); i++) {
		Dictionary a = attrs[i];
		const StringName name = StringName(String(a.get("name", "")));
		const AttrDomain domain = static_cast<AttrDomain>((int)a.get("domain", 0));
		const AttrType type = static_cast<AttrType>((int)a.get("type", 0));
		PackedByteArray data = a.get("data", PackedByteArray());
		AttributeStorage *st = _storage_for_write(out.ptr(), domain);
		if (st == nullptr || String(name).is_empty()) {
			continue;
		}
		const int esz = attr_type_size(type);
		const int incoming = esz > 0 ? data.size() / esz : 0;
		const int want = out->domain_size(domain);
		Ref<AttributeArray> arr;
		arr.instantiate();
		arr->name = name;
		arr->type = type;
		arr->domain = domain;
		arr->resize(want);
		if (incoming <= 0 || want <= 0) {
			st->set(name, arr);
			continue;
		}
		uint8_t *dst = arr->data.ptrw();
		const uint8_t *src = data.ptr();
		if (domain == AttrDomain::Edge && our_edge_index.size() > 0 &&
				sender_edges.size() / 2 == incoming) {
			const int32_t *se = sender_edges.ptr();
			for (int e = 0; e < incoming; e++) {
				HashMap<uint64_t, int32_t>::ConstIterator it =
						our_edge_index.find(_edge_key(se[e * 2], se[e * 2 + 1]));
				if (it == our_edge_index.end() || it->value >= want) {
					continue;
				}
				memcpy(dst + (int64_t)it->value * esz, src + (int64_t)e * esz, esz);
			}
		} else {
			const int n = incoming < want ? incoming : want;
			memcpy(dst, src, (int64_t)n * esz);
		}
		st->set(name, arr);
	}
	return out;
}

Dictionary ProcMesh::to_mesh_data() const {
	Dictionary d;
	d["points"] = positions;
	d["corners"] = indices;
	PackedInt32Array faces = face_offsets;
	if (faces.size() <= 1) {
		// Triangles: say so explicitly rather than making the receiver guess,
		// which is the habit this channel exists to end.
		const int fc = triangle_count();
		faces.resize(fc + 1);
		int32_t *fw = faces.ptrw();
		for (int f = 0; f <= fc; f++) {
			fw[f] = f * 3;
		}
	}
	d["faces"] = faces;
	// Our derived order, shipped WITH the edge values so the receiver can put
	// them back on the right edges.
	PackedInt32Array edges = compute_edges();
	d["edges"] = edges;

	Array attrs;
	const AttrDomain domains[4] = { AttrDomain::Point, AttrDomain::Edge,
			AttrDomain::Face, AttrDomain::Corner };
	for (int di = 0; di < 4; di++) {
		const AttributeStorage *st = _storage_for(this, (int)domains[di]);
		if (st == nullptr) {
			continue;
		}
		for (const KeyValue<StringName, Ref<AttributeArray>> &kv : st->attributes) {
			if (kv.value.is_null()) {
				continue;
			}
			Dictionary a;
			a["name"] = String(kv.key);
			a["domain"] = (int)domains[di];
			a["type"] = (int)kv.value->type;
			a["data"] = kv.value->data;
			attrs.push_back(a);
		}
	}
	d["attributes"] = attrs;
	return d;
}

} // namespace godot