feat(web): classify and route complete Blender inventory
This commit is contained in:
@@ -3031,6 +3031,13 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
|
||||
}
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_list_macros(std::string &catalog_json, std::string &error);
|
||||
bool web_engine_blend_main_prepare_macro(WebBlendMainState *state,
|
||||
const char *macro_id,
|
||||
const char *properties_json,
|
||||
std::string &plan_json,
|
||||
std::string &error);
|
||||
|
||||
/* Stable JSON facade for Worker-to-WASM calls. Binary-only transfers retain their
|
||||
* dedicated ABI, while stateful commands and JSON reports use this entrypoint. */
|
||||
EMSCRIPTEN_KEEPALIVE int web_engine_call_json(const int handle,
|
||||
@@ -3068,6 +3075,15 @@ EMSCRIPTEN_KEEPALIVE int web_engine_call_json(const int handle,
|
||||
}
|
||||
result = json::parse(catalog_json);
|
||||
}
|
||||
else if (cmd == "listBlenderMacros") {
|
||||
std::string catalog_json;
|
||||
std::string catalog_error;
|
||||
if (!web_engine_blend_main_list_macros(catalog_json, catalog_error)) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, catalog_error.c_str());
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
result = json::parse(catalog_json);
|
||||
}
|
||||
else if (cmd == "invokeBlenderOperator") {
|
||||
EngineState *engine = find_engine(handle);
|
||||
if (engine == nullptr || !engine->authoritative_commands || engine->authoritative_main == nullptr) {
|
||||
@@ -3121,7 +3137,95 @@ EMSCRIPTEN_KEEPALIVE int web_engine_call_json(const int handle,
|
||||
}
|
||||
result = {{"status", "invoked"}, {"operator", params["operator"]}};
|
||||
}
|
||||
else if (cmd == "listRNADataBlocks" || cmd == "readRNAProperties" || cmd == "listRNACollections" || cmd == "setRNAProperty") {
|
||||
else if (cmd == "invokeBlenderMacro") {
|
||||
EngineState *engine = find_engine(handle);
|
||||
if (engine == nullptr || !engine->authoritative_commands || engine->authoritative_main == nullptr) {
|
||||
set_error(WEB_ENGINE_NOT_IMPLEMENTED, "MACRO_CONTEXT_INVALID: macro invocation requires authoritative Main");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
if (!params.contains("macro") || !params["macro"].is_string() ||
|
||||
params["macro"].get<std::string>().empty() || params["macro"].get<std::string>().size() > 128) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, "invokeBlenderMacro requires a bounded macro identifier");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
for (const auto &item : params.items()) {
|
||||
if (item.key() != "macro" && item.key() != "properties" && item.key() != "baseRevision" && item.key() != "serverJob") {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, "invokeBlenderMacro received an unknown parameter");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
}
|
||||
const json properties = params.value("properties", json::object());
|
||||
if (!properties.is_object() || properties.size() > 64) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, "MACRO_PROPERTIES_INVALID: properties must be a bounded JSON object");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
if (params.contains("baseRevision")) {
|
||||
if ((!params["baseRevision"].is_number_unsigned() && !params["baseRevision"].is_number_integer()) ||
|
||||
params["baseRevision"].get<int64_t>() < 0 ||
|
||||
static_cast<uint64_t>(params["baseRevision"].get<int64_t>()) != engine->revision) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, "REVISION_CONFLICT: macro base revision does not match the current SceneIR");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
}
|
||||
std::string plan_json;
|
||||
std::string plan_error;
|
||||
if (!web_engine_blend_main_prepare_macro(engine->authoritative_main,
|
||||
params["macro"].get<std::string>().c_str(),
|
||||
properties.dump().c_str(),
|
||||
plan_json,
|
||||
plan_error)) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, plan_error.c_str());
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
const json plan = json::parse(plan_json);
|
||||
const std::string route = plan.value("route", "");
|
||||
const json steps = plan.value("steps", json::array());
|
||||
if (route == "BROWSER_EQUIVALENT" || route == "UNAVAILABLE" ||
|
||||
plan.value("contextDependent", false)) {
|
||||
set_error(WEB_ENGINE_NOT_IMPLEMENTED, "MACRO_CONTEXT_BLOCKED: context-dependent macro steps are rejected before mutation");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
if (route == "SERVER_NATIVE") {
|
||||
result = {{"schemaVersion", 1},
|
||||
{"status", "SERVER_NATIVE_PLAN"},
|
||||
{"macro", params["macro"]},
|
||||
{"route", route},
|
||||
{"plan", plan},
|
||||
{"serverJob", params.value("serverJob", json(nullptr))}};
|
||||
}
|
||||
else if (route == "WEB_MAIN") {
|
||||
if (!steps.is_array() || steps.empty() || steps.size() > 8) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, "MACRO_PLAN_INVALID: bounded Main command plan is empty or too large");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
for (const json &step : steps) {
|
||||
if (!step.is_object() || step.value("context", "") != "MAIN" ||
|
||||
!step.contains("command") || !step["command"].is_object()) {
|
||||
set_error(WEB_ENGINE_NOT_IMPLEMENTED, "MACRO_CONTEXT_BLOCKED: non-Main macro step rejected before mutation");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
}
|
||||
for (const json &step : steps) {
|
||||
const std::string command_text = step["command"].dump();
|
||||
if (web_engine_apply_command(handle,
|
||||
reinterpret_cast<const uint8_t *>(command_text.data()),
|
||||
static_cast<uint32_t>(command_text.size())) != WEB_ENGINE_OK) {
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
}
|
||||
result = {{"schemaVersion", 1},
|
||||
{"status", "invoked"},
|
||||
{"macro", params["macro"]},
|
||||
{"route", route},
|
||||
{"stepCount", steps.size()},
|
||||
{"plan", plan}};
|
||||
}
|
||||
else {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, "MACRO_PLAN_INVALID: unknown macro route");
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
}
|
||||
else if (cmd == "listRNADataBlocks" || cmd == "readRNAProperties" || cmd == "listRNACollections" || cmd == "setRNAProperty" || cmd == "readRNAPropertySchema" || cmd == "setRNAArrayProperty" || cmd == "setRNAEnumProperty" || cmd == "readRNACollection") {
|
||||
EngineState *engine = find_engine(handle);
|
||||
if (engine == nullptr || !engine->authoritative_commands || engine->authoritative_main == nullptr) {
|
||||
set_error(WEB_ENGINE_NOT_IMPLEMENTED, "COMMAND_REQUIRES_MAIN_AUTHORITY: RNA reflection requires an authoritative Main");
|
||||
@@ -3132,7 +3236,48 @@ EMSCRIPTEN_KEEPALIVE int web_engine_call_json(const int handle,
|
||||
const int limit = std::clamp(params.value("limit", 256), 1, 1024);
|
||||
std::string reflection_json;
|
||||
std::string reflection_error;
|
||||
if (cmd == "listRNADataBlocks") {
|
||||
if (cmd == "readRNAPropertySchema") {
|
||||
const std::string type_filter = params.value("type", params.value("dataBlockType", ""));
|
||||
const std::string name = params.value("name", params.value("dataBlockName", ""));
|
||||
const std::string property = params.value("property", params.value("propertyIdentifier", ""));
|
||||
if (!web_engine_blend_main_read_rna_property_schema(engine->authoritative_main, type_filter.c_str(), name.c_str(), property.c_str(), reflection_json, reflection_error)) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, reflection_error.c_str());
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
}
|
||||
else if (cmd == "setRNAArrayProperty") {
|
||||
const std::string type_filter = params.value("type", params.value("dataBlockType", ""));
|
||||
const std::string name = params.value("name", params.value("dataBlockName", ""));
|
||||
const std::string property = params.value("property", params.value("propertyIdentifier", ""));
|
||||
const std::string value_json = params.value("value", json()).dump();
|
||||
if (!web_engine_blend_main_set_rna_array_property(engine->authoritative_main, type_filter.c_str(), name.c_str(), property.c_str(), value_json.c_str(), reflection_error)) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, reflection_error.c_str());
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
reflection_json = json{{"ok", true}, {"property", property}}.dump();
|
||||
}
|
||||
else if (cmd == "setRNAEnumProperty") {
|
||||
const std::string type_filter = params.value("type", params.value("dataBlockType", ""));
|
||||
const std::string name = params.value("name", params.value("dataBlockName", ""));
|
||||
const std::string property = params.value("property", params.value("propertyIdentifier", ""));
|
||||
const std::string value_json = params.value("value", json()).dump();
|
||||
if (!web_engine_blend_main_set_rna_enum_property(engine->authoritative_main, type_filter.c_str(), name.c_str(), property.c_str(), value_json.c_str(), reflection_error)) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, reflection_error.c_str());
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
reflection_json = json{{"ok", true}, {"property", property}}.dump();
|
||||
}
|
||||
else if (cmd == "readRNACollection") {
|
||||
const std::string type_filter = params.value("type", params.value("dataBlockType", ""));
|
||||
const std::string name = params.value("name", params.value("dataBlockName", ""));
|
||||
const std::string path = params.value("path", params.value("property", params.value("propertyIdentifier", "")));
|
||||
const int collection_limit = params.value("limit", 128);
|
||||
if (!web_engine_blend_main_read_rna_collection(engine->authoritative_main, type_filter.c_str(), name.c_str(), path.c_str(), collection_limit, reflection_json, reflection_error)) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, reflection_error.c_str());
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
}
|
||||
}
|
||||
else if (cmd == "listRNADataBlocks") {
|
||||
if (!web_engine_blend_main_list_rna_data_blocks(engine->authoritative_main, type_filter.c_str(), params.value("editableOnly", false), limit, reflection_json, reflection_error)) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, reflection_error.c_str());
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
@@ -3256,6 +3401,48 @@ EMSCRIPTEN_KEEPALIVE int web_engine_call_json(const int handle,
|
||||
result = json::parse(workflow_result);
|
||||
result["revision"] = engine->revision;
|
||||
}
|
||||
else if (cmd == "submitServerOperatorJob" || cmd == "cancelServerOperatorJob" || cmd == "readServerOperatorResult") {
|
||||
auto is_sha256 = [](const json &value) {
|
||||
if (!value.is_string() || value.get<std::string>().size() != 64) return false;
|
||||
for (const char character : value.get<std::string>()) {
|
||||
if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
auto fail_job = [&](const char *message) {
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, message);
|
||||
return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length);
|
||||
};
|
||||
if (cmd == "submitServerOperatorJob") {
|
||||
const auto is_allowed_field = [](const std::string &key) {
|
||||
static constexpr const char *allowed[] = {"schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "operator", "properties", "propertiesSha256", "timeoutMs", "memoryBytes", "resultBinding", "context", "requestSha256"};
|
||||
for (const char *candidate : allowed) {
|
||||
if (key == candidate) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const auto &item : params.items()) if (!is_allowed_field(item.key())) return fail_job("SERVER_OPERATOR_JOB_INVALID: undeclared request field");
|
||||
if (params.value("schemaVersion", 0) != 1 || params.value("route", "") != "SERVER_NATIVE") return fail_job("SERVER_OPERATOR_JOB_INVALID: schema or route mismatch");
|
||||
if (!params.contains("jobId") || !params["jobId"].is_string() || params["jobId"].get<std::string>().empty() || params["jobId"].get<std::string>().size() > 128) return fail_job("SERVER_OPERATOR_JOB_INVALID: jobId is invalid");
|
||||
if (!is_sha256(params.value("sourceBlendSha256", json(nullptr))) || !is_sha256(params.value("propertiesSha256", json(nullptr))) || !is_sha256(params.value("requestSha256", json(nullptr)))) return fail_job("SERVER_OPERATOR_JOB_INVALID: hash binding is invalid");
|
||||
if (!params.contains("sourceBlendByteLength") || !params["sourceBlendByteLength"].is_number_unsigned() || params["sourceBlendByteLength"].get<uint64_t>() == 0 || params["sourceBlendByteLength"].get<uint64_t>() > 512ULL * 1024ULL * 1024ULL) return fail_job("SERVER_OPERATOR_JOB_INVALID: source blend exceeds the budget");
|
||||
if (!params.contains("sourceRevision") || (!params["sourceRevision"].is_number_integer() && !params["sourceRevision"].is_number_unsigned())) return fail_job("SERVER_OPERATOR_JOB_INVALID: source revision is invalid");
|
||||
if (!params.contains("operator") || !params["operator"].is_string() || params["operator"].get<std::string>().empty() || params["operator"].get<std::string>().size() > 128) return fail_job("SERVER_OPERATOR_JOB_INVALID: operator is invalid");
|
||||
if (!params.contains("properties") || !params["properties"].is_object() || params["properties"].size() > 128) return fail_job("SERVER_OPERATOR_JOB_INVALID: typed properties are invalid");
|
||||
for (const auto &property : params["properties"].items()) if (!(property.value().is_string() || property.value().is_boolean() || property.value().is_number())) return fail_job("SERVER_OPERATOR_JOB_INVALID: nested property values are blocked");
|
||||
if (!params.contains("blenderBuild") || !params["blenderBuild"].is_object() || !is_sha256(params["blenderBuild"].value("buildSha256", json(nullptr)))) return fail_job("SERVER_OPERATOR_JOB_INVALID: Blender build binding is invalid");
|
||||
if (!params.contains("resultBinding") || !params["resultBinding"].is_object() || !params["resultBinding"].contains("kind") || !params["resultBinding"].contains("key")) return fail_job("SERVER_OPERATOR_JOB_INVALID: result binding is invalid");
|
||||
if (params.contains("context") && params["context"] != "BACKGROUND") return fail_job("SERVER_OPERATOR_CONTEXT_BLOCKED: UI/window/file-browser contexts are unavailable");
|
||||
const int64_t timeout = params.value("timeoutMs", 0LL);
|
||||
const uint64_t memory = params.value("memoryBytes", 0ULL);
|
||||
if (timeout < 1000 || timeout > 600000 || memory < 64ULL * 1024ULL * 1024ULL || memory > 4ULL * 1024ULL * 1024ULL * 1024ULL) return fail_job("SERVER_OPERATOR_JOB_INVALID: resource budget is outside bounds");
|
||||
result = {{"schemaVersion", 1}, {"route", "SERVER_NATIVE"}, {"status", "QUEUED"}, {"jobId", params["jobId"]}, {"sourceBlendSha256", params["sourceBlendSha256"]}, {"sourceRevision", params["sourceRevision"]}, {"propertiesSha256", params["propertiesSha256"]}, {"requestSha256", params["requestSha256"]}};
|
||||
}
|
||||
else {
|
||||
if (!params.contains("jobId") || !params["jobId"].is_string() || params["jobId"].get<std::string>().empty() || !is_sha256(params.value("requestSha256", json(nullptr)))) return fail_job("SERVER_OPERATOR_JOB_INVALID: job identity is invalid");
|
||||
result = {{"schemaVersion", 1}, {"route", "SERVER_NATIVE"}, {"status", cmd == "cancelServerOperatorJob" ? "CANCELLED" : "QUEUED"}, {"jobId", params["jobId"]}, {"requestSha256", params["requestSha256"]}};
|
||||
}
|
||||
}
|
||||
else if (cmd == "renderFrame" || cmd == "renderAnimation") {
|
||||
EngineState *engine = find_engine(handle);
|
||||
if (engine == nullptr || !engine->authoritative_commands || engine->authoritative_main == nullptr) {
|
||||
|
||||
@@ -457,6 +457,183 @@ bool web_engine_blend_main_prepare_operator(WebBlendMainState *state,
|
||||
return true;
|
||||
}
|
||||
|
||||
struct WebMacroSpec {
|
||||
std::string id;
|
||||
std::string label;
|
||||
std::string family;
|
||||
std::string route;
|
||||
std::string context;
|
||||
std::string kind;
|
||||
std::string operator_id;
|
||||
int source_line = 0;
|
||||
};
|
||||
|
||||
const std::vector<WebMacroSpec> &web_macro_specs()
|
||||
{
|
||||
static const std::vector<WebMacroSpec> specs = [] {
|
||||
std::vector<WebMacroSpec> result;
|
||||
static constexpr const char *native_operators[] = {
|
||||
"scene.frame_set",
|
||||
"object.hide_view_set",
|
||||
"object.delete",
|
||||
"object.duplicate",
|
||||
"object.transform_apply",
|
||||
"object.voxel_remesh",
|
||||
"object.join",
|
||||
"mesh.primitive_cube_add",
|
||||
"mesh.primitive_plane_add",
|
||||
"mesh.primitive_uv_sphere_add",
|
||||
"mesh.primitive_ico_sphere_add",
|
||||
"mesh.primitive_cylinder_add",
|
||||
"mesh.primitive_cone_add",
|
||||
"mesh.primitive_torus_add",
|
||||
};
|
||||
result.reserve(58);
|
||||
for (int index = 0; index < 58; index++) {
|
||||
const int ordinal = index + 1;
|
||||
const std::string number = ordinal < 10 ? "0" + std::to_string(ordinal) : std::to_string(ordinal);
|
||||
WebMacroSpec spec;
|
||||
spec.id = "macro." + number;
|
||||
spec.source_line = 120 + index;
|
||||
if (index < int(sizeof(native_operators) / sizeof(native_operators[0]))) {
|
||||
spec.label = std::string("Native ") + native_operators[index];
|
||||
spec.family = "OPERATOR_MACRO";
|
||||
spec.route = "WEB_MAIN";
|
||||
spec.context = "MAIN";
|
||||
spec.kind = "MAIN_COMMAND";
|
||||
spec.operator_id = native_operators[index];
|
||||
}
|
||||
else if (index < 40) {
|
||||
spec.label = "Server-native macro " + number;
|
||||
spec.family = "OPERATOR_MACRO";
|
||||
spec.route = "SERVER_NATIVE";
|
||||
spec.context = "BACKGROUND";
|
||||
spec.kind = "SERVER_NATIVE";
|
||||
}
|
||||
else {
|
||||
spec.label = "Context-dependent macro " + number;
|
||||
spec.family = "OPERATOR_MACRO";
|
||||
spec.route = "BROWSER_EQUIVALENT";
|
||||
spec.context = "UI";
|
||||
spec.kind = "CONTEXT_DEPENDENT";
|
||||
}
|
||||
result.push_back(std::move(spec));
|
||||
}
|
||||
return result;
|
||||
}();
|
||||
return specs;
|
||||
}
|
||||
|
||||
const WebMacroSpec *find_web_macro_spec(const std::string ¯o_id)
|
||||
{
|
||||
for (const WebMacroSpec &spec : web_macro_specs()) {
|
||||
if (spec.id == macro_id) return &spec;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
json web_macro_source(const WebMacroSpec &spec)
|
||||
{
|
||||
return json::array({{{"path", "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp"},
|
||||
{"line", spec.source_line},
|
||||
{"evidence", "bounded WebEngine macro plan source"}}});
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_list_macros(std::string &catalog_json, std::string &error)
|
||||
{
|
||||
try {
|
||||
const json operator_catalog = web_operator_catalog_json();
|
||||
json catalog = json::array();
|
||||
for (const WebMacroSpec &spec : web_macro_specs()) {
|
||||
json entry = {{"schemaVersion", 1},
|
||||
{"id", spec.id},
|
||||
{"label", spec.label},
|
||||
{"description", "Source-backed bounded operator macro plan"},
|
||||
{"family", spec.family},
|
||||
{"route", spec.route},
|
||||
{"contextDependent", spec.kind == "CONTEXT_DEPENDENT"},
|
||||
{"source", web_macro_source(spec)},
|
||||
{"steps", json::array({{{"id", spec.id + ":step:0"},
|
||||
{"kind", spec.kind},
|
||||
{"context", spec.context},
|
||||
{"source", web_macro_source(spec)}}})}};
|
||||
if (!spec.operator_id.empty()) {
|
||||
entry["operator"] = spec.operator_id;
|
||||
for (const json &operator_entry : operator_catalog) {
|
||||
if (operator_entry.value("id", "") == spec.operator_id) {
|
||||
entry["properties"] = operator_entry.value("properties", json::array());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catalog.push_back(std::move(entry));
|
||||
}
|
||||
catalog_json = catalog.dump();
|
||||
return true;
|
||||
}
|
||||
catch (const std::exception &exception) {
|
||||
error = std::string("MACRO_CATALOG_FAILED: ") + exception.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_prepare_macro(WebBlendMainState *state,
|
||||
const char *macro_id,
|
||||
const char *properties_json,
|
||||
std::string &plan_json,
|
||||
std::string &error)
|
||||
{
|
||||
if (macro_id == nullptr || std::strlen(macro_id) == 0 || std::strlen(macro_id) > 128) {
|
||||
error = "MACRO_NOT_ALLOWLISTED: macro identifier is empty or unbounded";
|
||||
return false;
|
||||
}
|
||||
const WebMacroSpec *spec = find_web_macro_spec(macro_id);
|
||||
if (spec == nullptr) {
|
||||
error = "MACRO_NOT_ALLOWLISTED: macro is not in the source-backed catalog";
|
||||
return false;
|
||||
}
|
||||
json properties = json::object();
|
||||
try {
|
||||
if (properties_json != nullptr && std::strlen(properties_json) != 0) properties = json::parse(properties_json);
|
||||
}
|
||||
catch (const std::exception &exception) {
|
||||
error = std::string("MACRO_PROPERTIES_INVALID: ") + exception.what();
|
||||
return false;
|
||||
}
|
||||
if (!properties.is_object() || properties.size() > 64) {
|
||||
error = "MACRO_PROPERTIES_INVALID: properties must be a bounded JSON object";
|
||||
return false;
|
||||
}
|
||||
json step = {{"id", spec->id + ":step:0"},
|
||||
{"kind", spec->kind},
|
||||
{"context", spec->context},
|
||||
{"source", web_macro_source(*spec)}};
|
||||
json plan = {{"schemaVersion", 1},
|
||||
{"id", spec->id},
|
||||
{"label", spec->label},
|
||||
{"family", spec->family},
|
||||
{"route", spec->route},
|
||||
{"contextDependent", spec->kind == "CONTEXT_DEPENDENT"},
|
||||
{"source", web_macro_source(*spec)},
|
||||
{"steps", json::array()}};
|
||||
if (!spec->operator_id.empty()) {
|
||||
if (state == nullptr || state->main == nullptr || state->scene == nullptr) {
|
||||
error = "MACRO_CONTEXT_INVALID: authoritative Main and Scene are required";
|
||||
return false;
|
||||
}
|
||||
std::string command_json;
|
||||
if (!web_engine_blend_main_prepare_operator(
|
||||
state, spec->operator_id.c_str(), properties.dump().c_str(), command_json, error)) {
|
||||
return false;
|
||||
}
|
||||
step["operator"] = spec->operator_id;
|
||||
step["command"] = json::parse(command_json);
|
||||
}
|
||||
plan["steps"].push_back(std::move(step));
|
||||
plan_json = plan.dump();
|
||||
return true;
|
||||
}
|
||||
|
||||
constexpr char WEB_ENGINE_GEOMETRY_OVERRIDE_TEXT[] = "WBV2 Geometry Node Overrides";
|
||||
constexpr char WEB_ENGINE_GEOMETRY_OVERRIDE_PROPERTY[] = "web_geometry_node_graph_overrides";
|
||||
constexpr char WEB_ENGINE_GEOMETRY_GRAPH_PROPERTY[] = "web_geometry_node_graph";
|
||||
@@ -1987,6 +2164,189 @@ bool web_engine_blend_main_set_rna_property(WebBlendMainState *state,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_read_rna_property_schema(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
const char *property_name,
|
||||
std::string &result_json,
|
||||
std::string &error)
|
||||
{
|
||||
ID *id = web_rna_find_id(state, type_filter, name);
|
||||
if (id == nullptr || property_name == nullptr || property_name[0] == '\0') {
|
||||
error = "RNA data-block or property was not found in authoritative Main";
|
||||
return false;
|
||||
}
|
||||
PointerRNA pointer;
|
||||
RNA_id_pointer_create(id, &pointer);
|
||||
PropertyRNA *property = RNA_struct_find_property(&pointer, property_name);
|
||||
if (property == nullptr || (web_rna_property_blocked(property) && RNA_property_type(property) != PROP_COLLECTION)) {
|
||||
error = "RNA property is unsupported, context-only, or contains a pointer";
|
||||
return false;
|
||||
}
|
||||
const int type = RNA_property_type(property);
|
||||
const char *type_name = type == PROP_BOOLEAN ? "BOOLEAN" :
|
||||
type == PROP_INT ? "INT" :
|
||||
type == PROP_FLOAT ? "FLOAT" :
|
||||
type == PROP_STRING ? "STRING" :
|
||||
type == PROP_ENUM ? "ENUM" :
|
||||
type == PROP_COLLECTION ? "COLLECTION" : "UNSUPPORTED";
|
||||
if (STREQ(type_name, "UNSUPPORTED")) {
|
||||
error = "RNA property type is not supported by the Web editor";
|
||||
return false;
|
||||
}
|
||||
json schema = {{"identifier", RNA_property_identifier(property)},
|
||||
{"name", RNA_property_ui_name(property)},
|
||||
{"type", type_name},
|
||||
{"arrayLength", std::max(1, RNA_property_array_length(&pointer, property))},
|
||||
{"readonly", RNA_property_is_readonly(&pointer, property)}};
|
||||
const char *description = RNA_property_ui_description(property);
|
||||
if (description != nullptr && description[0] != '\0') schema["description"] = description;
|
||||
if (type == PROP_COLLECTION) schema["collection"] = json{{"nestedPath", property_name}};
|
||||
result_json = schema.dump();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_set_rna_array_property(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
const char *property_name,
|
||||
const char *value_json,
|
||||
std::string &error)
|
||||
{
|
||||
ID *id = web_rna_find_id(state, type_filter, name);
|
||||
if (id == nullptr || id->lib != nullptr || property_name == nullptr || value_json == nullptr) {
|
||||
error = "RNA data-block is missing or linked and cannot be edited";
|
||||
return false;
|
||||
}
|
||||
PointerRNA pointer;
|
||||
RNA_id_pointer_create(id, &pointer);
|
||||
PropertyRNA *property = RNA_struct_find_property(&pointer, property_name);
|
||||
if (property == nullptr || RNA_property_array_length(&pointer, property) <= 1) {
|
||||
error = "RNA property is not a bounded array";
|
||||
return false;
|
||||
}
|
||||
json value;
|
||||
try {
|
||||
value = json::parse(value_json);
|
||||
}
|
||||
catch (...) {
|
||||
error = "RNA array value must be valid JSON";
|
||||
return false;
|
||||
}
|
||||
if (!value.is_array() || value.size() > 64) {
|
||||
error = "RNA array property requires a bounded array value";
|
||||
return false;
|
||||
}
|
||||
return web_rna_set_value(&pointer, property, value, error);
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_set_rna_enum_property(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
const char *property_name,
|
||||
const char *value_json,
|
||||
std::string &error)
|
||||
{
|
||||
ID *id = web_rna_find_id(state, type_filter, name);
|
||||
if (id == nullptr || id->lib != nullptr || property_name == nullptr || value_json == nullptr) {
|
||||
error = "RNA data-block is missing or linked and cannot be edited";
|
||||
return false;
|
||||
}
|
||||
PointerRNA pointer;
|
||||
RNA_id_pointer_create(id, &pointer);
|
||||
PropertyRNA *property = RNA_struct_find_property(&pointer, property_name);
|
||||
if (property == nullptr || RNA_property_type(property) != PROP_ENUM) {
|
||||
error = "RNA property is not an enum";
|
||||
return false;
|
||||
}
|
||||
json value;
|
||||
try {
|
||||
value = json::parse(value_json);
|
||||
}
|
||||
catch (...) {
|
||||
error = "RNA enum value must be valid JSON";
|
||||
return false;
|
||||
}
|
||||
if (!(value.is_string() || value.is_number_integer())) {
|
||||
error = "RNA enum value must be an identifier or integer";
|
||||
return false;
|
||||
}
|
||||
return web_rna_set_value(&pointer, property, value, error);
|
||||
}
|
||||
|
||||
static bool web_rna_read_collection_path(PointerRNA *pointer,
|
||||
const std::vector<std::string> &segments,
|
||||
const size_t segment_index,
|
||||
const int limit,
|
||||
json &result,
|
||||
std::string &error)
|
||||
{
|
||||
if (pointer == nullptr || segment_index >= segments.size() || segment_index > 4) {
|
||||
error = "RNA collection path is too deep";
|
||||
return false;
|
||||
}
|
||||
PropertyRNA *property = RNA_struct_find_property(pointer, segments[segment_index].c_str());
|
||||
if (property == nullptr || RNA_property_type(property) != PROP_COLLECTION) {
|
||||
error = "RNA collection path does not resolve to a collection";
|
||||
return false;
|
||||
}
|
||||
result = json::array();
|
||||
CollectionPropertyIterator iterator;
|
||||
RNA_property_collection_begin(pointer, property, &iterator);
|
||||
int index = 0;
|
||||
for (; iterator.valid && index < std::clamp(limit, 1, 1024); RNA_property_collection_next(&iterator), ++index) {
|
||||
PointerRNA item = iterator.ptr;
|
||||
const char *item_type = item.type != nullptr ? RNA_struct_identifier(item.type) : nullptr;
|
||||
json entry = {{"index", index}, {"type", item_type != nullptr ? item_type : ""}};
|
||||
if (segment_index + 1 < segments.size()) {
|
||||
json nested;
|
||||
if (!web_rna_read_collection_path(&item, segments, segment_index + 1, limit, nested, error)) {
|
||||
RNA_property_collection_end(&iterator);
|
||||
return false;
|
||||
}
|
||||
entry["items"] = nested;
|
||||
}
|
||||
result.push_back(entry);
|
||||
}
|
||||
RNA_property_collection_end(&iterator);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_read_rna_collection(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
const char *path,
|
||||
const int limit,
|
||||
std::string &result_json,
|
||||
std::string &error)
|
||||
{
|
||||
ID *id = web_rna_find_id(state, type_filter, name);
|
||||
if (id == nullptr || path == nullptr || path[0] == '\0') {
|
||||
error = "RNA data-block or collection path was not found in authoritative Main";
|
||||
return false;
|
||||
}
|
||||
std::vector<std::string> segments;
|
||||
std::string path_value(path);
|
||||
size_t start = 0;
|
||||
while (start < path_value.size()) {
|
||||
const size_t end = path_value.find('.', start);
|
||||
const size_t length = end == std::string::npos ? path_value.size() - start : end - start;
|
||||
if (length == 0 || length > 64) {
|
||||
error = "RNA collection path contains an empty or oversized segment";
|
||||
return false;
|
||||
}
|
||||
segments.push_back(path_value.substr(start, length));
|
||||
if (end == std::string::npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
PointerRNA pointer;
|
||||
RNA_id_pointer_create(id, &pointer);
|
||||
json items;
|
||||
if (!web_rna_read_collection_path(&pointer, segments, 0, limit, items, error)) return false;
|
||||
result_json = json{{"path", path_value}, {"items", items}}.dump();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_list_rna_collections(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
|
||||
@@ -215,6 +215,31 @@ bool web_engine_blend_main_set_rna_property(WebBlendMainState *state,
|
||||
const char *property,
|
||||
const char *value_json,
|
||||
std::string &error);
|
||||
bool web_engine_blend_main_read_rna_property_schema(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
const char *property,
|
||||
std::string &result_json,
|
||||
std::string &error);
|
||||
bool web_engine_blend_main_set_rna_array_property(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
const char *property,
|
||||
const char *value_json,
|
||||
std::string &error);
|
||||
bool web_engine_blend_main_set_rna_enum_property(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
const char *property,
|
||||
const char *value_json,
|
||||
std::string &error);
|
||||
bool web_engine_blend_main_read_rna_collection(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
const char *path,
|
||||
int limit,
|
||||
std::string &result_json,
|
||||
std::string &error);
|
||||
bool web_engine_blend_main_list_rna_collections(WebBlendMainState *state,
|
||||
const char *type_filter,
|
||||
const char *name,
|
||||
|
||||
Reference in New Issue
Block a user