#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