Skip to content

Commit 9a8bd6e

Browse files
tksuoranclaude
andcommitted
editor: serialize content-library brushes in .erhescene bundles (#247)
Brushes were never written to or read from the scene directory bundle, so a saved-then-loaded .erhescene lost every brush in its content library (load_scene creates a fresh, empty Content_library and only the scene builder ever populated the default brush set procedurally). Add a Brush_data_serial struct (name, geometry file base, referenced material name, density, normal style) to the Scene_file schema (bumped to v8). save_scene now iterates content_library->brushes, writes each brush's geometry to a companion brush_<i>.geogram file (like geometry-normative meshes) and records the metadata; load_scene reconstructs each brush by loading and processing the geometry, resolving the material by name, and re-creating the Brush in the content library. Collision shapes are not persisted -- Brush::late_initialize rebuilds a convex hull from the geometry on first instantiation, matching runtime creation. Adds Brush::get_density() / get_normal_style() accessors for the save path. Verified headless: the default scene's 104 brushes round-trip through save_scene/load_scene with matching vertex/facet counts, and a reconstructed brush places and instantiates cleanly (full late_initialize primitive + collision-shape path), no warnings or errors in logs/log.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bc95aa9 commit 9a8bd6e

6 files changed

Lines changed: 157 additions & 1 deletion

File tree

src/editor/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ erhe_codegen_generate(
600600
"${_scene_renderer_defs}:erhe_scene_renderer/generated/"
601601
"${_xr_config_defs}:erhe_xr/generated/"
602602
DEFINITIONS
603+
"${_codegen_defs}/brush_data_serial.py"
603604
"${_codegen_defs}/camera_data.py"
604605
"${_codegen_defs}/collision_filter_data.py"
605606
"${_codegen_defs}/collision_shape_data.py"
@@ -632,6 +633,9 @@ erhe_codegen_generate(
632633
)
633634

634635
set(_codegen_sources
636+
${_codegen_out}/brush_data_serial.hpp
637+
${_codegen_out}/brush_data_serial_serialization.hpp
638+
${_codegen_out}/brush_data_serial.cpp
635639
${_codegen_out}/camera_data.hpp
636640
${_codegen_out}/camera_data_serialization.hpp
637641
${_codegen_out}/camera_data.cpp

src/editor/brushes/brush.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ auto Brush::get_material() const -> const std::shared_ptr<erhe::primitive::Mater
5353
return m_material;
5454
}
5555

56+
auto Brush::get_density() const -> float
57+
{
58+
return m_data.density;
59+
}
60+
61+
auto Brush::get_normal_style() const -> erhe::primitive::Normal_style
62+
{
63+
return m_data.normal_style;
64+
}
65+
5666
void Brush::set_material(const std::shared_ptr<erhe::primitive::Material>& material)
5767
{
5868
m_material = material;

src/editor/brushes/brush.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ class Brush : public erhe::Item<erhe::Item_base, erhe::Item_base, Brush, erhe::I
107107
[[nodiscard]] auto get_corner_count_to_facets() -> const std::map<GEO::index_t, std::vector<GEO::index_t>>&;
108108
[[nodiscard]] auto get_max_corner_count () const -> GEO::index_t;
109109
[[nodiscard]] auto get_material () const -> const std::shared_ptr<erhe::primitive::Material>&;
110+
[[nodiscard]] auto get_density () const -> float;
111+
[[nodiscard]] auto get_normal_style () const -> erhe::primitive::Normal_style;
110112
void set_material (const std::shared_ptr<erhe::primitive::Material>& material);
111113
[[nodiscard]] auto make_with_material (const std::shared_ptr<erhe::primitive::Material>& material) const -> std::shared_ptr<Brush>;
112114

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from erhe_codegen import *
2+
3+
struct("Brush_data_serial",
4+
version=1,
5+
fields=[
6+
field("name", String, added_in=1, default='""', short_desc="Name of the brush asset"),
7+
field("geometry_path", String, added_in=1, default='""', short_desc="Base name of the companion .geogram file holding the brush geometry"),
8+
field("material_name", String, added_in=1, default='""', short_desc="Name of the referenced material in the content library (empty = none)"),
9+
field("density", Float, added_in=1, default="1.0f", short_desc="Brush density used for mass computation"),
10+
field("normal_style", Int, added_in=1, default="0", short_desc="erhe::primitive::Normal_style used to build the brush primitive"),
11+
],
12+
)

src/editor/scene/definitions/scene_file.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from erhe_codegen import *
22

33
struct("Scene_file",
4-
version=7,
4+
version=8,
55
fields=[
66
field("name", String, added_in=1, default='""'),
77
field("enable_physics", Bool, added_in=1, default="true"),
@@ -11,6 +11,7 @@
1111
field("material_texture_sources", Vector(StructRef("Material_texture_source_data")), added_in=6, short_desc="Material slot -> Graph Texture bindings"),
1212
field("graph_meshes", Vector(StructRef("Graph_mesh_data")), added_in=7, short_desc="Procedural Graph Mesh assets in the content library"),
1313
field("graph_mesh_bindings", Vector(StructRef("Graph_mesh_binding_data")), added_in=7, short_desc="Scene node -> Graph Mesh bindings (Geometry Graph Mesh attachments)"),
14+
field("brushes", Vector(StructRef("Brush_data_serial")), added_in=8, short_desc="Brush assets in the content library (#247)"),
1415
field("nodes", Vector(StructRef("Node_data_serial")), added_in=1),
1516
field("cameras", Vector(StructRef("Camera_data")), added_in=1),
1617
field("lights", Vector(StructRef("Light_data")), added_in=1),

src/editor/scene/scene_serialization.cpp

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#include "scene/scene_serialization.hpp"
22

33
#include "app_context.hpp"
4+
#include "app_settings.hpp"
5+
#include "brushes/brush.hpp"
46
#include "content_library/content_library.hpp"
57
#include "geometry_graph/geometry_graph_mesh.hpp"
68
#include "geometry_graph/graph_mesh.hpp"
@@ -890,6 +892,51 @@ auto save_scene(
890892
}
891893
}
892894

895+
// Serialize brush assets in the content library (#247). A brush is an
896+
// editor-only concept (not expressible in glTF), so it is persisted here:
897+
// each brush's geometry is written to a companion .geogram file (like
898+
// geometry-normative meshes) and the brush metadata (name, referenced
899+
// material name, density, normal style) goes into scene.json. The
900+
// collision shape is intentionally not serialized - Brush::late_initialize
901+
// rebuilds a convex hull from the geometry at load, matching how brushes
902+
// are created at runtime.
903+
{
904+
const std::shared_ptr<Content_library> content_library = scene_root.get_content_library();
905+
if (content_library && content_library->brushes) {
906+
GEO::MeshIOFlags brush_ioflags;
907+
brush_ioflags.set_dimension(3);
908+
brush_ioflags.set_attributes(GEO::MeshAttributesFlags::MESH_ALL_ATTRIBUTES);
909+
brush_ioflags.set_elements(GEO::MeshElementsFlags::MESH_ALL_ELEMENTS);
910+
int brush_index = 0;
911+
for (const std::shared_ptr<Brush>& brush : content_library->brushes->get_all<Brush>()) {
912+
const std::shared_ptr<erhe::geometry::Geometry> geometry = brush->get_geometry();
913+
if (!geometry) {
914+
++brush_index;
915+
continue;
916+
}
917+
const std::string geometry_base = fmt::format("brush_{}", brush_index);
918+
const std::string filename = fmt::format("{}.geogram", geometry_base);
919+
const std::filesystem::path full_path = scene_dir / filename;
920+
GEO::OutputGeoFile geofile{full_path.string(), 3};
921+
if (!GEO::mesh_save(geometry->get_mesh(), geofile, brush_ioflags)) {
922+
log_parsers->error("save_scene: failed to save brush geometry: {}", full_path.string());
923+
}
924+
925+
Brush_data_serial brush_data;
926+
brush_data.name = brush->get_name();
927+
brush_data.geometry_path = geometry_base;
928+
brush_data.density = brush->get_density();
929+
brush_data.normal_style = static_cast<int>(brush->get_normal_style());
930+
const std::shared_ptr<erhe::primitive::Material>& material = brush->get_material();
931+
if (material) {
932+
brush_data.material_name = material->get_name();
933+
}
934+
scene_file.brushes.push_back(std::move(brush_data));
935+
++brush_index;
936+
}
937+
}
938+
}
939+
893940
// Serialize layouts and layout items
894941
for (const auto& node : flat_nodes) {
895942
const auto layout = erhe::scene::get_attachment<erhe::scene::Layout>(node.get());
@@ -1716,6 +1763,86 @@ auto load_scene(
17161763
}
17171764
}
17181765

1766+
// Reconstruct brush assets into the content library (#247). Each brush's
1767+
// geometry is loaded from its companion .geogram file, its referenced
1768+
// material (if any) is resolved by name, and the collision shape is rebuilt
1769+
// from the geometry the first time the brush is instantiated
1770+
// (Brush::late_initialize). Needs App_context for build_info / app_settings;
1771+
// skipped on pure data loads (context == nullptr).
1772+
if ((context != nullptr) && content_library && content_library->brushes && !scene_file.brushes.empty()) {
1773+
const erhe::primitive::Build_info brush_build_info{
1774+
.primitive_types = {
1775+
.fill_triangles = true,
1776+
.fill_triangles_expanded = true,
1777+
.edge_lines = true,
1778+
.corner_points = true,
1779+
.centroid_points = true,
1780+
},
1781+
.buffer_info = context->mesh_memory->make_primitive_buffer_info()
1782+
};
1783+
constexpr uint64_t brush_process_flags =
1784+
erhe::geometry::Geometry::process_flag_connect |
1785+
erhe::geometry::Geometry::process_flag_build_edges |
1786+
erhe::geometry::Geometry::process_flag_compute_facet_centroids |
1787+
erhe::geometry::Geometry::process_flag_compute_smooth_vertex_normals |
1788+
erhe::geometry::Geometry::process_flag_generate_facet_texture_coordinates;
1789+
1790+
for (const Brush_data_serial& brush_data : scene_file.brushes) {
1791+
const std::string filename = fmt::format("{}.geogram", brush_data.geometry_path);
1792+
const std::filesystem::path geom_path = scene_dir / filename;
1793+
if (!std::filesystem::exists(geom_path)) {
1794+
log_parsers->warn("load_scene: brush geometry file not found: {}", geom_path.string());
1795+
continue;
1796+
}
1797+
1798+
auto geometry = std::make_shared<erhe::geometry::Geometry>(brush_data.name);
1799+
// Unbind erhe Mesh_attributes before loading, because mesh_load will
1800+
// try to bind attributes with the same names and assert.
1801+
geometry->get_attributes().unbind();
1802+
GEO::Mesh& geo_mesh = geometry->get_mesh();
1803+
geo_mesh.clear(false, false);
1804+
GEO::MeshIOFlags ioflags;
1805+
ioflags.set_dimension(3);
1806+
ioflags.set_attributes(GEO::MeshAttributesFlags::MESH_ALL_ATTRIBUTES);
1807+
ioflags.set_elements(GEO::MeshElementsFlags::MESH_ALL_ELEMENTS);
1808+
if (!GEO::mesh_load(geom_path.string().c_str(), geo_mesh, ioflags)) {
1809+
log_parsers->error("load_scene: failed to load brush geometry: {}", geom_path.string());
1810+
continue;
1811+
}
1812+
geometry->get_attributes().bind();
1813+
if (geo_mesh.vertices.nb() == 0 || geo_mesh.facets.nb() == 0) {
1814+
log_parsers->warn("load_scene: empty brush geometry: {}", geom_path.string());
1815+
continue;
1816+
}
1817+
geo_mesh.vertices.set_single_precision();
1818+
geometry->process({.flags = brush_process_flags});
1819+
1820+
// Resolve the referenced material by name from the content library.
1821+
std::shared_ptr<erhe::primitive::Material> material{};
1822+
if (!brush_data.material_name.empty() && content_library->materials) {
1823+
for (const std::shared_ptr<erhe::primitive::Material>& candidate : content_library->materials->get_all<erhe::primitive::Material>()) {
1824+
if (candidate->get_name() == brush_data.material_name) {
1825+
material = candidate;
1826+
break;
1827+
}
1828+
}
1829+
}
1830+
1831+
Brush_data create_info{
1832+
.context = *context,
1833+
.app_settings = *context->app_settings,
1834+
.build_info = brush_build_info,
1835+
.normal_style = static_cast<erhe::primitive::Normal_style>(brush_data.normal_style),
1836+
.geometry = geometry,
1837+
.density = brush_data.density,
1838+
};
1839+
const std::shared_ptr<Brush> brush = content_library->brushes->make<Brush>(create_info);
1840+
if (material) {
1841+
brush->set_material(material);
1842+
}
1843+
}
1844+
}
1845+
17191846
return scene_root;
17201847
}
17211848

0 commit comments

Comments
 (0)