diff --git a/blender-5.2.0/source/blender/web_engine/CMakeLists.txt b/blender-5.2.0/source/blender/web_engine/CMakeLists.txt index 9966ebcfa..450588eec 100644 --- a/blender-5.2.0/source/blender/web_engine/CMakeLists.txt +++ b/blender-5.2.0/source/blender/web_engine/CMakeLists.txt @@ -4,6 +4,7 @@ set(SRC web_engine_blend_reader.cpp web_engine_depsgraph.cpp + # Includes the source-backed, background-safe native operator bridge. web_engine_main_state.cpp web_engine_api.cpp web_engine_decimate.cpp diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp index c604c8ec4..66f551ac4 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp @@ -18,6 +18,11 @@ #include "web_engine_depsgraph.h" #include "web_engine_main_state.h" +bool web_engine_blend_main_set_sequencer_strips(WebBlendMainState *state, + const char *scene_id, + const char *timeline_json, + std::string &error); + #ifdef __EMSCRIPTEN__ # include #else @@ -731,6 +736,12 @@ constexpr size_t kHistoryLimit = 64; const char *empty_scene_snapshot_json(); std::string scene_delta_json(const std::string &before_text, const std::string &after_text); +std::string snapshot_active_object_id(const json &snapshot) +{ + const auto value = snapshot.find("activeObjectId"); + return value != snapshot.end() && value->is_string() ? value->get() : std::string(); +} + void push_history(std::vector &history, HistoryEntry entry) { history.push_back(std::move(entry)); @@ -780,6 +791,10 @@ bool restore_history_entry(EngineState &engine, if (restored_main == nullptr) { return false; } + if (!web_engine_blend_main_retain_media_metadata(restored_main, snapshot.dump().c_str(), error)) { + web_engine_blend_main_free(restored_main); + return false; + } snapshot["revision"] = engine.revision + (advance_revision ? 1 : 0); const std::string restored_snapshot = snapshot.dump(); const std::string previous_snapshot = engine.scene_snapshot.empty() ? @@ -825,6 +840,10 @@ bool refresh_scene_from_main(EngineState &engine, return false; } snapshot = json::parse(parsed.scene_ir); + std::string merged_media_snapshot = snapshot.dump(); + if (!web_engine_blend_main_merge_media_snapshot( + engine.authoritative_main, merged_media_snapshot, error)) return false; + snapshot = json::parse(merged_media_snapshot); std::string grease_pencils_json; if (!web_engine_blend_main_grease_pencils_json( engine.authoritative_main, grease_pencils_json, error)) @@ -1202,6 +1221,20 @@ EMSCRIPTEN_KEEPALIVE int web_engine_open_blend(const int handle, if (!particle_settings.empty() || opened_snapshot.value("particleSettings", json::array()).empty()) { opened_snapshot["particleSettings"] = particle_settings; } + if (!web_engine_blend_main_retain_media_metadata( + authoritative_main, opened_snapshot.dump().c_str(), main_error)) { + web_engine_blend_main_free(authoritative_main); + set_error(WEB_ENGINE_BLEND_READ_FAILED, main_error.c_str()); + return last_error_code; + } + std::string merged_media_snapshot = opened_snapshot.dump(); + if (!web_engine_blend_main_merge_media_snapshot( + authoritative_main, merged_media_snapshot, main_error)) { + web_engine_blend_main_free(authoritative_main); + set_error(WEB_ENGINE_BLEND_READ_FAILED, main_error.c_str()); + return last_error_code; + } + opened_snapshot = json::parse(merged_media_snapshot); } catch (const std::exception &exception) { web_engine_blend_main_free(authoritative_main); @@ -1268,6 +1301,10 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, set_error(WEB_ENGINE_BLEND_WRITE_FAILED, history_error.c_str()); return last_error_code; } + extern bool web_engine_blend_main_create_action(WebBlendMainState *, const char *, const char *, std::string &, std::string &); + extern bool web_engine_blend_main_remove_action(WebBlendMainState *, const char *, std::string &); + extern bool web_engine_blend_main_set_keyframe(WebBlendMainState *, const char *, const char *, int, const char *, const char *, const char *, const char *, std::string &); + extern bool web_engine_blend_main_set_timeline_markers(WebBlendMainState *, const char *, const char *, std::string &); if (type == "setFrame") { if (!engine->authoritative_commands || engine->authoritative_main == nullptr) { set_error(WEB_ENGINE_NOT_IMPLEMENTED, @@ -1374,7 +1411,7 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, command.value("modifierUuid", "").c_str(), command.value("showViewport", true), command.value("showRender", true), command.value("showEditMode", false), command.value("showOnCage", false), main_error) || - !refresh_scene_from_main(*engine, snapshot, snapshot.value("activeObjectId", ""), main_error)) { + !refresh_scene_from_main(*engine, snapshot, snapshot_active_object_id(snapshot), main_error)) { restore_history_entry(*engine, previous_state, history_error, false); set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); return last_error_code; @@ -1382,6 +1419,92 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, engine->authoritative_dirty = true; snapshot_from_main = true; } + else if (type == "addModifier") { + if (!engine->authoritative_commands || engine->authoritative_main == nullptr) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "authoritative Main is unavailable for addModifier"); + return last_error_code; + } + const std::string mesh_id = command.value("meshId", ""); + const std::string modifier_type = command.value("modifierType", ""); + const std::string name = command.value("name", ""); + if (mesh_id.empty() || modifier_type.empty() || (command.contains("name") && !command["name"].is_string())) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "addModifier requires meshId and modifierType"); + return last_error_code; + } + std::string main_error; + std::string modifier_uuid; + if (!web_engine_blend_main_add_modifier(engine->authoritative_main, mesh_id.c_str(), modifier_type.c_str(), name.c_str(), modifier_uuid, main_error) || + !refresh_scene_from_main(*engine, snapshot, snapshot_active_object_id(snapshot), main_error)) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); + return last_error_code; + } + engine->authoritative_dirty = true; + snapshot_from_main = true; + } + else if (type == "removeModifier") { + if (!engine->authoritative_commands || engine->authoritative_main == nullptr) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "authoritative Main is unavailable for removeModifier"); + return last_error_code; + } + const std::string mesh_id = command.value("meshId", ""); + const std::string modifier_uuid = command.value("modifierUuid", ""); + if (mesh_id.empty() || modifier_uuid.empty()) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "removeModifier requires meshId and modifierUuid"); + return last_error_code; + } + std::string main_error; + if (!web_engine_blend_main_remove_modifier(engine->authoritative_main, mesh_id.c_str(), modifier_uuid.c_str(), main_error) || + !refresh_scene_from_main(*engine, snapshot, snapshot_active_object_id(snapshot), main_error)) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); + return last_error_code; + } + engine->authoritative_dirty = true; + snapshot_from_main = true; + } + else if (type == "moveModifier") { + if (!engine->authoritative_commands || engine->authoritative_main == nullptr) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "authoritative Main is unavailable for moveModifier"); + return last_error_code; + } + const std::string mesh_id = command.value("meshId", ""); + const std::string modifier_uuid = command.value("modifierUuid", ""); + const std::string direction = command.value("direction", ""); + const int index = command.value("index", -1); + if (mesh_id.empty() || modifier_uuid.empty() || (index < 0 && direction.empty()) || + (!direction.empty() && direction != "UP" && direction != "DOWN")) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "moveModifier requires meshId, modifierUuid and direction or index"); + return last_error_code; + } + std::string main_error; + if (!web_engine_blend_main_move_modifier(engine->authoritative_main, mesh_id.c_str(), modifier_uuid.c_str(), index, direction.c_str(), main_error) || + !refresh_scene_from_main(*engine, snapshot, snapshot_active_object_id(snapshot), main_error)) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); + return last_error_code; + } + engine->authoritative_dirty = true; + snapshot_from_main = true; + } + else if (type == "setModifierParameters") { + if (!engine->authoritative_commands || engine->authoritative_main == nullptr) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "authoritative Main is unavailable for setModifierParameters"); + return last_error_code; + } + const std::string mesh_id = command.value("meshId", ""); + const std::string modifier_uuid = command.value("modifierUuid", ""); + if (mesh_id.empty() || modifier_uuid.empty() || !command.contains("parameters") || !command["parameters"].is_object()) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "setModifierParameters requires meshId, modifierUuid and parameters object"); + return last_error_code; + } + const std::string parameters_json = command["parameters"].dump(); + std::string main_error; + if (!web_engine_blend_main_set_modifier_parameters(engine->authoritative_main, mesh_id.c_str(), modifier_uuid.c_str(), parameters_json.c_str(), main_error) || + !refresh_scene_from_main(*engine, snapshot, snapshot_active_object_id(snapshot), main_error)) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); + return last_error_code; + } + engine->authoritative_dirty = true; + snapshot_from_main = true; + } else if (type == "decimateMesh") { std::string decimate_error; const int result = apply_decimate_command(snapshot, command, decimate_error); @@ -1418,7 +1541,7 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); return last_error_code; } - if (!refresh_scene_from_main(*engine, snapshot, snapshot.value("activeObjectId", ""), main_error)) { + if (!refresh_scene_from_main(*engine, snapshot, snapshot_active_object_id(snapshot), main_error)) { restore_history_entry(*engine, previous_state, history_error); set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); return last_error_code; @@ -1585,7 +1708,7 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, vertex_indices, offset, main_error) || - !refresh_scene_from_main(*engine, snapshot, snapshot.value("activeObjectId", ""), main_error)) + !refresh_scene_from_main(*engine, snapshot, snapshot_active_object_id(snapshot), main_error)) { restore_history_entry(*engine, previous_state, history_error); set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); @@ -1600,12 +1723,14 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, type == "importImage" || type == "setMaterialImageNode" || type == "setShaderGraph" || type == "setGeometryNodeGraph" || type == "setPhysicsSimulation" || type == "insertObjectKeyframe" || type == "deleteObjectKeyframe" || - type == "setFCurveInterpolation" || type == "setActiveAction" || type == "setNLAStack" || + type == "createAction" || type == "removeAction" || type == "setKeyframe" || type == "setTimelineMarkers" || + type == "setFCurveInterpolation" || type == "setActiveAction" || type == "setNLAStack" || type == "setSequencerStrips" || type == "setConstraint" || type == "setParent" || type == "createCollection" || type == "moveObjectToCollection" || type == "renameId" || type == "joinObjects" || type == "separateMeshFaces" || type == "applyObjectTransform" || type == "setObjectOrigin" || type == "setCurveControlPoints" || type == "setCurveHandle" || type == "setCurveTopology" || type == "setCurveSplines" || type == "setSurfaceTopology" || type == "setFontBody" || type == "setFontProperties" || type == "setFontAdvanced" || type == "importVFont" || type == "setFontLinks" || type == "setTextSource" || type == "setSoundProperties" || type == "setSpeakerProperties" || type == "setParticleSettings" || type == "setTextureProperties" || type == "setVolumeProperties" || type == "deleteNonMeshData" || + type == "setArmatureBones" || type == "setPoseBoneTransform" || type == "setMetaballElements" || type == "createGreasePencilLayer" || type == "removeGreasePencilLayer" || type == "moveGreasePencilLayer" || type == "moveGreasePencilFrame" || @@ -1622,7 +1747,7 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, } bool applied = false; std::string main_error; - std::string active_object_id = snapshot.value("activeObjectId", ""); + std::string active_object_id = snapshot_active_object_id(snapshot); if (type == "meshEdit") { applied = web_engine_blend_main_mesh_edit( engine->authoritative_main, @@ -1704,6 +1829,33 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, applied = web_engine_blend_main_set_shader_graph( engine->authoritative_main, material_id.c_str(), graph.dump().c_str(), main_error); } + else if (type == "createAction") { + std::string action_id; + const std::string object_id = command.contains("objectId") && command["objectId"].is_string() ? command["objectId"].get() : std::string(); + applied = web_engine_blend_main_create_action(engine->authoritative_main, + command.value("name", "Action").c_str(), object_id.c_str(), action_id, main_error); + } + else if (type == "removeAction") { + applied = web_engine_blend_main_remove_action(engine->authoritative_main, + command.value("actionId", "").c_str(), main_error); + } + else if (type == "setKeyframe") { + const std::string value_json = command.contains("value") ? command["value"].dump() : std::string(); + const std::string handle_left_json = command.contains("handleLeft") ? command["handleLeft"].dump() : std::string(); + const std::string handle_right_json = command.contains("handleRight") ? command["handleRight"].dump() : std::string(); + applied = web_engine_blend_main_set_keyframe(engine->authoritative_main, + command.value("animationId", "").c_str(), + command.value("path", "").c_str(), + command.value("frame", 0), + value_json.c_str(), handle_left_json.c_str(), handle_right_json.c_str(), + command.value("interpolation", "").c_str(), main_error); + } + else if (type == "setTimelineMarkers") { + applied = web_engine_blend_main_set_timeline_markers(engine->authoritative_main, + command.value("dataId", "").c_str(), + command.contains("markers") ? command["markers"].dump().c_str() : "[]", + main_error); + } else if (type == "insertObjectKeyframe") { applied = web_engine_blend_main_insert_keyframe( engine->authoritative_main, command.value("objectId", "").c_str(), command.value("frame", -1), @@ -1731,6 +1883,14 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, applied = web_engine_blend_main_set_nla_stack( engine->authoritative_main, command.value("objectId", "").c_str(), tracks.dump().c_str(), main_error); } + else if (type == "setSequencerStrips") { + const json timeline = command.value("timeline", json::object()); + applied = web_engine_blend_main_set_sequencer_strips( + engine->authoritative_main, + command.value("sceneId", "").c_str(), + timeline.dump().c_str(), + main_error); + } else if (type == "setConstraint") { applied = web_engine_blend_main_set_constraint( engine->authoritative_main, command.value("objectId", "").c_str(), @@ -2089,6 +2249,72 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, properties, main_error); } + else if (type == "setArmatureBones") { + auto read_vec3 = [](const json &value, std::array &target) { + if (!value.is_array() || value.size() != 3) return false; + for (int index = 0; index < 3; index++) { + if (!value[index].is_number()) return false; + target[index] = value[index].get(); + } + return true; + }; + std::vector bones; + bool valid = command["bones"].is_array(); + for (const json &item : command["bones"]) { + WebArmatureBoneEdit bone{}; + bone.name = item.value("name", ""); + bone.parent_name = item["parentName"].is_null() ? "" : item.value("parentName", ""); + bone.roll = item.value("roll", 0.0f); + bone.connected = item.value("connected", false); + valid = valid && item.is_object() && !bone.name.empty() && item["roll"].is_number() && + (item["parentName"].is_null() || item["parentName"].is_string()) && + read_vec3(item["head"], bone.head) && read_vec3(item["tail"], bone.tail); + bones.push_back(std::move(bone)); + } + if (!valid) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, + "ARMATURE_BONES_INVALID: bones require names, parent names, head/tail and roll"); + return last_error_code; + } + if (!web_engine_blend_main_set_armature_bones(engine->authoritative_main, + command.value("objectId", "").c_str(), + bones, + main_error)) + { + set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); + return last_error_code; + } + } + else if (type == "setPoseBoneTransform") { + auto read_vec3 = [](const json &value, std::array &target) { + if (!value.is_array() || value.size() != 3) return false; + for (int index = 0; index < 3; index++) { + if (!value[index].is_number()) return false; + target[index] = value[index].get(); + } + return true; + }; + std::array translation, rotation_euler, scale; + if (!read_vec3(command["translation"], translation) || + !read_vec3(command["rotationEuler"], rotation_euler) || + !read_vec3(command["scale"], scale)) + { + set_error(WEB_ENGINE_INVALID_ARGUMENT, + "POSE_BONE_TRANSFORM_INVALID: transform fields must contain three numbers"); + return last_error_code; + } + if (!web_engine_blend_main_set_pose_bone_transform(engine->authoritative_main, + command.value("objectId", "").c_str(), + command.value("boneName", "").c_str(), + translation, + rotation_euler, + scale, + main_error)) + { + set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); + return last_error_code; + } + } else if (type == "setMetaballElements") { std::vector elements; const json source_elements = command.value("elements", json::array()); @@ -2425,7 +2651,69 @@ EMSCRIPTEN_KEEPALIVE int web_engine_call_json(const int handle, return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length); } json result; - if (cmd == "applyCommand") { + if (cmd == "listBlenderOperators") { + std::string catalog_json; + std::string catalog_error; + if (!web_engine_blend_main_list_operators(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) { + set_error(WEB_ENGINE_NOT_IMPLEMENTED, "OPERATOR_CONTEXT_INVALID: operator 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("operator") || !params["operator"].is_string()) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "invokeBlenderOperator requires string operator"); + 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()) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "invokeBlenderOperator properties must be an object"); + 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() != "operator" && item.key() != "properties" && item.key() != "baseRevision") { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "invokeBlenderOperator 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 std::string properties_text = properties.dump(); + std::string command_json; + std::string operator_error; + if (!web_engine_blend_main_prepare_operator(engine->authoritative_main, + params["operator"].get().c_str(), + properties_text.c_str(), + command_json, + operator_error)) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, operator_error.c_str()); + return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length); + } + json command = json::parse(command_json); + if (params.contains("baseRevision")) { + if (!params["baseRevision"].is_number_unsigned() && !params["baseRevision"].is_number_integer()) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "REVISION_CONFLICT: baseRevision must be an integer"); + return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length); + } + const int64_t base_revision = params["baseRevision"].get(); + if (base_revision < 0) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "REVISION_CONFLICT: baseRevision must be non-negative"); + return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length); + } + command["baseRevision"] = static_cast(base_revision); + } + const std::string command_text = command.dump(); + if (web_engine_apply_command(handle, + reinterpret_cast(command_text.data()), + static_cast(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 = {{"status", "invoked"}, {"operator", params["operator"]}}; + } + else if (cmd == "applyCommand") { if (!params.contains("command") || !params["command"].is_object()) { set_error(WEB_ENGINE_INVALID_ARGUMENT, "applyCommand params.command must be an object"); return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length); @@ -2439,6 +2727,34 @@ EMSCRIPTEN_KEEPALIVE int web_engine_call_json(const int handle, } result = {{"status", "applied"}}; } + else if (cmd == "importScene" || cmd == "exportScene") { + EngineState *engine = find_engine(handle); + const std::string format = params.value("format", ""); + const bool local_format = format == "GLTF" || format == "GLB" || format == "OBJ" || format == "STL" || format == "PLY"; + if (engine == nullptr) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "IO_FORMAT_PROJECT_UNAVAILABLE: no WebEngine scene is open"); + return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length); + } + if (!local_format) { + set_error(WEB_ENGINE_NOT_IMPLEMENTED, "SERVER_JOB_UNAVAILABLE: this format requires the configured server job provider"); + return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}, {"route", "SERVER"}, {"format", format}}, result_data, result_length); + } + if (cmd == "importScene") { + const std::string base64 = params.value("base64", ""); + if (base64.empty() || base64.size() > 96u * 1024u * 1024u) { + set_error(WEB_ENGINE_INVALID_ARGUMENT, "IO_FORMAT_INPUT_INVALID: importScene base64 payload is empty or exceeds 64 MiB"); + return write_json_result({{"ok", false}, {"cmd", cmd}, {"code", last_error_code}, {"error", last_error_message}}, result_data, result_length); + } + } + result = {{"status", "ROUTED"}, + {"route", "LOCAL"}, + {"format", format}, + {"operation", cmd == "importScene" ? "IMPORT" : "EXPORT"}, + {"lossReport", {{"schemaVersion", 1}, + {"operation", cmd == "importScene" ? "SCENE_IMPORT_LOSS_REPORT" : "SCENE_EXPORT_LOSS_REPORT"}, + {"warningCount", 0}, + {"warnings", json::array()}}}}; + } else if (cmd == "snapshot" || cmd == "delta" || cmd == "memoryStats") { EngineState *engine = find_engine(handle); if (engine == nullptr) { diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp index 296cd0659..ee53f21a7 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp @@ -3,14 +3,17 @@ #include #include +#include #include #include #include #include #include +#include #include #include #include +#include #include #include @@ -42,7 +45,9 @@ #include "BKE_object.hh" #include "BKE_object_deform.h" #include "BKE_packedFile.hh" +#include "BKE_particle.h" #include "BKE_scene.hh" +#include "BKE_speaker.hh" #include "BKE_rigidbody.h" #include "BKE_text.h" #include "BKE_vfont.hh" @@ -71,6 +76,8 @@ #include "DNA_material_types.h" #include "DNA_meshdata_types.h" #include "DNA_modifier_types.h" +#include "RNA_access.hh" +#include "RNA_prototypes.hh" #include "DNA_node_types.h" #include "DNA_object_force_types.h" #include "DNA_object_types.h" @@ -100,6 +107,12 @@ #include "bmesh.hh" #include "bmesh_tools.hh" +#include "SEQ_add.hh" +#include "SEQ_edit.hh" +#include "SEQ_relations.hh" +#include "SEQ_sequencer.hh" +#include "SEQ_utils.hh" + struct WebBlendMainState { blender::BlendFileData *blend = nullptr; blender::Main *main = nullptr; @@ -107,6 +120,7 @@ struct WebBlendMainState { blender::ViewLayer *view_layer = nullptr; std::unordered_map geometry_node_graph_overrides; std::string physics_simulation_override; + std::unordered_map retained_media_metadata; }; namespace { @@ -143,8 +157,307 @@ std::string id_name(const ID &id) return id.name[0] != '\0' && id.name[1] != '\0' ? std::string(id.name + 2) : std::string(); } +struct WebOperatorSpec { + const char *id; + const char *family; + const char *context; + std::vector> properties; +}; + +const std::vector &web_operator_specs() +{ + static const std::vector specs = { + {"scene.frame_set", "SCENE", "scene", {{"frame", "integer"}}}, + {"object.hide_view_set", "OBJECT", "object", {{"objectId", "string"}, {"visible", "boolean"}}}, + {"object.delete", "OBJECT", "object", {{"objectId", "string"}}}, + {"object.duplicate", "OBJECT", "object", {{"objectId", "string"}, {"offset", "float[3]"}, {"linked", "boolean"}}}, + {"object.transform_apply", "OBJECT", "object", {{"objectId", "string"}}}, + {"object.voxel_remesh", "SCULPT", "object", {{"objectId", "string"}, {"voxel_size", "float"}}}, + {"object.join", "OBJECT", "object", {{"activeObjectId", "string"}, {"objectIds", "string[]"}}}, + {"mesh.primitive_cube_add", "MESH", "main", {{"name", "string"}, {"location", "float[3]"}}}, + {"mesh.primitive_plane_add", "MESH", "main", {{"name", "string"}, {"location", "float[3]"}}}, + {"mesh.primitive_uv_sphere_add", "MESH", "main", {{"name", "string"}, {"location", "float[3]"}}}, + {"mesh.primitive_ico_sphere_add", "MESH", "main", {{"name", "string"}, {"location", "float[3]"}}}, + {"mesh.primitive_cylinder_add", "MESH", "main", {{"name", "string"}, {"location", "float[3]"}}}, + {"mesh.primitive_cone_add", "MESH", "main", {{"name", "string"}, {"location", "float[3]"}}}, + {"mesh.primitive_torus_add", "MESH", "main", {{"name", "string"}, {"location", "float[3]"}}}, + }; + return specs; +} + +const WebOperatorSpec *find_web_operator_spec(const std::string &operator_id) +{ + for (const WebOperatorSpec &spec : web_operator_specs()) { + if (operator_id == spec.id) return &spec; + } + return nullptr; +} + +bool web_operator_is_ui_only(const std::string &operator_id) +{ + static constexpr const char *prefixes[] = {"wm.", "window.", "screen.", "file.", "outliner.", "view3d."}; + for (const char *prefix : prefixes) { + if (operator_id.rfind(prefix, 0) == 0) return true; + } + return false; +} + +json web_operator_catalog_json() +{ + json catalog = json::array(); + for (const WebOperatorSpec &spec : web_operator_specs()) { + json properties = json::array(); + for (const auto &[name, type] : spec.properties) { + const bool required = std::strcmp(spec.id, "object.duplicate") != 0 && + std::strncmp(spec.id, "mesh.primitive_", 15) != 0 && + std::strcmp(name, "offset") != 0 && std::strcmp(name, "linked") != 0; + properties.push_back({{"name", name}, {"type", type}, {"required", required}}); + } + catalog.push_back({{"id", spec.id}, + {"family", spec.family}, + {"context", spec.context}, + {"backgroundSafe", true}, + {"properties", std::move(properties)}}); + } + return catalog; +} + +bool web_operator_property_keys(const json &properties, + const std::vector &allowed, + std::string &error) +{ + if (!properties.is_object()) { + error = "OPERATOR_PROPERTIES_INVALID: properties must be a JSON object"; + return false; + } + for (const auto &item : properties.items()) { + if (std::find(allowed.begin(), allowed.end(), item.key()) == allowed.end()) { + error = "OPERATOR_PROPERTIES_INVALID: unknown property '" + item.key() + "'"; + return false; + } + } + return true; +} + +bool web_operator_number3(const json &value, std::vector &output) +{ + if (!value.is_array() || value.size() != 3) return false; + output.clear(); + for (const json &item : value) { + if (!item.is_number() || !std::isfinite(item.get())) return false; + output.push_back(item.get()); + } + return true; +} + +bool web_operator_object_exists(WebBlendMainState *state, const std::string &object_id) +{ + if (state == nullptr || state->main == nullptr || object_id.empty()) return false; + LISTBASE_FOREACH (Object *, object, &state->main->objects) { + const std::string name = id_name(object->id); + if (object_id == name || object_id == "object:" + name) return true; + } + return false; +} + +bool web_engine_blend_main_list_operators(std::string &catalog_json, std::string &error) +{ + try { + catalog_json = web_operator_catalog_json().dump(); + return true; + } + catch (const std::exception &exception) { + error = std::string("OPERATOR_CATALOG_FAILED: ") + exception.what(); + return false; + } +} + +bool web_engine_blend_main_prepare_operator(WebBlendMainState *state, + const char *operator_id, + const char *properties_json, + std::string &command_json, + std::string &error) +{ + if (state == nullptr || state->main == nullptr || state->scene == nullptr) { + error = "OPERATOR_CONTEXT_INVALID: authoritative Main and Scene are required"; + return false; + } + if (operator_id == nullptr || std::strlen(operator_id) == 0) { + error = "OPERATOR_NOT_ALLOWLISTED: operator identifier is empty"; + return false; + } + const std::string id(operator_id); + if (web_operator_is_ui_only(id)) { + error = "OPERATOR_UI_UNSUPPORTED: UI, window, and file-browser operators use browser-native workflows"; + return false; + } + const WebOperatorSpec *spec = find_web_operator_spec(id); + if (spec == nullptr) { + error = "OPERATOR_NOT_ALLOWLISTED: operator is not in the WebEngine background-safe 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("OPERATOR_PROPERTIES_INVALID: ") + exception.what(); + return false; + } + std::vector allowed; + for (const auto &property : spec->properties) { + allowed.emplace_back(property.first); + } + if (!web_operator_property_keys(properties, allowed, error)) return false; + json command; + if (id == "scene.frame_set") { + if (!properties.contains("frame") || !properties["frame"].is_number_integer()) { + error = "OPERATOR_PROPERTIES_INVALID: scene.frame_set.frame must be an integer"; + return false; + } + command = {{"type", "setFrame"}, {"frame", properties["frame"]}}; + } + else if (id == "object.hide_view_set") { + if (!properties.contains("objectId") || !properties["objectId"].is_string() || + !properties.contains("visible") || !properties["visible"].is_boolean()) { + error = "OPERATOR_PROPERTIES_INVALID: object.hide_view_set requires objectId and visible"; + return false; + } + const std::string object_id = properties["objectId"].get(); + if (!web_operator_object_exists(state, object_id)) { + error = "OPERATOR_POLL_FAILED: object.hide_view_set object does not exist"; + return false; + } + command = {{"type", "setObjectVisibility"}, {"objectId", object_id}, {"visible", properties["visible"]}}; + } + else if (id == "object.delete") { + if (!properties.contains("objectId") || !properties["objectId"].is_string()) { + error = "OPERATOR_PROPERTIES_INVALID: object.delete requires objectId"; + return false; + } + const std::string object_id = properties["objectId"].get(); + if (!web_operator_object_exists(state, object_id)) { + error = "OPERATOR_POLL_FAILED: object.delete object does not exist"; + return false; + } + command = {{"type", "deleteObject"}, {"objectId", object_id}}; + } + else if (id == "object.duplicate") { + if (!properties.contains("objectId") || !properties["objectId"].is_string()) { + error = "OPERATOR_PROPERTIES_INVALID: object.duplicate requires objectId"; + return false; + } + std::vector offset = {0.0f, 0.0f, 0.0f}; + if (properties.contains("offset") && !web_operator_number3(properties["offset"], offset)) { + error = "OPERATOR_PROPERTIES_INVALID: object.duplicate.offset must be finite float[3]"; + return false; + } + const std::string object_id = properties["objectId"].get(); + if (!web_operator_object_exists(state, object_id)) { + error = "OPERATOR_POLL_FAILED: object.duplicate object does not exist"; + return false; + } + command = {{"type", "duplicateObject"}, {"objectId", object_id}, {"offset", offset}, {"linked", properties.value("linked", false)}}; + } + else if (id == "object.transform_apply") { + if (!properties.contains("objectId") || !properties["objectId"].is_string()) { + error = "OPERATOR_PROPERTIES_INVALID: object.transform_apply requires objectId"; + return false; + } + const std::string object_id = properties["objectId"].get(); + if (!web_operator_object_exists(state, object_id)) { + error = "OPERATOR_POLL_FAILED: object.transform_apply object does not exist"; + return false; + } + command = {{"type", "applyObjectTransform"}, {"objectId", object_id}}; + } + else if (id == "object.voxel_remesh") { + if (!properties.contains("objectId") || !properties["objectId"].is_string() || + !properties.contains("voxel_size") || !properties["voxel_size"].is_number()) { + error = "OPERATOR_PROPERTIES_INVALID: object.voxel_remesh requires objectId and voxel_size"; + return false; + } + const std::string object_id = properties["objectId"].get(); + const float voxel_size = properties["voxel_size"].get(); + if (!std::isfinite(voxel_size) || voxel_size <= 0.0f || voxel_size > 10.0f) { + error = "OPERATOR_PROPERTIES_INVALID: object.voxel_remesh.voxel_size is outside the bounded range"; + return false; + } + if (!web_operator_object_exists(state, object_id)) { + error = "OPERATOR_POLL_FAILED: object.voxel_remesh object does not exist"; + return false; + } + command = {{"type", "sculptStroke"}, + {"stroke", {{"schemaVersion", 1}, + {"meshId", object_id}, + {"brush", "SMOOTH"}, + {"samples", {{{"position", {0.0f, 0.0f, 0.0f}}, + {"normal", {0.0f, 0.0f, 1.0f}}, + {"radius", 0.001f}, + {"strength", 0.0f}, + {"pressure", 0.0f}, + {"time", 0.0f}}}}, + {"symmetry", {false, false, false}}, + {"mirrorObjectSpace", true}, + {"remeshVoxelSize", voxel_size}}}}; + } + else if (id == "object.join") { + if (!properties.contains("activeObjectId") || !properties["activeObjectId"].is_string() || + !properties.contains("objectIds") || !properties["objectIds"].is_array() || + properties["objectIds"].size() < 2 || properties["objectIds"].size() > 1024) { + error = "OPERATOR_PROPERTIES_INVALID: object.join requires activeObjectId and at least two objectIds"; + return false; + } + const std::string active_id = properties["activeObjectId"].get(); + std::vector object_ids; + for (const json &value : properties["objectIds"]) { + if (!value.is_string()) { + error = "OPERATOR_PROPERTIES_INVALID: object.join.objectIds must contain strings"; + return false; + } + object_ids.push_back(value.get()); + if (!web_operator_object_exists(state, object_ids.back())) { + error = "OPERATOR_POLL_FAILED: object.join object does not exist"; + return false; + } + } + if (!web_operator_object_exists(state, active_id)) { + error = "OPERATOR_POLL_FAILED: object.join active object does not exist"; + return false; + } + command = {{"type", "joinObjects"}, {"activeObjectId", active_id}, {"objectIds", object_ids}}; + } + else { + if (properties.contains("name") && (!properties["name"].is_string() || properties["name"].get().size() > 63)) { + error = "OPERATOR_PROPERTIES_INVALID: primitive name must be a bounded string"; + return false; + } + std::vector location = {0.0f, 0.0f, 0.0f}; + if (properties.contains("location") && !web_operator_number3(properties["location"], location)) { + error = "OPERATOR_PROPERTIES_INVALID: primitive location must be finite float[3]"; + return false; + } + command = {{"type", "createPrimitive"}, + {"primitive", + id == "mesh.primitive_cube_add" ? "CUBE" : + id == "mesh.primitive_plane_add" ? "PLANE" : + id == "mesh.primitive_uv_sphere_add" ? "UV_SPHERE" : + id == "mesh.primitive_ico_sphere_add" ? "ICO_SPHERE" : + id == "mesh.primitive_cylinder_add" ? "CYLINDER" : + id == "mesh.primitive_cone_add" ? "CONE" : "TORUS"}, + {"location", location}}; + if (properties.contains("name")) command["name"] = properties["name"]; + } + command_json = command.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"; constexpr char WEB_ENGINE_PHYSICS_OVERRIDE_TEXT[] = "WBV2 Physics Simulation Override"; +constexpr char WEB_ENGINE_PHYSICS_OVERRIDE_PROPERTY[] = "web_physics_simulation_override"; +constexpr char WEB_ENGINE_MEDIA_RETENTION_TEXT[] = "WBV2 Media Datablock Retention"; std::string text_contents(Text &text) { @@ -192,40 +505,82 @@ bool write_id_string(ID &id, const char *name, const std::string &contents) return true; } +std::string read_id_string(const ID &id, const char *name) +{ + IDProperty *group = IDP_GetProperties(const_cast(&id)); + IDProperty *property = group != nullptr ? IDP_GetPropertyFromGroup_null(group, name) : nullptr; + if (property == nullptr || property->type != IDP_STRING) return {}; + return std::string(IDP_string_get(property)); +} + void load_persisted_geometry_overrides(WebBlendMainState *state) { if (state == nullptr || state->main == nullptr) return; - Text *text = find_text(state->main, WEB_ENGINE_GEOMETRY_OVERRIDE_TEXT); - if (text == nullptr) return; - try { - const json payload = json::parse(text_contents(*text)); - if (!payload.is_object()) return; - for (const auto &[graph_id, graph] : payload.items()) { - if (graph.is_object()) state->geometry_node_graph_overrides[graph_id] = graph.dump(); + const auto merge = [&](const std::string &serialized) { + if (serialized.empty()) return; + try { + const json payload = json::parse(serialized); + if (!payload.is_object()) return; + if (payload.contains("id") && payload["id"].is_string()) { + state->geometry_node_graph_overrides[payload["id"].get()] = payload.dump(); + return; + } + for (const auto &[graph_id, graph] : payload.items()) { + if (graph.is_object()) state->geometry_node_graph_overrides[graph_id] = graph.dump(); + } + } + catch (...) { + /* Invalid internal data is ignored; regular Main data remains authoritative. */ + } + }; + if (state->scene != nullptr) merge(read_id_string(state->scene->id, WEB_ENGINE_GEOMETRY_OVERRIDE_PROPERTY)); + /* Keep reading the legacy Text slot when opening desktop-authored files, but + * never require Text creation in the Web build. */ + if (Text *text = find_text(state->main, WEB_ENGINE_GEOMETRY_OVERRIDE_TEXT)) merge(text_contents(*text)); + for (Object &object : state->main->objects) { + merge(read_id_string(object.id, WEB_ENGINE_GEOMETRY_OVERRIDE_PROPERTY)); + merge(read_id_string(object.id, WEB_ENGINE_GEOMETRY_GRAPH_PROPERTY)); + if (object.data != nullptr) { + merge(read_id_string(*object.data, WEB_ENGINE_GEOMETRY_OVERRIDE_PROPERTY)); + merge(read_id_string(*object.data, WEB_ENGINE_GEOMETRY_GRAPH_PROPERTY)); } - } - catch (...) { - /* Invalid internal text is ignored; regular Main data remains authoritative. */ } } void load_persisted_physics_override(WebBlendMainState *state) { if (state == nullptr || state->main == nullptr || !state->physics_simulation_override.empty()) return; + if (state->scene != nullptr) { + const std::string persisted = read_id_string(state->scene->id, WEB_ENGINE_PHYSICS_OVERRIDE_PROPERTY); + if (!persisted.empty()) { + state->physics_simulation_override = persisted; + return; + } + } Text *text = find_text(state->main, WEB_ENGINE_PHYSICS_OVERRIDE_TEXT); if (text == nullptr) return; const std::string contents = text_contents(*text); if (!contents.empty()) state->physics_simulation_override = contents; } -bool persist_geometry_overrides(WebBlendMainState *state) +bool persist_geometry_overrides(WebBlendMainState *state, ID *preferred_owner = nullptr) { json payload = json::object(); for (const auto &[graph_id, graph_text] : state->geometry_node_graph_overrides) { try { payload[graph_id] = json::parse(graph_text); } catch (...) { return false; } } - return write_text(state->main, WEB_ENGINE_GEOMETRY_OVERRIDE_TEXT, payload.dump()); + const std::string serialized = payload.dump(); + if (preferred_owner != nullptr && write_id_string(*preferred_owner, WEB_ENGINE_GEOMETRY_OVERRIDE_PROPERTY, serialized)) { + return true; + } + if (state->scene != nullptr && write_id_string(state->scene->id, WEB_ENGINE_GEOMETRY_OVERRIDE_PROPERTY, serialized)) { + return true; + } + for (Object &object : state->main->objects) { + if (write_id_string(object.id, WEB_ENGINE_GEOMETRY_OVERRIDE_PROPERTY, serialized)) return true; + } + return write_text(state->main, WEB_ENGINE_GEOMETRY_OVERRIDE_TEXT, serialized); } Object *find_object(Main *main, const char *object_id) @@ -307,24 +662,128 @@ bSound *find_sound(Main *main, const char *data_id) { if (main == nullptr || data_id == nullptr || strncmp(data_id, "sound:", 6) != 0) return nullptr; const std::string name(data_id + 6); - for (bSound &sound : main->sounds) if (id_name(sound.id) == name) return &sound; - return nullptr; + ID *id = BKE_libblock_find_name(main, ID_SO, name.c_str(), std::nullopt); + return id != nullptr ? reinterpret_cast(id) : nullptr; } Speaker *find_speaker(Main *main, const char *data_id) { if (main == nullptr || data_id == nullptr || strncmp(data_id, "speaker:", 8) != 0) return nullptr; const std::string name(data_id + 8); - for (Speaker &speaker : main->speakers) if (id_name(speaker.id) == name) return &speaker; - return nullptr; + ID *id = BKE_libblock_find_name(main, ID_SPK, name.c_str(), std::nullopt); + return id != nullptr ? reinterpret_cast(id) : nullptr; } ParticleSettings *find_particle_settings(Main *main, const char *data_id) { if (main == nullptr || data_id == nullptr || strncmp(data_id, "particle-settings:", 18) != 0) return nullptr; const std::string name(data_id + 18); - for (ParticleSettings &settings : main->particles) if (id_name(settings.id) == name) return &settings; - return nullptr; + ID *id = BKE_libblock_find_name(main, ID_PA, name.c_str(), std::nullopt); + return id != nullptr ? reinterpret_cast(id) : nullptr; +} + +const std::string *retained_media_record(const WebBlendMainState *state, const char *data_id) +{ + if (state == nullptr || data_id == nullptr) return nullptr; + const auto it = state->retained_media_metadata.find(data_id); + return it == state->retained_media_metadata.end() ? nullptr : &it->second; +} + +template T *adopt_web_id(Main *main, const char *name) +{ + if (main == nullptr || name == nullptr || name[0] == '\0') return nullptr; + T *id = BKE_id_new_nomain(name); + if (id == nullptr) return nullptr; + id->id.tag &= ~ID_TAG_NO_MAIN; + id->id.us = 1; + BLI_addtail(which_libbase(main, T::id_type), &id->id); + BKE_lib_libblock_session_uid_ensure(&id->id); + return id; +} + +bSound *ensure_sound(WebBlendMainState *state, const char *data_id) +{ + if (state == nullptr || data_id == nullptr) return nullptr; + if (bSound *sound = find_sound(state->main, data_id)) return sound; + const std::string *record_text = retained_media_record(state, data_id); + if (record_text == nullptr) return nullptr; + try { + const json record = json::parse(*record_text); + GlobalMainScope global_main_scope(state->main); + const std::string name = std::string(data_id).substr(6); + bSound *sound = adopt_web_id(state->main, name.c_str()); + if (sound == nullptr) return nullptr; + STRNCPY(sound->filepath, record.value("sourcePath", "").c_str()); + sound->volume = record.value("volume", 0.0f); + sound->pitch = record.value("pitch", 0.0f); + sound->audio_channels = record.value("audioChannels", 0); + sound->samplerate = record.value("sampleRate", 0); + return sound; + } + catch (...) { + return nullptr; + } +} + +Speaker *ensure_speaker(WebBlendMainState *state, const char *data_id) +{ + if (state == nullptr || data_id == nullptr) return nullptr; + if (Speaker *speaker = find_speaker(state->main, data_id)) return speaker; + const std::string *record_text = retained_media_record(state, data_id); + if (record_text == nullptr) return nullptr; + try { + const json record = json::parse(*record_text); + GlobalMainScope global_main_scope(state->main); + const std::string name = std::string(data_id).substr(8); + Speaker *speaker = adopt_web_id(state->main, name.c_str()); + if (speaker == nullptr) return nullptr; + speaker->volume_max = record.value("volumeMax", 1.0f); + speaker->volume_min = record.value("volumeMin", 0.0f); + speaker->distance_max = record.value("distanceMax", FLT_MAX); + speaker->distance_reference = record.value("distanceReference", 1.0f); + speaker->attenuation = record.value("attenuation", 1.0f); + speaker->cone_angle_outer = record.value("coneAngleOuter", 360.0f); + speaker->cone_angle_inner = record.value("coneAngleInner", 360.0f); + speaker->cone_volume_outer = record.value("coneVolumeOuter", 1.0f); + speaker->volume = record.value("volume", 1.0f); + speaker->pitch = record.value("pitch", 1.0f); + if (record.contains("soundId") && record["soundId"].is_string()) { + speaker->sound = ensure_sound(state, record["soundId"].get().c_str()); + } + return speaker; + } + catch (...) { + return nullptr; + } +} + +ParticleSettings *ensure_particle_settings(WebBlendMainState *state, const char *data_id) +{ + if (state == nullptr || data_id == nullptr) return nullptr; + if (ParticleSettings *settings = find_particle_settings(state->main, data_id)) return settings; + const std::string *record_text = retained_media_record(state, data_id); + if (record_text == nullptr) return nullptr; + try { + const json record = json::parse(*record_text); + GlobalMainScope global_main_scope(state->main); + const std::string name = std::string(data_id).substr(18); + ParticleSettings *settings = adopt_web_id(state->main, name.c_str()); + if (settings == nullptr) return nullptr; + settings->type = eParticleType(record.value("type", 0) == 1 ? 2 : 0); + settings->from = eParticleFrom(record.value("from", 0) == 0 ? 1 : record.value("from", 0) == 1 ? 0 : 2); + settings->distr = eParticleDistribution(record.value("distribution", 0)); + settings->phystype = eParticlePhysicsType(record.value("physicsType", 0)); + settings->totpart = record.value("totalParticles", 0); + settings->sta = record.value("start", 0.0f); + settings->end = record.value("end", 0.0f); + settings->lifetime = record.value("lifetime", 0.0f); + settings->size = record.value("size", 0.0f); + settings->draw_size = record.value("drawSize", 0.0f); + return settings; + } + catch (...) { + return nullptr; + } } Tex *find_texture(Main *main, const char *data_id) @@ -1015,6 +1474,123 @@ void web_engine_blend_main_free(WebBlendMainState *state) delete state; } +bool web_engine_blend_main_retain_media_metadata(WebBlendMainState *state, + const char *snapshot_json, + std::string &error) +{ + if (state == nullptr || state->main == nullptr || snapshot_json == nullptr) { + error = "authoritative Main media retention requires an open state and SceneIR payload"; + return false; + } + try { + const json snapshot = json::parse(snapshot_json); + state->retained_media_metadata.clear(); + const std::string persisted = state->scene != nullptr ? read_id_string(state->scene->id, "web_media_datablock_retention") : std::string(); + if (!persisted.empty()) { + try { + const json stored = json::parse(persisted); + if (stored.is_object()) { + for (const auto &[id, value] : stored.items()) if (value.is_object()) state->retained_media_metadata[id] = value.dump(); + } + } + catch (...) { + state->retained_media_metadata.clear(); + } + } + for (const char *field : {"sounds", "speakers", "particleSettings"}) { + const auto value = snapshot.find(field); + if (value == snapshot.end() || !value->is_array()) continue; + for (const json &record : *value) { + if (!record.is_object() || !record.contains("id") || !record["id"].is_string()) continue; + const std::string id = record["id"].get(); + if ((std::strcmp(field, "sounds") == 0 && id.rfind("sound:", 0) != 0) || + (std::strcmp(field, "speakers") == 0 && id.rfind("speaker:", 0) != 0) || + (std::strcmp(field, "particleSettings") == 0 && id.rfind("particle-settings:", 0) != 0)) { + continue; + } + state->retained_media_metadata[id] = record.dump(); + } + } + json stored = json::object(); + for (const auto &[id, value] : state->retained_media_metadata) stored[id] = json::parse(value); + if (!stored.empty()) { + if (state->scene == nullptr || !write_id_string(state->scene->id, "web_media_datablock_retention", stored.dump())) { + error = "BLEND_WRITE_FAILED: Web media retention ID property could not be written"; + return false; + } + } + return true; + } + catch (const std::exception &exception) { + error = std::string("authoritative Main media retention payload is invalid: ") + exception.what(); + return false; + } +} + +bool web_engine_blend_main_merge_media_snapshot(WebBlendMainState *state, + std::string &snapshot_json, + std::string &error) +{ + if (state == nullptr || state->main == nullptr) { + error = "authoritative Main media merge requires an open state"; + return false; + } + try { + json snapshot = json::parse(snapshot_json); + for (const char *field : {"sounds", "speakers", "particleSettings"}) { + json &items = snapshot[field]; + if (!items.is_array()) items = json::array(); + std::unordered_map indices; + for (size_t index = 0; index < items.size(); index++) if (items[index].is_object() && items[index].contains("id") && items[index]["id"].is_string()) indices[items[index]["id"].get()] = index; + for (const auto &[id, value] : state->retained_media_metadata) { + const json record = json::parse(value); + const bool matches = (std::strcmp(field, "sounds") == 0 && id.rfind("sound:", 0) == 0) || + (std::strcmp(field, "speakers") == 0 && id.rfind("speaker:", 0) == 0) || + (std::strcmp(field, "particleSettings") == 0 && id.rfind("particle-settings:", 0) == 0); + if (!matches) continue; + if (const auto it = indices.find(id); it != indices.end()) items[it->second] = record; + else items.push_back(record); + } + } + snapshot_json = snapshot.dump(); + return true; + } + catch (const std::exception &exception) { + error = std::string("authoritative Main media merge failed: ") + exception.what(); + return false; + } +} + +bool update_retained_media_record(WebBlendMainState *state, + const char *data_id, + const std::function &update, + std::string &error) +{ + if (state == nullptr || data_id == nullptr) return false; + const auto it = state->retained_media_metadata.find(data_id); + if (it == state->retained_media_metadata.end()) { + error = "NON_MESH_DATA_UNSUPPORTED: retained media ID was not registered: " + std::string(data_id); + return false; + } + try { + json record = json::parse(it->second); + update(record); + it->second = record.dump(); + json stored = json::object(); + for (const auto &[id, value] : state->retained_media_metadata) stored[id] = json::parse(value); + const std::string stored_text = stored.dump(); + if (state->scene == nullptr || !write_id_string(state->scene->id, "web_media_datablock_retention", stored_text)) { + error = "BLEND_WRITE_FAILED: Web media retention Text datablock could not be written; retained=" + std::to_string(state->retained_media_metadata.size()) + ", main=" + std::to_string(state->main != nullptr) + ", bytes=" + std::to_string(stored_text.size()); + return false; + } + return true; + } + catch (const std::exception &exception) { + error = std::string("NON_MESH_PROPERTY_INVALID: retained media record is invalid: ") + exception.what(); + return false; + } +} + bool web_engine_blend_main_set_frame(WebBlendMainState *state, const int frame, std::string &error) @@ -1045,6 +1621,175 @@ bool web_engine_blend_main_set_visibility(WebBlendMainState *state, return true; } +bool web_engine_blend_main_add_modifier(WebBlendMainState *state, + const char *mesh_id, + const char *modifier_type, + const char *name, + std::string &modifier_uuid, + std::string &error) +{ + Object *object = find_mesh_object(state != nullptr ? state->main : nullptr, mesh_id); + if (object == nullptr || object->id.lib != nullptr || modifier_type == nullptr || modifier_type[0] == '\0') { + error = "meshId or modifierType was not found in editable authoritative Main"; + return false; + } + const eModifierType type = BKE_modifiertype_from_idname(modifier_type); + if (type == eModifierType_None) { + error = "modifierType is not supported by Blender metadata"; + return false; + } + ModifierData *modifier = BKE_modifier_new(type); + if (modifier == nullptr) { + error = "Blender could not allocate the requested modifier"; + return false; + } + if (name != nullptr && name[0] != '\0') STRNCPY_UTF8(modifier->name, name); + BKE_modifier_unique_name(&object->modifiers, modifier); + BKE_modifiers_persistent_uid_init(*object, modifier); + BLI_addtail(&object->modifiers, modifier); + object->id.recalc |= ID_RECALC_GEOMETRY; + modifier_uuid = std::string(object->id.name + 2) + ":" + std::to_string(modifier->persistent_uid); + return true; +} + +bool web_engine_blend_main_remove_modifier(WebBlendMainState *state, + const char *mesh_id, + const char *modifier_uuid, + std::string &error) +{ + Object *object = find_mesh_object(state != nullptr ? state->main : nullptr, mesh_id); + const int uid = modifier_uid_from_uuid(modifier_uuid); + if (object == nullptr || object->id.lib != nullptr || uid <= 0) { + error = "meshId or modifierUuid was not found in editable authoritative Main"; + return false; + } + for (ModifierData &modifier : object->modifiers) { + if (modifier.persistent_uid != uid) continue; + BLI_remlink(&object->modifiers, &modifier); + BKE_modifier_free(&modifier); + object->id.recalc |= ID_RECALC_GEOMETRY; + return true; + } + error = "modifierUuid was not found in authoritative Main"; + return false; +} + +bool web_engine_blend_main_move_modifier(WebBlendMainState *state, + const char *mesh_id, + const char *modifier_uuid, + const int index, + const char *direction, + std::string &error) +{ + Object *object = find_mesh_object(state != nullptr ? state->main : nullptr, mesh_id); + const int uid = modifier_uid_from_uuid(modifier_uuid); + if (object == nullptr || object->id.lib != nullptr || uid <= 0) { + error = "meshId or modifierUuid was not found in editable authoritative Main"; + return false; + } + ModifierData *target = nullptr; + int current_index = 0; + int count = 0; + for (ModifierData &modifier : object->modifiers) { + if (modifier.persistent_uid == uid) target = &modifier; + ++count; + } + if (target == nullptr) { + error = "modifierUuid was not found in authoritative Main"; + return false; + } + for (ModifierData *modifier = static_cast(object->modifiers.first); modifier != nullptr && modifier != target; modifier = modifier->next) ++current_index; + int target_index = index; + if (target_index < 0) target_index = current_index + ((direction != nullptr && STREQ(direction, "UP")) ? -1 : 1); + target_index = std::clamp(target_index, 0, std::max(0, count - 1)); + if (target_index == current_index) return true; + BLI_remlink(&object->modifiers, target); + ModifierData *anchor = static_cast(object->modifiers.first); + for (int i = 0; anchor != nullptr && i < target_index; ++i) anchor = anchor->next; + if (anchor == nullptr) BLI_addtail(&object->modifiers, target); + else BLI_insertlinkbefore(&object->modifiers, anchor, target); + object->id.recalc |= ID_RECALC_GEOMETRY; + return true; +} + +bool web_engine_blend_main_set_modifier_parameters(WebBlendMainState *state, + const char *mesh_id, + const char *modifier_uuid, + const char *parameters_json, + std::string &error) +{ + Object *object = find_mesh_object(state != nullptr ? state->main : nullptr, mesh_id); + const int uid = modifier_uid_from_uuid(modifier_uuid); + if (object == nullptr || object->id.lib != nullptr || uid <= 0 || parameters_json == nullptr) { + error = "meshId, modifierUuid or parameters was not found in editable authoritative Main"; + return false; + } + json parameters; + try { + parameters = json::parse(parameters_json); + } + catch (...) { + error = "modifier parameters must be a JSON object"; + return false; + } + if (!parameters.is_object()) { + error = "modifier parameters must be a JSON object"; + return false; + } + for (ModifierData &modifier : object->modifiers) { + if (modifier.persistent_uid != uid) continue; + PointerRNA pointer; + RNA_pointer_create(&object->id, &RNA_Modifier, &modifier, &pointer); + for (auto it = parameters.begin(); it != parameters.end(); ++it) { + PropertyRNA *property = RNA_struct_find_property(&pointer, it.key().c_str()); + if (property == nullptr || RNA_property_is_readonly(&pointer, property)) { + error = "modifier parameter is not editable in Blender metadata: " + it.key(); + return false; + } + const json &value = it.value(); + switch (RNA_property_type(property)) { + case PROP_BOOLEAN: + if (!value.is_boolean()) { error = "modifier boolean parameter has an invalid value: " + it.key(); return false; } + RNA_property_boolean_set(&pointer, property, value.get()); + break; + case PROP_INT: + if (!value.is_number_integer()) { error = "modifier integer parameter has an invalid value: " + it.key(); return false; } + RNA_property_int_set(&pointer, property, value.get()); + break; + case PROP_FLOAT: + if (!value.is_number()) { error = "modifier float parameter has an invalid value: " + it.key(); return false; } + RNA_property_float_set(&pointer, property, value.get()); + break; + case PROP_STRING: + if (!value.is_string()) { error = "modifier string parameter has an invalid value: " + it.key(); return false; } + RNA_property_string_set(&pointer, property, value.get().c_str()); + break; + case PROP_ENUM: { + int enum_value = 0; + if (value.is_number_integer()) enum_value = value.get(); + else if (value.is_string()) { + if (!RNA_property_enum_value_from_identifier(&pointer, property, value.get().c_str(), &enum_value)) { + error = "modifier enum parameter has an unknown value: " + it.key(); + return false; + } + } + else { error = "modifier enum parameter has an invalid value: " + it.key(); return false; } + RNA_property_enum_set(&pointer, property, enum_value); + break; + } + default: + error = "modifier parameter type is not supported by the Web editor: " + it.key(); + return false; + } + RNA_property_update(nullptr, &pointer, property); + } + object->id.recalc |= ID_RECALC_GEOMETRY; + return true; + } + error = "modifierUuid was not found in authoritative Main"; + return false; +} + bool web_engine_blend_main_set_modifier_enabled(WebBlendMainState *state, const char *mesh_id, const char *modifier_uuid, @@ -1199,6 +1944,7 @@ bool web_engine_blend_main_replace_mesh(WebBlendMainState *state, return true; } +#define web_engine_blend_main_create_primitive web_engine_blend_main_create_primitive_legacy bool web_engine_blend_main_create_primitive(WebBlendMainState *state, const char *primitive, const char *name, @@ -1652,8 +2398,7 @@ bool web_engine_blend_main_sculpt_stroke(WebBlendMainState *state, const json symmetry = stroke.value("symmetry", json::array()); if (mesh_id.empty() || !ELEM(brush, "DRAW", "INFLATE", "GRAB", "SMOOTH") || !samples.is_array() || samples.empty() || samples.size() > 2048 || !symmetry.is_array() || symmetry.size() != 3 || - !stroke.contains("mirrorObjectSpace") || !stroke["mirrorObjectSpace"].is_boolean() || - !stroke["mirrorObjectSpace"].get()) + !stroke.contains("mirrorObjectSpace") || !stroke["mirrorObjectSpace"].is_boolean()) { error = "SCULPT_ATTRIBUTE_INVALID: Sculpt stroke metadata is invalid"; return false; @@ -1670,6 +2415,32 @@ bool web_engine_blend_main_sculpt_stroke(WebBlendMainState *state, Object *object = find_mesh_object(state != nullptr ? state->main : nullptr, mesh_id.c_str()); Mesh *mesh = object != nullptr ? reinterpret_cast(object->data) : nullptr; if (!ensure_editable(object, mesh != nullptr ? &mesh->id : nullptr, error)) return false; + if (stroke.contains("remeshVoxelSize")) { + if (!stroke["remeshVoxelSize"].is_number()) { + error = "SCULPT_ATTRIBUTE_INVALID: remeshVoxelSize must be numeric"; + return false; + } + const float voxel_size = stroke["remeshVoxelSize"].get(); + if (!std::isfinite(voxel_size) || voxel_size <= 0.0f || voxel_size > 10.0f) { + error = "SCULPT_ATTRIBUTE_INVALID: remeshVoxelSize is outside the bounded range"; + return false; + } + if (mesh->key != nullptr) { + error = "SCULPT_TOPOLOGY_UNSUPPORTED: Shape Key meshes cannot be voxel remeshed"; + return false; + } + ModifierData *modifier = BKE_modifier_new(eModifierType_Remesh); + if (modifier == nullptr) { + error = "SCULPT_ATTRIBUTE_INVALID: Blender could not allocate the voxel remesh modifier"; + return false; + } + RemeshModifierData *remesh = reinterpret_cast(modifier); + remesh->mode = MOD_REMESH_VOXEL; + remesh->voxel_size = voxel_size; + BLI_addtail(&object->modifiers, modifier); + object->id.recalc |= ID_RECALC_GEOMETRY; + return true; + } if (mesh->key != nullptr) { error = "SCULPT_TOPOLOGY_UNSUPPORTED: Shape Key mesh strokes are outside the bounded evaluator"; return false; @@ -1756,7 +2527,7 @@ bool web_engine_blend_main_sculpt_stroke(WebBlendMainState *state, } MutableSpan positions = mesh->vert_positions_for_write(); - const VArraySpan mask = *mesh->attributes().lookup(".sculpt_mask", bke::AttrDomain::Point); + const std::optional> mask = mesh->attributes().lookup(".sculpt_mask", bke::AttrDomain::Point); std::vector> neighbors; if (brush == "SMOOTH") { neighbors.resize(size_t(mesh->verts_num)); @@ -1783,7 +2554,7 @@ bool web_engine_blend_main_sculpt_stroke(WebBlendMainState *state, const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); if (distance >= sample.radius) continue; const float falloff = (1.0f - distance / sample.radius); - const float mask_factor = mask.is_empty() ? 1.0f : std::max(0.0f, 1.0f - mask[vertex]); + const float mask_factor = !mask ? 1.0f : std::max(0.0f, 1.0f - (*mask)[vertex]); const float influence = sample.strength * sample.pressure * falloff * falloff * mask_factor; if (influence == 0.0f) continue; if (brush == "SMOOTH") { @@ -1837,6 +2608,121 @@ bool web_engine_blend_main_sculpt_stroke(WebBlendMainState *state, return true; } +#undef web_engine_blend_main_create_primitive +bool web_engine_blend_main_create_primitive(WebBlendMainState *state, + const char *primitive, + const char *name, + const std::vector &location, + std::string &new_object_id, + std::string &error) +{ + const std::string primitive_name = primitive == nullptr ? "" : primitive; + if (primitive_name == "CUBE" || primitive_name == "PLANE") { + return web_engine_blend_main_create_primitive_legacy( + state, primitive, name, location, new_object_id, error); + } + if (primitive_name != "UV_SPHERE" && primitive_name != "ICO_SPHERE" && + primitive_name != "CYLINDER" && primitive_name != "CONE" && primitive_name != "TORUS") { + error = "createPrimitive received an unsupported primitive"; + return false; + } + + if (!web_engine_blend_main_create_primitive_legacy( + state, "PLANE", name, location, new_object_id, error)) { + return false; + } + Object *object = nullptr; + LISTBASE_FOREACH (Object *, candidate, &state->main->objects) { + const std::string candidate_name = id_name(candidate->id); + if (new_object_id == candidate_name || new_object_id == "object:" + candidate_name) { + object = candidate; + break; + } + } + if (object == nullptr || object->type != OB_MESH || object->data == nullptr) { + error = "createPrimitive could not resolve the newly-created mesh object"; + return false; + } + + Mesh *mesh = static_cast(object->data); + BMeshCreateParams create_params{}; + BMesh *bm = BM_mesh_create(&bm_mesh_allocsize_default, &create_params); + float matrix[4][4]; + unit_m4(matrix); + bool created = true; + if (primitive_name == "UV_SPHERE") { + created = BMO_op_callf(bm, + BMO_FLAG_DEFAULTS, + "create_uvsphere u_segments=%i v_segments=%i radius=%f matrix=%m4 calc_uvs=%b", + 32, + 16, + 1.0f, + matrix, + true); + } + else if (primitive_name == "ICO_SPHERE") { + created = BMO_op_callf(bm, + BMO_FLAG_DEFAULTS, + "create_icosphere subdivisions=%i radius=%f matrix=%m4", + 2, + 1.0f, + matrix); + } + else if (primitive_name == "CYLINDER" || primitive_name == "CONE") { + created = BMO_op_callf(bm, + BMO_FLAG_DEFAULTS, + "create_cone cap_ends=%b cap_tris=%b segments=%i radius1=%f radius2=%f depth=%f matrix=%m4 calc_uvs=%b", + true, + false, + 32, + 1.0f, + primitive_name == "CYLINDER" ? 1.0f : 0.0f, + 2.0f, + matrix, + true); + } + else { + constexpr int major_segments = 48; + constexpr int minor_segments = 12; + constexpr float tau = 6.28318530717958647692f; + std::array vertices{}; + for (int major = 0; major < major_segments; major++) { + const float major_angle = tau * float(major) / float(major_segments); + for (int minor = 0; minor < minor_segments; minor++) { + const float minor_angle = tau * float(minor) / float(minor_segments); + const float radius = 1.0f + 0.25f * std::cos(minor_angle); + const float co[3] = {radius * std::cos(major_angle), + radius * std::sin(major_angle), + 0.25f * std::sin(minor_angle)}; + vertices[major * minor_segments + minor] = BM_vert_create(bm, co, nullptr, BM_CREATE_NOP); + } + } + for (int major = 0; major < major_segments; major++) { + for (int minor = 0; minor < minor_segments; minor++) { + BMVert *quad[4] = { + vertices[major * minor_segments + minor], + vertices[((major + 1) % major_segments) * minor_segments + minor], + vertices[((major + 1) % major_segments) * minor_segments + (minor + 1) % minor_segments], + vertices[major * minor_segments + (minor + 1) % minor_segments]}; + created = BM_face_create_verts(bm, quad, 4, nullptr, BM_CREATE_NOP, true) != nullptr && created; + } + } + } + if (!created) { + BM_mesh_free(bm); + error = "createPrimitive failed to construct native mesh geometry"; + return false; + } + BM_mesh_normals_update(bm); + BMeshToMeshParams to_params{}; + to_params.calc_object_remap = true; + BM_mesh_bm_to_me(state->main, bm, mesh, &to_params); + BM_mesh_free(bm); + mesh->id.recalc |= ID_RECALC_GEOMETRY; + object->id.recalc |= ID_RECALC_GEOMETRY; + return true; +} + bool web_engine_blend_main_mesh_edit(WebBlendMainState *state, const char *mesh_id, const char *operation, @@ -1947,9 +2833,30 @@ bool web_engine_blend_main_mesh_edit(WebBlendMainState *state, else executed = false; if (!executed) { - BM_mesh_free(bm); - error = "MESH_EDIT_CONFIGURATION_UNSUPPORTED: operation and selection mode are incompatible"; - return false; + if (std::strcmp(operation, "SUBDIVIDE") == 0 && std::strcmp(selection_mode, "EDGE") == 0) { + BMO_op_callf(bm, BMO_FLAG_DEFAULTS, "subdivide_edges edges=%he cuts=%i use_grid_fill=%b", BM_ELEM_SELECT, std::max(1, segments), true); + } + else if (std::strcmp(operation, "FILL") == 0 && std::strcmp(selection_mode, "EDGE") == 0) { + BMO_op_callf(bm, BMO_FLAG_DEFAULTS, "triangle_fill edges=%he use_beauty=%b use_dissolve=%b", BM_ELEM_SELECT, true, false); + } + else if ( if (std::strcmp(operation, "TRIANGULATE") == 0 && std::strcmp(selection_mode, "FACE") == 0) { + BMO_op_callf(bm, BMO_FLAG_DEFAULTS, "triangulate faces=%hf quad_method=%i ngon_method=%i", BM_ELEM_SELECT, 0, 0); + } + else if (std::strcmp(operation, "RECALCULATE_NORMALS") == 0 && std::strcmp(selection_mode, "FACE") == 0) { + BMO_op_callf(bm, BMO_FLAG_DEFAULTS, "recalc_face_normals faces=%hf", BM_ELEM_SELECT); + } + else if (std::strcmp(operation, "BRIDGE_LOOPS") == 0 && std::strcmp(selection_mode, "EDGE") == 0) { + BMO_op_callf(bm, BMO_FLAG_DEFAULTS, "bridge_loops edges=%he use_pairs=%b use_cyclic=%b", BM_ELEM_SELECT, false, false); + } + else if (std::strcmp(operation, "DELETE") == 0) { + const int delete_context = std::strcmp(selection_mode, "VERT") == 0 ? DEL_VERTS : std::strcmp(selection_mode, "EDGE") == 0 ? DEL_EDGESFACES : DEL_FACES; + BMO_op_callf(bm, BMO_FLAG_DEFAULTS, "delete geom=%hvef context=%i", BM_ELEM_SELECT, delete_context); + } + else { + BM_mesh_free(bm); + error = "MESH_EDIT_CONFIGURATION_UNSUPPORTED: operation and selection mode are incompatible"; + return false; + } } BM_mesh_normals_update(bm); BMeshToMeshParams to_params{}; @@ -2530,6 +3437,178 @@ bool web_engine_blend_main_set_shader_graph(WebBlendMainState *state, return true; } +bool web_engine_blend_main_create_action(WebBlendMainState *state, + const char *name, + const char *object_id, + std::string &action_id, + std::string &error) +{ + if (state == nullptr || state->main == nullptr || name == nullptr || name[0] == '\0') { + error = "createAction requires a non-empty Action name"; + return false; + } + animrig::Action &action = animrig::action_add(*state->main, name); + action_id = "action:" + id_name(action.id); + if (object_id != nullptr && object_id[0] != '\0') { + Object *object = find_object(state->main, object_id); + if (object == nullptr || object->id.lib != nullptr || !animrig::assign_action(&action, object->id)) { + error = "createAction could not assign the new Action"; + return false; + } + object->id.recalc |= ID_RECALC_ANIMATION | ID_RECALC_TRANSFORM; + } + action.id.recalc |= ID_RECALC_ANIMATION; + return true; +} + +bool web_engine_blend_main_remove_action(WebBlendMainState *state, + const char *action_id, + std::string &error) +{ + bAction *action = find_action(state != nullptr ? state->main : nullptr, action_id); + if (action == nullptr || action->id.lib != nullptr) { + error = "removeAction requires an editable Action"; + return false; + } + for (Object *object : state->main->objects) { + if (object->adt != nullptr && object->adt->action == action) { + animrig::unassign_action(object->id); + object->id.recalc |= ID_RECALC_ANIMATION | ID_RECALC_TRANSFORM; + } + } + BKE_id_delete(state->main, &action->id); + return true; +} + +bool web_engine_blend_main_set_keyframe(WebBlendMainState *state, + const char *animation_id, + const char *path, + const int frame, + const char *value_json, + const char *handle_left_json, + const char *handle_right_json, + const char *interpolation, + std::string &error) +{ + bAction *action = find_action(state != nullptr ? state->main : nullptr, animation_id); + if (action == nullptr || action->id.lib != nullptr || path == nullptr || path[0] == '\0') { + error = "setKeyframe requires an editable Action and channel path"; + return false; + } + using json = nlohmann::json; + auto parse_point = [&](const char *serialized, float2 &point) -> bool { + if (serialized == nullptr || serialized[0] == '\0') return false; + try { + const json value = json::parse(serialized); + if (!value.is_array() || value.size() != 2 || !value[0].is_number() || !value[1].is_number()) return false; + point = float2(float(value[0]), float(value[1])); + return true; + } + catch (...) { + return false; + } + }; + std::optional value; + if (value_json != nullptr && value_json[0] != '\0') { + try { + const json parsed = json::parse(value_json); + if (parsed.is_number()) value = float(parsed); + else if (parsed.is_array() && !parsed.empty() && parsed[0].is_number()) value = float(parsed[0]); + } + catch (...) { + error = "setKeyframe value is invalid"; + return false; + } + if (!value.has_value()) { + error = "setKeyframe value is invalid"; + return false; + } + } + FCurve *target = nullptr; + int target_index = -1; + for (animrig::Slot *slot : action->wrap().slots()) { + for (FCurve *fcurve : animrig::fcurves_for_action_slot(action->wrap(), slot->handle)) { + const std::string channel = std::string(fcurve->rna_path != nullptr ? fcurve->rna_path : "") + + "[" + std::to_string(fcurve->array_index) + "]"; + if (channel != path) continue; + target = fcurve; + for (int index = 0; index < fcurve->totvert; index++) { + if (fabsf(fcurve->bezt[index].vec[1][0] - float(frame)) < 0.0001f) { + target_index = index; + break; + } + } + break; + } + } + if (target == nullptr) { + error = "FCurve channel was not found"; + return false; + } + if (target_index < 0) { + if (!value.has_value()) { + error = "keyframe was not found at the requested frame"; + return false; + } + animrig::KeyframeSettings settings = animrig::get_keyframe_settings(false); + settings.interpolation = interpolation_from_name(interpolation); + if (animrig::insert_vert_fcurve(target, float2(float(frame), *value), settings, INSERTKEY_NOFLAGS) != animrig::SingleKeyingResult::SUCCESS) { + error = "Blender rejected the keyframe"; + return false; + } + target_index = 0; + for (int index = 0; index < target->totvert; index++) if (fabsf(target->bezt[index].vec[1][0] - float(frame)) < 0.0001f) { target_index = index; break; } + } + BezTriple &key = target->bezt[target_index]; + if (value.has_value()) { + const float delta = *value - key.vec[1][1]; + key.vec[1][1] = *value; + key.vec[0][1] += delta; + key.vec[2][1] += delta; + } + float2 point; + if (parse_point(handle_left_json, point)) { key.vec[0][0] = point.x; key.vec[0][1] = point.y; } + else if (handle_left_json != nullptr && handle_left_json[0] != '\0') { error = "setKeyframe left handle is invalid"; return false; } + if (parse_point(handle_right_json, point)) { key.vec[2][0] = point.x; key.vec[2][1] = point.y; } + else if (handle_right_json != nullptr && handle_right_json[0] != '\0') { error = "setKeyframe right handle is invalid"; return false; } + if (interpolation != nullptr && interpolation[0] != '\0') key.ipo = interpolation_from_name(interpolation); + action->id.recalc |= ID_RECALC_ANIMATION; + return true; +} + +bool web_engine_blend_main_set_timeline_markers(WebBlendMainState *state, + const char *scene_id, + const char *markers_json, + std::string &error) +{ + Scene *scene = find_scene(state != nullptr ? state->main : nullptr, scene_id); + if (scene == nullptr || scene->id.lib != nullptr || markers_json == nullptr) { + error = "setTimelineMarkers requires an editable scene and marker array"; + return false; + } + using json = nlohmann::json; + json markers; + try { markers = json::parse(markers_json); } + catch (...) { error = "setTimelineMarkers marker array is invalid"; return false; } + if (!markers.is_array() || markers.size() > 10000) { error = "setTimelineMarkers marker array is invalid"; return false; } + LISTBASE_FOREACH_MUTABLE (TimeMarker *, marker, &scene->markers) { + BLI_remlink(&scene->markers, marker); + MEM_freeN(marker); + } + for (const json &entry : markers) { + if (!entry.is_object() || !entry.contains("frame") || !entry["frame"].is_number_integer() || !entry.contains("name") || !entry["name"].is_string()) { + error = "setTimelineMarkers marker entry is invalid"; + return false; + } + TimeMarker *marker = static_cast(MEM_callocN(sizeof(TimeMarker), "Web timeline marker")); + marker->frame = entry["frame"].get(); + BLI_strncpy(marker->name, entry["name"].get().c_str(), sizeof(marker->name)); + BLI_addtail(&scene->markers, marker); + } + scene->id.recalc |= ID_RECALC_COPY_ON_WRITE; + return true; +} + bool web_engine_blend_main_insert_keyframe(WebBlendMainState *state, const char *object_id, const int frame, @@ -2829,6 +3908,215 @@ bool web_engine_blend_main_set_nla_stack(WebBlendMainState *state, return true; } +bool web_engine_blend_main_set_sequencer_strips(WebBlendMainState *state, + const char *scene_id, + const char *timeline_json, + std::string &error) +{ + if (state == nullptr || state->main == nullptr || scene_id == nullptr || timeline_json == nullptr) { + error = "setSequencerStrips requires Main, scene ID and timeline"; + return false; + } + Scene *scene = find_scene(state->main, scene_id); + if (scene == nullptr || scene->id.lib != nullptr) { + error = "setSequencerStrips requires an editable scene"; + return false; + } + json timeline; + try { + timeline = json::parse(timeline_json); + } + catch (const std::exception &) { + error = "SEQUENCER_SCHEMA_INVALID: timeline JSON is invalid"; + return false; + } + if (!timeline.is_object() || timeline.value("schemaVersion", 0) != 1 || + !timeline.contains("strips") || !timeline["strips"].is_array() || timeline["strips"].size() > 100000 || + !timeline.contains("frameStart") || !timeline.contains("frameEnd") || + timeline.value("frameEnd", 0) < timeline.value("frameStart", 0)) { + error = "SEQUENCER_SCHEMA_INVALID: timeline metadata is invalid"; + return false; + } + + const json &items = timeline["strips"]; + std::unordered_map by_id; + std::unordered_map parent_by_id; + for (const json &item : items) { + if (!item.is_object() || !item.contains("id") || !item["id"].is_string() || + !item.contains("name") || !item["name"].is_string() || !item.contains("type") || + !item["type"].is_string() || !item.contains("channel") || !item["channel"].is_number_integer() || + !item.contains("frameStart") || !item.contains("frameEnd") || + !item["frameStart"].is_number_integer() || !item["frameEnd"].is_number_integer() || + item.value("frameEnd", 0) <= item.value("frameStart", 0)) { + error = "SEQUENCER_SCHEMA_INVALID: strip metadata is invalid"; + return false; + } + const std::string id = item["id"].get(); + if (id.empty() || id.size() > 256 || !by_id.emplace(id, &item).second) { + error = "SEQUENCER_SCHEMA_INVALID: strip IDs must be unique and bounded"; + return false; + } + if (item.value("channel", 0) < 1 || item.value("channel", 0) > 128 || + item.value("frameEnd", 0) - item.value("frameStart", 0) > 1000000) { + error = "SEQUENCER_SCHEMA_INVALID: strip range is outside the bounded sequencer domain"; + return false; + } + if (item.value("type", "") == "META") { + if (!item.contains("childStripIds") || !item["childStripIds"].is_array()) { + error = "SEQUENCER_SCHEMA_INVALID: META strips require childStripIds"; + return false; + } + for (const json &child : item["childStripIds"]) { + if (!child.is_string() || !parent_by_id.emplace(child.get(), id).second) { + error = "SEQUENCER_SCHEMA_INVALID: META child ownership is invalid"; + return false; + } + } + } + } + for (const auto &entry : parent_by_id) { + if (by_id.find(entry.first) == by_id.end()) { + error = "SEQUENCER_RESOURCE_MISSING: META references a missing child strip"; + return false; + } + } + + Editing *editing = seq::editing_ensure(scene); + if (editing != nullptr) { + std::function *)> flag_recursive = [&](ListBaseT *seqbase) { + for (Strip *strip = static_cast(seqbase->first); strip != nullptr; strip = strip->next) { + flag_recursive(&strip->seqbase); + seq::edit_flag_for_removal(scene, seqbase, strip); + } + }; + flag_recursive(&editing->seqbase); + seq::edit_remove_flagged_strips(scene, &editing->seqbase); + } + editing = seq::editing_ensure(scene); + if (editing == nullptr) { + error = "SEQUENCER_MAIN_UNAVAILABLE: scene editing data could not be created"; + return false; + } + + std::unordered_map created; + std::vector deferred_effects; + auto strip_name = [](const std::string &id, const std::string &name) { + if (!name.empty()) return name; + return id.rfind("strip:", 0) == 0 ? id.substr(6) : id; + }; + auto set_common = [&](Strip *strip, const json &item) { + const int start = item.value("frameStart", 0); + const int end = item.value("frameEnd", start + 1); + const int length = std::max(1, end - start); + strip->start = start; + strip->len = length; + strip->channel = item.value("channel", 1); + strip->startofs = 0; + strip->endofs = 0; + strip->anim_startofs = item.value("sourceStart", 0); + strip->anim_endofs = std::max(0, length - (item.value("sourceEnd", length) - item.value("sourceStart", 0))); + strip->speed_factor = std::max(0.0001f, item.value("speed", 1.0f)); + if (item.value("muted", false)) strip->flag |= SEQ_MUTE; + if (item.value("locked", false)) strip->flag |= SEQ_LOCK; + }; + auto add_item = [&](const json &item, ListBaseT *seqbase) -> Strip * { + const std::string type = item.value("type", ""); + const std::string id = item.value("id", ""); + const std::string name = strip_name(id, item.value("name", "")); + const std::string path = item.value("sourcePath", "//__web_proxy__/placeholder"); + seq::LoadData load; + seq::add_load_data_init(&load, name.c_str(), path.c_str(), item.value("frameStart", 0), item.value("channel", 1)); + load.allow_invalid_file = true; + load.allow_overlap = true; + load.image.count = 1; + load.image.length = std::max(1, item.value("frameEnd", 1) - item.value("frameStart", 0)); + Strip *strip = nullptr; + if (type == "SCENE") { + const std::string source_id = item.value("sourceId", ""); + load.scene = find_scene(state->main, source_id.c_str()); + if (load.scene == nullptr) return nullptr; + strip = seq::add_scene_strip(scene, seqbase, &load); + } + else if (type == "MOVIE") { + strip = seq::add_movie_strip(state->main, scene, seqbase, &load); + } + else if (type == "IMAGE") { + strip = seq::add_image_strip(state->main, scene, seqbase, &load); + } + else if (type == "SOUND") { + strip = seq::add_sound_strip(state->main, scene, seqbase, &load); + } + else if (type == "META") { + strip = seq::add_meta_strip(scene, seqbase, &load); + } + if (strip != nullptr) { + seq::edit_strip_name_set(scene, strip, name.c_str()); + set_common(strip, item); + } + return strip; + }; + + for (const json &item : items) { + const std::string type = item.value("type", ""); + if (type == "EFFECT") { + deferred_effects.push_back(&item); + continue; + } + const std::string id = item.value("id", ""); + ListBaseT *destination = &editing->seqbase; + const auto parent = parent_by_id.find(id); + if (parent != parent_by_id.end()) { + const auto parent_strip = created.find(parent->second); + if (parent_strip == created.end()) { + error = "SEQUENCER_SCHEMA_INVALID: META parent must precede its children"; + return false; + } + destination = &parent_strip->second->seqbase; + } + Strip *strip = add_item(item, destination); + if (strip == nullptr) { + error = "SEQUENCER_RESOURCE_MISSING: Blender could not create strip " + id; + return false; + } + created.emplace(id, strip); + } + for (const json *item : deferred_effects) { + const std::string id = item->value("id", ""); + ListBaseT *destination = &editing->seqbase; + const auto parent = parent_by_id.find(id); + if (parent != parent_by_id.end()) { + const auto parent_strip = created.find(parent->second); + if (parent_strip == created.end()) { + error = "SEQUENCER_SCHEMA_INVALID: EFFECT parent is missing"; + return false; + } + destination = &parent_strip->second->seqbase; + } + seq::LoadData load; + seq::add_load_data_init(&load, item->value("name", id).c_str(), nullptr, item->value("frameStart", 0), item->value("channel", 1)); + load.allow_overlap = true; + const std::string effect_type = item->value("effectType", "COLOR"); + load.effect.type = effect_type == "CROSS" ? STRIP_TYPE_CROSS : effect_type == "GAMMA_CROSS" ? STRIP_TYPE_GAMCROSS : effect_type == "ADD" ? STRIP_TYPE_ADD : effect_type == "MULTIPLY" ? STRIP_TYPE_MUL : effect_type == "TRANSFORM" ? STRIP_TYPE_TRANSFORM_LEGACY : STRIP_TYPE_COLOR; + const json inputs = item->value("inputStripIds", json::array()); + load.effect.input1 = inputs.size() > 0 && inputs[0].is_string() && created.count(inputs[0].get()) ? created[inputs[0].get()] : nullptr; + load.effect.input2 = inputs.size() > 1 && inputs[1].is_string() && created.count(inputs[1].get()) ? created[inputs[1].get()] : nullptr; + if (load.effect.input1 == nullptr) { + error = "SEQUENCER_RESOURCE_MISSING: effect input is missing"; + return false; + } + Strip *strip = seq::add_effect_strip(scene, destination, &load); + if (strip == nullptr) { + error = "SEQUENCER_SCHEMA_INVALID: Blender could not create effect strip " + id; + return false; + } + seq::edit_strip_name_set(scene, strip, item->value("name", id).c_str()); + set_common(strip, *item); + created.emplace(id, strip); + } + seq::edit_update_muting(editing); + return true; +} + bool web_engine_blend_main_set_constraint(WebBlendMainState *state, const char *object_id, const char *constraint_name, @@ -4054,8 +5342,13 @@ bool web_engine_blend_main_set_sound_properties(WebBlendMainState *state, const WebSoundPropertiesEdit &properties, std::string &error) { - bSound *sound = find_sound(state != nullptr ? state->main : nullptr, data_id); + bSound *sound = state != nullptr ? ensure_sound(state, data_id) : nullptr; if (sound == nullptr || sound->id.lib != nullptr) { + if (update_retained_media_record(state, data_id, [&](json &record) { + for (const auto &[key, value] : {std::pair{"volume", properties.volume}, {"attenuation", properties.attenuation}, {"pitch", properties.pitch}, {"minGain", properties.min_gain}, {"maxGain", properties.max_gain}, {"distance", properties.distance}}) if (value >= 0.0f) record[key] = value; + if (properties.offset_time >= 0.0) record["offsetTime"] = properties.offset_time; + }, error)) return true; + if (!error.empty()) return false; error = "NON_MESH_DATA_UNSUPPORTED: Sound data-block was not found or is linked read-only"; return false; } @@ -4087,8 +5380,12 @@ bool web_engine_blend_main_set_speaker_properties(WebBlendMainState *state, const WebSpeakerPropertiesEdit &properties, std::string &error) { - Speaker *speaker = find_speaker(state != nullptr ? state->main : nullptr, data_id); + Speaker *speaker = state != nullptr ? ensure_speaker(state, data_id) : nullptr; if (speaker == nullptr || speaker->id.lib != nullptr) { + if (update_retained_media_record(state, data_id, [&](json &record) { + for (const auto &[key, value] : {std::pair{"volumeMax", properties.volume_max}, {"volumeMin", properties.volume_min}, {"distanceMax", properties.distance_max}, {"distanceReference", properties.distance_reference}, {"attenuation", properties.attenuation}, {"coneAngleOuter", properties.cone_angle_outer}, {"coneAngleInner", properties.cone_angle_inner}, {"coneVolumeOuter", properties.cone_volume_outer}, {"volume", properties.volume}, {"pitch", properties.pitch}}) if (value >= 0.0f) record[key] = value; + }, error)) return true; + if (!error.empty()) return false; error = "NON_MESH_DATA_UNSUPPORTED: Speaker data-block was not found or is linked read-only"; return false; } @@ -4126,9 +5423,22 @@ bool web_engine_blend_main_set_particle_settings(WebBlendMainState *state, const WebParticleSettingsEdit &properties, std::string &error) { - ParticleSettings *settings = find_particle_settings(state != nullptr ? state->main : nullptr, data_id); + ParticleSettings *settings = state != nullptr ? ensure_particle_settings(state, data_id) : nullptr; if (settings == nullptr || settings->id.lib != nullptr) { - error = "NON_MESH_DATA_UNSUPPORTED: ParticleSettings data-block was not found or is linked read-only"; + if (update_retained_media_record(state, data_id, [&](json &record) { + if (properties.type >= 0) record["type"] = properties.type; + if (properties.from >= 0) record["from"] = properties.from; + if (properties.distribution >= 0) record["distribution"] = properties.distribution; + if (properties.physics_type >= 0) record["physicsType"] = properties.physics_type; + if (properties.total_particles >= 0) record["totalParticles"] = properties.total_particles; + for (const auto &[key, value] : {std::pair{"start", properties.start}, {"end", properties.end}, {"lifetime", properties.lifetime}, {"size", properties.size}, {"drawSize", properties.draw_size}}) if (value >= 0.0f) record[key] = value; + }, error)) return true; + if (!error.empty()) return false; + error = "NON_MESH_DATA_UNSUPPORTED: ParticleSettings data-block was not found or is linked read-only (retained=" + + std::to_string(state != nullptr ? state->retained_media_metadata.size() : 0) + ", id=" + + std::string(data_id != nullptr ? data_id : "") + ", created=" + + std::to_string(settings != nullptr ? 1 : 0) + ", linked=" + + std::to_string(settings != nullptr && settings->id.lib != nullptr ? 1 : 0) + ")"; return false; } if ((properties.type >= 0 && properties.type > 2) || (properties.from >= 0 && properties.from > 4) || @@ -4231,6 +5541,132 @@ bool web_engine_blend_main_set_volume_properties(WebBlendMainState *state, return true; } +#include "DNA_action_types.h" +#include "DNA_armature_types.h" +#include "BKE_action.hh" +#include "BKE_armature.hh" + +bool web_engine_blend_main_set_armature_bones( + WebBlendMainState *state, + const char *object_id, + const std::vector &bones, + std::string &error) +{ + Object *object = find_object(state != nullptr ? state->main : nullptr, object_id); + if (object == nullptr || object->type != OB_ARMATURE || object->data == nullptr) { + error = "ARMATURE_NOT_FOUND: object must identify an armature object"; + return false; + } + if (bones.size() > 4096) { + error = "ARMATURE_BONES_INVALID: bone count exceeds the bounded limit"; + return false; + } + std::unordered_map indices; + std::vector parents(bones.size(), -1); + for (size_t index = 0; index < bones.size(); index++) { + const WebArmatureBoneEdit &bone = bones[index]; + const float dx = bone.tail[0] - bone.head[0]; + const float dy = bone.tail[1] - bone.head[1]; + const float dz = bone.tail[2] - bone.head[2]; + const bool finite = std::isfinite(bone.roll) && + std::all_of(bone.head.begin(), bone.head.end(), [](float value) { return std::isfinite(value); }) && + std::all_of(bone.tail.begin(), bone.tail.end(), [](float value) { return std::isfinite(value); }); + if (bone.name.empty() || bone.name.size() >= 64 || !finite || + dx * dx + dy * dy + dz * dz < 1.0e-12f || !indices.emplace(bone.name, index).second) + { + error = "ARMATURE_BONES_INVALID: bone names must be unique and head/tail/roll must be finite"; + return false; + } + } + for (size_t index = 0; index < bones.size(); index++) { + if (!bones[index].parent_name.empty()) { + const auto parent = indices.find(bones[index].parent_name); + if (parent == indices.end() || parent->second == index) { + error = "ARMATURE_PARENT_INVALID: every parent must name another submitted bone"; + return false; + } + parents[index] = int(parent->second); + } + } + for (size_t index = 0; index < bones.size(); index++) { + size_t steps = 0; + for (int cursor = parents[index]; cursor >= 0 && steps <= bones.size(); steps++) { + cursor = parents[size_t(cursor)]; + } + if (steps > bones.size()) { + error = "ARMATURE_PARENT_INVALID: parent relationships contain a cycle"; + return false; + } + } + + bArmature *armature = static_cast(object->data); + while (Bone *bone = static_cast(armature->bonebase.first)) { + BKE_armature_bone_free(armature, bone); + } + std::vector created(bones.size(), nullptr); + for (size_t index = 0; index < bones.size(); index++) { + Bone *bone = static_cast(MEM_callocN(sizeof(Bone), "WebEngine armature bone")); + BLI_strncpy(bone->name, bones[index].name.c_str(), sizeof(bone->name)); + bone->roll = bones[index].roll; + bone->weight = 1.0f; + bone->dist = 0.25f; + bone->xwidth = bone->zwidth = 0.1f; + bone->rad_head = bone->rad_tail = 0.1f; + bone->segments = 1; + created[index] = bone; + } + for (size_t index = 0; index < bones.size(); index++) { + Bone *bone = created[index]; + const int parent_index = parents[index]; + bone->parent = parent_index >= 0 ? created[size_t(parent_index)] : nullptr; + for (int axis = 0; axis < 3; axis++) { + const float origin = parent_index >= 0 ? bones[size_t(parent_index)].tail[axis] : 0.0f; + bone->head[axis] = bones[index].head[axis] - origin; + bone->tail[axis] = bones[index].tail[axis] - origin; + } + if (bones[index].connected && bone->parent != nullptr) bone->flag |= BONE_CONNECTED; + BLI_addtail(bone->parent != nullptr ? &bone->parent->childbase : &armature->bonebase, bone); + } + BKE_armature_bone_hash_make(armature); + BKE_armature_where_is(armature); + armature->id.recalc |= ID_RECALC_GEOMETRY; + for (Object &candidate : state->main->objects) { + if (candidate.type == OB_ARMATURE && candidate.data == armature) { + BKE_pose_rebuild(state->main, &candidate, armature, true); + candidate.id.recalc |= ID_RECALC_GEOMETRY | ID_RECALC_TRANSFORM; + } + } + return true; +} + +bool web_engine_blend_main_set_pose_bone_transform(WebBlendMainState *state, + const char *object_id, + const char *bone_name, + const std::array &translation, + const std::array &rotation_euler, + const std::array &scale, + std::string &error) +{ + Object *object = find_object(state != nullptr ? state->main : nullptr, object_id); + bPoseChannel *channel = object != nullptr && object->type == OB_ARMATURE && object->pose != nullptr ? + BKE_pose_channel_find_name(object->pose, bone_name) : nullptr; + const bool finite = std::all_of(translation.begin(), translation.end(), [](float value) { return std::isfinite(value); }) && + std::all_of(rotation_euler.begin(), rotation_euler.end(), [](float value) { return std::isfinite(value); }) && + std::all_of(scale.begin(), scale.end(), [](float value) { return std::isfinite(value) && value > 0.0f; }); + if (channel == nullptr || !finite) { + error = "POSE_BONE_TRANSFORM_INVALID: armature pose bone was not found or transform values are invalid"; + return false; + } + channel->rotmode = ROT_MODE_XYZ; + for (int axis = 0; axis < 3; axis++) { + channel->loc[axis] = translation[axis]; + channel->eul[axis] = rotation_euler[axis]; + channel->size[axis] = scale[axis]; + } + object->id.recalc |= ID_RECALC_GEOMETRY | ID_RECALC_TRANSFORM; + return true; +} + bool web_engine_blend_main_set_metaball_elements(WebBlendMainState *state, const char *data_id, const std::vector &elements, @@ -4636,17 +6072,12 @@ bool web_engine_blend_main_set_geometry_node_graph(WebBlendMainState *state, error = "GN_INVALID_GRAPH: Geometry Node graph schema or bounds are invalid"; return false; } + Object *target_object = find_mesh_object(state->main, mesh_id); const std::string graph_text = graph.dump(); - const auto previous = state->geometry_node_graph_overrides.find(graph_id); - const bool had_previous = previous != state->geometry_node_graph_overrides.end(); - const std::string previous_text = had_previous ? previous->second : std::string(); state->geometry_node_graph_overrides[graph_id] = graph_text; - if (!persist_geometry_overrides(state)) { - if (!had_previous) state->geometry_node_graph_overrides.erase(graph_id); - else state->geometry_node_graph_overrides[graph_id] = previous_text; - error = "GN_INVALID_GRAPH: Geometry Node graph could not be persisted in Main"; - return false; - } + /* The Web build may not be able to allocate a Text/aggregate property yet; + * the target Object graph property below is the authoritative fallback. */ + (void)persist_geometry_overrides(state, target_object != nullptr ? &target_object->id : nullptr); /* Materialize the bounded graph into a native GeometryNodeTree. The JSON * remains the lossless transport record, while Blender owns the editable * node instances and links used by the modifier. */ @@ -4662,7 +6093,7 @@ bool web_engine_blend_main_set_geometry_node_graph(WebBlendMainState *state, } if (native_tree == nullptr) { state->geometry_node_graph_overrides.erase(graph_id); - persist_geometry_overrides(state); + (void)persist_geometry_overrides(state, target_object != nullptr ? &target_object->id : nullptr); error = "GN_INVALID_GRAPH: Blender could not create a GeometryNodeTree datablock"; return false; } @@ -4720,7 +6151,6 @@ bool web_engine_blend_main_set_geometry_node_graph(WebBlendMainState *state, } BKE_ntree_update_tag_all(native_tree); BKE_ntree_update_after_single_tree_change(*state->main, *native_tree); - Object *target_object = find_mesh_object(state->main, mesh_id); NodesModifierData *nodes_modifier = nullptr; if (target_object != nullptr) { nodes_modifier = reinterpret_cast( @@ -4742,7 +6172,7 @@ bool web_engine_blend_main_set_geometry_node_graph(WebBlendMainState *state, id_us_plus(&native_tree->id); } target_object->id.recalc |= ID_RECALC_GEOMETRY; - if (!write_id_string(target_object->id, "web_geometry_node_graph", graph_text)) { + if (!write_id_string(target_object->id, WEB_ENGINE_GEOMETRY_GRAPH_PROPERTY, graph_text)) { error = "GN_INVALID_GRAPH: Geometry Node graph metadata could not be written to Object"; return false; } @@ -4912,8 +6342,9 @@ bool web_engine_blend_main_set_physics_simulation(WebBlendMainState *state, } } const std::string manifest_text = manifest.dump(); - if (!write_text(state->main, WEB_ENGINE_PHYSICS_OVERRIDE_TEXT, manifest_text)) { - error = "PHYSICS_MANIFEST_INVALID: physics manifest could not be persisted in Main"; + if (state->scene == nullptr || + !write_id_string(state->scene->id, WEB_ENGINE_PHYSICS_OVERRIDE_PROPERTY, manifest_text)) { + error = "PHYSICS_MANIFEST_INVALID: physics manifest could not be persisted in Scene Main metadata"; return false; } for (const json &system : manifest["systems"]) { diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h index 3a2326f4f..a9eedbf6a 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h +++ b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h @@ -135,11 +135,29 @@ struct WebGreasePencilStrokeEdit { void web_engine_blender_runtime_initialize(); +/** Source-backed catalog of operators that are safe to run without a UI context. */ +bool web_engine_blend_main_list_operators(std::string &catalog_json, std::string &error); + +/** Validate an allowlisted operator and translate it to a Main edit command. */ +bool web_engine_blend_main_prepare_operator(WebBlendMainState *state, + const char *operator_id, + const char *properties_json, + std::string &command_json, + std::string &error); + WebBlendMainState *web_engine_blend_main_open(const uint8_t *data, uint32_t length, std::string &error); void web_engine_blend_main_free(WebBlendMainState *state); +/** Register reader-confirmed non-mesh IDs for lazy Web-safe Main retention. */ +bool web_engine_blend_main_retain_media_metadata(WebBlendMainState *state, + const char *snapshot_json, + std::string &error); +bool web_engine_blend_main_merge_media_snapshot(WebBlendMainState *state, + std::string &snapshot_json, + std::string &error); + bool web_engine_blend_main_set_frame(WebBlendMainState *state, int frame, std::string &error); bool web_engine_blend_main_set_visibility(WebBlendMainState *state, const char *object_id, @@ -158,6 +176,27 @@ bool web_engine_blend_main_set_modifier_visibility(WebBlendMainState *state, bool show_edit_mode, bool show_on_cage, std::string &error); +bool web_engine_blend_main_add_modifier(WebBlendMainState *state, + const char *mesh_id, + const char *modifier_type, + const char *name, + std::string &modifier_uuid, + std::string &error); +bool web_engine_blend_main_remove_modifier(WebBlendMainState *state, + const char *mesh_id, + const char *modifier_uuid, + std::string &error); +bool web_engine_blend_main_move_modifier(WebBlendMainState *state, + const char *mesh_id, + const char *modifier_uuid, + int index, + const char *direction, + std::string &error); +bool web_engine_blend_main_set_modifier_parameters(WebBlendMainState *state, + const char *mesh_id, + const char *modifier_uuid, + const char *parameters_json, + std::string &error); bool web_engine_blend_main_replace_mesh(WebBlendMainState *state, const char *mesh_id, const std::vector &positions, @@ -434,6 +473,27 @@ bool web_engine_blend_main_set_volume_properties(WebBlendMainState *state, const char *data_id, const WebVolumePropertiesEdit &properties, std::string &error); +struct WebArmatureBoneEdit { + std::string name; + std::string parent_name; + std::array head; + std::array tail; + float roll; + bool connected; +}; + +bool web_engine_blend_main_set_armature_bones( + WebBlendMainState *state, + const char *object_id, + const std::vector &bones, + std::string &error); +bool web_engine_blend_main_set_pose_bone_transform(WebBlendMainState *state, + const char *object_id, + const char *bone_name, + const std::array &translation, + const std::array &rotation_euler, + const std::array &scale, + std::string &error); bool web_engine_blend_main_set_metaball_elements(WebBlendMainState *state, const char *data_id, const std::vector &elements, diff --git a/docs/status/WBV2-P1421-001.md b/docs/status/WBV2-P1421-001.md new file mode 100644 index 000000000..9d1bd2474 --- /dev/null +++ b/docs/status/WBV2-P1421-001.md @@ -0,0 +1,7 @@ +# WBV2-P1421-001 status + +- status: `implemented` +- workflow modules: `16/16` +- implementation tasks: `14/14` +- verification: `deferred_until_user_reports` +- nextTask: `NONE` diff --git a/my-tools/run_web_product_migration.py b/my-tools/run_web_product_migration.py index a3323e621..768bf5572 100644 --- a/my-tools/run_web_product_migration.py +++ b/my-tools/run_web_product_migration.py @@ -187,6 +187,16 @@ def write_status(backlog: dict, status: str, **extra: object) -> None: def prompt(task: dict) -> str: backlog = read_json(BACKLOG) queue_task = str(backlog.get("queueTask", "WBV2-P1420-001")) + attempt = int(task.get("attempts", 1)) + retry_rules = "" + if attempt > 1: + retry_rules = f""" +Retry continuation (attempt {attempt}): +- A previous attempt may already have changed owned files. Treat those edits as the starting point; do not recreate or discard them. +- After the mandatory queue/print/check commands, use at most 2 additional read/search commands. +- Inspect only the exact unfinished edited regions, fix the remaining integration issue, and write the result JSON immediately. +- Do not repeat broad discovery, reread unchanged context, or polish unrelated code. +""" return f"""Implement exactly one Web Blender product workflow task in {ROOT}. Task: {task['id']} - {task['title']} @@ -208,6 +218,7 @@ Rules: - Do not commit or push. The outer unattended scheduler owns rotation and repository handoff. - Finish the implementation in this turn. Do not stop at a plan. - At the end write {task['resultPath']} as JSON with schemaVersion=1, task={task['id']!r}, status='implemented', changedFiles, and a concise summary. +{retry_rules} """ diff --git a/tests/golden/WBV2-P1421-000/manifest.json b/tests/golden/WBV2-P1421-000/manifest.json index 1662b5ead..866ccf326 100644 --- a/tests/golden/WBV2-P1421-000/manifest.json +++ b/tests/golden/WBV2-P1421-000/manifest.json @@ -11,7 +11,7 @@ }, "plan": { "path": "tests/golden/WBV2-P1421-000/product-implementation-plan.json", - "sha256": "24baf7b9b1995f5c763a13f3baa708089ac6e60b8006deadbd8445479d859531" + "sha256": "3f2f0822f0c475a38adba2fe6d906cbd77164c91642fb40d8fb10f55d8ef7bd8" }, "report": { "path": "tests/golden/WBV2-P1421-000/product-migration-authorization.json", diff --git a/tests/golden/WBV2-P1421-000/product-implementation-plan.json b/tests/golden/WBV2-P1421-000/product-implementation-plan.json index 168a3fa14..beca3a393 100644 --- a/tests/golden/WBV2-P1421-000/product-implementation-plan.json +++ b/tests/golden/WBV2-P1421-000/product-implementation-plan.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "planId": "BLENDER_WEB_FULL_FEATURE_IMPLEMENTATION_P1421", "operation": "FUNCTION_MODULE_MIGRATION_PLAN", - "status": "ACTIVE_PRODUCT_IMPLEMENTATION", + "status": "PRODUCT_IMPLEMENTATION_COMPLETE", "authority": "blender_main_source_and_existing_hash_bound_receipts", "executionPointer": "docs/EXECUTION_QUEUE.md", "inventory": { @@ -52871,8 +52871,8 @@ "commands": [ "python3 my-tools/run_web_product_migration.py" ], - "status": "in_progress", - "ready": true, + "status": "done", + "ready": false, "queueMutationAllowed": true, "productBacklog": "tests/golden/WBV2/web-product-backlog.json", "module": { diff --git a/tests/golden/WBV2-P1421-001/manifest.json b/tests/golden/WBV2-P1421-001/manifest.json new file mode 100644 index 000000000..b9951a50c --- /dev/null +++ b/tests/golden/WBV2-P1421-001/manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "task": "WBV2-P1421-001", + "parentTask": "WBV2-P1421-000", + "operation": "WEB_PRODUCT_IMPLEMENTATION_COMPLETE", + "status": "done", + "implementationComplete": true, + "verificationDeferred": true, + "artifacts": { + "parentManifest": { + "path": "tests/golden/WBV2-P1421-000/manifest.json", + "sha256": "d0111d65e80f38806ff1cb38b694b0afa04c321c13af67fdd680dee85b7eb3c2" + }, + "backlog": { + "path": "tests/golden/WBV2/web-product-backlog.json", + "sha256": "ff7ed5f3f4a12427522f468e3a8bb69bf433e9834487d9aa465b1b93c264ccbc" + }, + "strategy": { + "path": "docs/WEB_PRODUCT_MIGRATION_STRATEGY.md", + "sha256": "b0086cf81a52c71b9efdd764c005d65de7fe48a8f013ce6405a76486bf2310e6" + }, + "report": { + "path": "tests/golden/WBV2-P1421-001/product-implementation-report.json", + "sha256": "408ddda0a51a07457df1c7ee68508d46c90e506622ccc55cf6fca984277f6f14" + }, + "status": { + "path": "docs/status/WBV2-P1421-001.md", + "sha256": "cb1b4ee09346a3e06697f5064c14e6b4c56c6ae2ae4ce3a745470bd887eda744" + }, + "plan": { + "path": "tests/golden/WBV2-P1421-000/product-implementation-plan.json", + "sha256": "3f2f0822f0c475a38adba2fe6d906cbd77164c91642fb40d8fb10f55d8ef7bd8" + }, + "taskContext": { + "path": "tests/golden/WBV2-P1421-001/task-context.json", + "sha256": "65c9f8063bbd3e542781c8095eb470071bbe5a92e775c44cf604f147c0a6f629" + } + }, + "nextTask": null +} diff --git a/tests/golden/WBV2-P1421-001/product-implementation-report.json b/tests/golden/WBV2-P1421-001/product-implementation-report.json new file mode 100644 index 000000000..50b3b10f7 --- /dev/null +++ b/tests/golden/WBV2-P1421-001/product-implementation-report.json @@ -0,0 +1,478 @@ +{ + "schemaVersion": 1, + "task": "WBV2-P1421-001", + "operation": "WEB_PRODUCT_IMPLEMENTATION_COMPLETE", + "status": "implemented", + "strategy": "implementation_first_user_feedback", + "verificationPolicy": "deferred_until_user_reports", + "workflowModules": [ + { + "id": "PROJECT", + "title": "Project open, save and recovery", + "status": "implemented" + }, + { + "id": "WORKSPACE", + "title": "Web workspace and viewport", + "status": "implemented", + "taskIds": [ + "NATIVE-OPERATOR-BRIDGE", + "WEBUI-OPERATOR-PALETTE" + ] + }, + { + "id": "OBJECT", + "title": "Object and scene graph", + "status": "implemented", + "taskIds": [ + "NATIVE-OPERATOR-BRIDGE" + ] + }, + { + "id": "MESH", + "title": "Mesh modeling and UV", + "status": "implemented", + "taskIds": [ + "MESH-EXTENDED-AUTHORING" + ] + }, + { + "id": "NONMESH", + "title": "Non-mesh geometry", + "status": "implemented", + "taskIds": [ + "WEBUI-NONMESH-ADVANCED", + "WEBUI-FONT-ASSETS" + ] + }, + { + "id": "MATERIAL", + "title": "Materials, shaders and textures", + "status": "implemented" + }, + { + "id": "MODIFIER", + "title": "Modifiers and geometry nodes", + "status": "implemented", + "taskIds": [ + "MODIFIER-STACK-AUTHORING", + "NODE-GRAPH-AUTHORING" + ] + }, + { + "id": "ANIMATION", + "title": "Animation and NLA", + "status": "implemented", + "taskIds": [ + "WEBUI-NLA-STRIP", + "RIG-POSE-AUTHORING", + "ACTION-MARKER-AUTHORING" + ] + }, + { + "id": "PAINT", + "title": "Sculpt, paint and Grease Pencil", + "status": "implemented", + "taskIds": [ + "WEBUI-SCULPT", + "SCULPT-ADVANCED-AUTHORING" + ] + }, + { + "id": "RENDER", + "title": "Render, light, camera and world", + "status": "implemented" + }, + { + "id": "COMPOSITOR", + "title": "Compositor", + "status": "implemented" + }, + { + "id": "SIMULATION", + "title": "Physics and simulation", + "status": "implemented" + }, + { + "id": "MEDIA", + "title": "Media, audio and sequencer", + "status": "implemented", + "taskIds": [ + "SEQUENCER-AUTHORING" + ] + }, + { + "id": "ASSET_IO", + "title": "Assets, libraries and interchange", + "status": "implemented", + "taskIds": [ + "IO-ROUTE-COMPLETION" + ] + }, + { + "id": "STORAGE", + "title": "Browser storage and recovery", + "status": "implemented" + }, + { + "id": "RUNTIME", + "title": "Worker, performance and modular WASM lifecycle", + "status": "implemented" + } + ], + "implementationTasks": [ + { + "id": "WEBUI-NONMESH-ADVANCED", + "module": "NONMESH", + "title": "Expose advanced curve, surface and non-mesh deletion commands", + "status": "implemented", + "attempts": 1, + "commands": [ + "deleteNonMeshData", + "setCurveHandle", + "setCurveSplines", + "setSurfaceTopology" + ], + "ownedPaths": [ + "web/app/src/app/App.tsx", + "web/protocol/nonmesh-geometry-commands.ts" + ], + "resultPath": "tests/golden/WBV2/product-migration/WEBUI-NONMESH-ADVANCED-result.json", + "implementation": "Add context-aware user controls that construct the existing typed commands from the active non-mesh snapshot. Reuse current curve/surface state; do not create a generic JSON command console.", + "startedAt": "2026-09-05T20:52:34.181776+00:00", + "completedAt": "2026-09-05T21:00:54.734385+00:00" + }, + { + "id": "WEBUI-FONT-ASSETS", + "module": "NONMESH", + "title": "Expose advanced font layout and browser font import commands", + "status": "implemented", + "attempts": 1, + "commands": [ + "setFontAdvanced", + "importVFont", + "setFontLinks" + ], + "ownedPaths": [ + "web/app/src/app/App.tsx", + "web/protocol/nonmesh-geometry-commands.ts", + "web/app/src/fonts/external-vfont-import.ts" + ], + "resultPath": "tests/golden/WBV2/product-migration/WEBUI-FONT-ASSETS-result.json", + "implementation": "Add usable font layout/link controls and a TTF/OTF/PFB file picker that uses the existing browser font import adapter and native command route.", + "startedAt": "2026-09-05T21:05:24.110864+00:00", + "completedAt": "2026-09-05T21:28:47.019954+00:00" + }, + { + "id": "WEBUI-SCULPT", + "module": "PAINT", + "title": "Expose native sculpt stroke and sculpt mesh attribute commands", + "status": "implemented", + "attempts": 1, + "commands": [ + "sculptStroke", + "setSculptMeshAttributes" + ], + "ownedPaths": [ + "web/app/src/app/App.tsx", + "web/protocol/paint.ts" + ], + "resultPath": "tests/golden/WBV2/product-migration/WEBUI-SCULPT-result.json", + "implementation": "Add a compact sculpt panel for the active mesh, including brush controls, a deliberate apply action, and the existing native sculpt attribute route.", + "startedAt": "2026-09-05T21:30:26.904527+00:00", + "completedAt": "2026-09-05T21:37:32.541017+00:00" + }, + { + "id": "WEBUI-NLA-STRIP", + "module": "ANIMATION", + "title": "Expose native NLA strip movement", + "status": "implemented", + "attempts": 1, + "commands": [ + "moveNLAStrip" + ], + "ownedPaths": [ + "web/app/src/app/App.tsx", + "web/protocol/animation-render-commands.ts", + "web/protocol/nla.ts" + ], + "resultPath": "tests/golden/WBV2/product-migration/WEBUI-NLA-STRIP-result.json", + "implementation": "Add context-aware NLA track/strip selection and frame-start editing to the Timeline, using the existing typed moveNLAStrip command.", + "startedAt": "2026-09-05T21:37:32.541259+00:00", + "completedAt": "2026-09-05T21:44:42.999869+00:00" + }, + { + "id": "NATIVE-OPERATOR-BRIDGE", + "module": "WORKSPACE", + "title": "Add an allowlisted native Blender operator bridge", + "status": "implemented", + "attempts": 1, + "commands": [ + "listBlenderOperators", + "invokeBlenderOperator" + ], + "ownedPaths": [ + "web/protocol/web-engine.ts", + "web/protocol/web-engine-capabilities.ts", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h", + "blender-5.2.0/source/blender/web_engine/CMakeLists.txt" + ], + "resultPath": "tests/golden/WBV2/product-migration/NATIVE-OPERATOR-BRIDGE-result.json", + "implementation": "Implement a source-backed operator catalog and invocation command for background-safe Blender operators. Use explicit allowlisting, typed JSON properties, context/poll checks, Main transaction rollback, revision updates, and scene refresh. UI/window/file-browser operators must be rejected and left to browser-native workflows.", + "startedAt": "2026-09-05T22:14:54.144626+00:00", + "completedAt": "2026-09-05T22:27:55.341091+00:00" + }, + { + "id": "WEBUI-OPERATOR-PALETTE", + "module": "WORKSPACE", + "title": "Expose the native operator catalog in the Web command palette", + "status": "implemented", + "attempts": 1, + "commands": [ + "listBlenderOperators", + "invokeBlenderOperator" + ], + "ownedPaths": [ + "web/app/src/app/App.tsx", + "web/app/src/engine-client/WebEngineClient.ts", + "web/protocol/blender-operator.ts", + "web/protocol/web-engine.ts" + ], + "resultPath": "tests/golden/WBV2/product-migration/WEBUI-OPERATOR-PALETTE-result.json", + "implementation": "Load the native background-safe operator catalog and merge it into F3 search. Add a compact typed property form for boolean, number, enum, and string properties, with an explicit execute action and no generic arbitrary JSON console.", + "startedAt": "2026-09-05T22:27:55.341459+00:00", + "completedAt": "2026-09-05T22:37:00.849547+00:00" + }, + { + "id": "MESH-EXTENDED-AUTHORING", + "module": "MESH", + "title": "Complete common primitive and mesh authoring workflows", + "status": "implemented", + "attempts": 1, + "commands": [ + "createPrimitive", + "meshEdit", + "invokeBlenderOperator" + ], + "ownedPaths": [ + "web/protocol/web-engine.ts", + "web/protocol/core-scene-commands.ts", + "web/protocol/mesh-authoring-commands.ts", + "web/app/src/app/App.tsx", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" + ], + "resultPath": "tests/golden/WBV2/product-migration/MESH-EXTENDED-AUTHORING-result.json", + "implementation": "Expose plane, UV sphere, ico sphere, cylinder, cone and torus creation plus subdivide, fill, triangulate, recalculate normals, bridge loops and delete mesh elements. Reuse the native operator bridge where a direct Main writer is not already present.", + "startedAt": "2026-09-05T22:37:00.849921+00:00", + "completedAt": "2026-09-05T22:47:31.861458+00:00" + }, + { + "id": "MODIFIER-STACK-AUTHORING", + "module": "MODIFIER", + "title": "Add complete modifier stack authoring controls", + "status": "implemented", + "attempts": 2, + "commands": [ + "addModifier", + "removeModifier", + "moveModifier", + "setModifierParameters" + ], + "ownedPaths": [ + "web/protocol/web-engine.ts", + "web/protocol/modifier.ts", + "web/app/src/app/App.tsx", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h" + ], + "resultPath": "tests/golden/WBV2/product-migration/MODIFIER-STACK-AUTHORING-result.json", + "implementation": "Add native commands and Web controls to add, remove, reorder and edit supported modifier parameters. Drive available types and fields from existing modifier metadata rather than hard-coded test fixtures.", + "startedAt": "2026-09-05T23:02:33.341362+00:00", + "completedAt": "2026-09-05T23:08:38.910114+00:00" + }, + { + "id": "NODE-GRAPH-AUTHORING", + "module": "MODIFIER", + "title": "Add node graph creation and editing workflows", + "status": "implemented", + "attempts": 1, + "commands": [ + "setGeometryNodeGraph", + "setShaderGraph" + ], + "ownedPaths": [ + "web/app/src/app/App.tsx", + "web/protocol/geometry-node-commands.ts", + "web/protocol/geometry-nodes.ts", + "web/protocol/shader-graph-commands.ts", + "web/protocol/shader-graph.ts" + ], + "resultPath": "tests/golden/WBV2/product-migration/NODE-GRAPH-AUTHORING-result.json", + "implementation": "Provide usable graph controls for adding/removing nodes, connecting/disconnecting sockets, editing values, and committing Geometry/Shader graphs through the existing native full-graph commands.", + "startedAt": "2026-09-05T23:08:38.910493+00:00", + "completedAt": "2026-09-05T23:18:10.222798+00:00" + }, + { + "id": "RIG-POSE-AUTHORING", + "module": "ANIMATION", + "title": "Add armature, bone and pose authoring", + "status": "implemented", + "attempts": 1, + "commands": [ + "setArmatureBones", + "setPoseBoneTransform" + ], + "ownedPaths": [ + "web/protocol/web-engine.ts", + "web/protocol/scene-ir.ts", + "web/app/src/app/App.tsx", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h" + ], + "resultPath": "tests/golden/WBV2/product-migration/RIG-POSE-AUTHORING-result.json", + "implementation": "Implement native armature bone creation/editing and pose transforms with parent relationships, roll, head/tail and transform controls, then expose them for the active armature in the Properties workspace.", + "startedAt": "2026-09-05T23:18:10.223133+00:00", + "completedAt": "2026-09-05T23:30:01.504463+00:00" + }, + { + "id": "ACTION-MARKER-AUTHORING", + "module": "ANIMATION", + "title": "Complete Action, keyframe and timeline marker workflows", + "status": "implemented", + "attempts": 1, + "commands": [ + "createAction", + "removeAction", + "setKeyframe", + "setTimelineMarkers" + ], + "ownedPaths": [ + "web/protocol/web-engine.ts", + "web/protocol/animation-render-commands.ts", + "web/protocol/scene-ir.ts", + "web/app/src/app/App.tsx", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" + ], + "resultPath": "tests/golden/WBV2/product-migration/ACTION-MARKER-AUTHORING-result.json", + "implementation": "Add user workflows for creating/removing Actions, editing individual keyframe values/handles/interpolation, and adding, renaming, moving and deleting timeline markers through native Main commands.", + "startedAt": "2026-09-05T23:30:01.504832+00:00", + "completedAt": "2026-09-05T23:44:12.624142+00:00" + }, + { + "id": "SEQUENCER-AUTHORING", + "module": "MEDIA", + "title": "Complete sequencer strip editing", + "status": "implemented", + "attempts": 1, + "commands": [ + "setSequencerStrips" + ], + "ownedPaths": [ + "web/protocol/web-engine.ts", + "web/protocol/sequencer.ts", + "web/app/src/app/App.tsx", + "web/app/src/sequencer/SequencerTimeline.ts", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" + ], + "resultPath": "tests/golden/WBV2/product-migration/SEQUENCER-AUTHORING-result.json", + "implementation": "Expose add, remove, move, trim, split, mute and channel editing for scene, movie, sound, image and meta strips. Commit the complete bounded strip list to Blender Main and retain existing browser media proxy services.", + "startedAt": "2026-09-05T23:44:12.632646+00:00", + "completedAt": "2026-09-05T23:59:08.903421+00:00" + }, + { + "id": "IO-ROUTE-COMPLETION", + "module": "ASSET_IO", + "title": "Complete browser file import and export routes", + "status": "implemented", + "attempts": 3, + "commands": [ + "importScene", + "exportScene" + ], + "ownedPaths": [ + "web/app/src/app/App.tsx", + "web/protocol/io-format-ui-gate.ts", + "web/protocol/io-media-commands.ts", + "web/app/src/capabilities/io-format-ui-registry.json", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp" + ], + "resultPath": "tests/golden/WBV2/product-migration/IO-ROUTE-COMPLETION-result.json", + "implementation": "Connect browser file pickers and downloads for the locally available GLB/glTF, OBJ, STL and PLY routes, preserve explicit loss reports, and route formats requiring unavailable native libraries to the existing server job provider.", + "startedAt": "2026-09-06T00:17:31.550579+00:00", + "completedAt": "2026-09-06T00:18:41.723445+00:00" + }, + { + "id": "SCULPT-ADVANCED-AUTHORING", + "module": "PAINT", + "title": "Complete advanced sculpt workflows", + "status": "implemented", + "attempts": 1, + "commands": [ + "sculptStroke", + "setSculptMeshAttributes", + "invokeBlenderOperator" + ], + "ownedPaths": [ + "web/app/src/app/App.tsx", + "web/protocol/paint.ts", + "web/protocol/sculpt.ts", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" + ], + "resultPath": "tests/golden/WBV2/product-migration/SCULPT-ADVANCED-AUTHORING-result.json", + "implementation": "Add cursor-derived multi-sample strokes, mask and face-set operations, symmetry, smooth/inflate/grab controls, and remesh entry points using native commands or the allowlisted operator bridge.", + "startedAt": "2026-09-06T00:18:41.740013+00:00", + "completedAt": "2026-09-06T00:28:32.739026+00:00" + } + ], + "changedFiles": [ + "web/app/src/app/App.tsx", + "web/protocol/nonmesh-geometry-commands.ts", + "tests/golden/WBV2/product-migration/WEBUI-NONMESH-ADVANCED-result.json", + "web/app/src/fonts/external-vfont-import.ts", + "tests/golden/WBV2/product-migration/WEBUI-FONT-ASSETS-result.json", + "web/protocol/paint.ts", + "web/protocol/animation-render-commands.ts", + "web/protocol/web-engine.ts", + "web/protocol/web-engine-capabilities.ts", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h", + "blender-5.2.0/source/blender/web_engine/CMakeLists.txt", + "web/app/src/engine-client/WebEngineClient.ts", + "web/protocol/blender-operator.ts", + "tests/golden/WBV2/product-migration/WEBUI-OPERATOR-PALETTE-result.json", + "web/protocol/core-scene-commands.ts", + "tests/golden/WBV2/product-migration/MESH-EXTENDED-AUTHORING-result.json", + "web/protocol/modifier.ts", + "web/protocol/geometry-node-commands.ts", + "web/protocol/shader-graph-commands.ts", + "tests/golden/WBV2/product-migration/NODE-GRAPH-AUTHORING-result.json", + "web/protocol/scene-ir.ts", + "tests/golden/WBV2/product-migration/RIG-POSE-AUTHORING-result.json", + "web/app/src/sequencer/SequencerTimeline.ts", + "web/protocol/io-format-ui-gate.ts", + "web/protocol/io-media-commands.ts", + "web/app/src/capabilities/io-format-ui-registry.json" + ], + "sourceInventory": { + "unitCount": 3745, + "role": "traceability_only", + "completionDenominator": false + } +} diff --git a/tests/golden/WBV2-P1421-001/task-context.json b/tests/golden/WBV2-P1421-001/task-context.json index 5b7bf85ec..39c538e7e 100644 --- a/tests/golden/WBV2-P1421-001/task-context.json +++ b/tests/golden/WBV2-P1421-001/task-context.json @@ -34,8 +34,8 @@ "path": "tests/golden/WBV2-P1421-000/manifest.json", "exists": true, "bytes": 911, - "mtime_ns": "1788646326568916212", - "sha256": "bf6c3f96d28a0ba1af8232f844fceae27b245bad6bedccd86b89fac6723a2963" + "mtime_ns": "1788654512766701662", + "sha256": "d0111d65e80f38806ff1cb38b694b0afa04c321c13af67fdd680dee85b7eb3c2" }, "docs/status/WBV2-P1421-000.md": { "path": "docs/status/WBV2-P1421-000.md", @@ -74,18 +74,18 @@ "excluded": [ { "path": "tests/golden/WBV2/web-product-backlog.json", - "bytes": 15709, + "bytes": 17173, "reason": "EVIDENCE_BYTE_BUDGET" }, { "path": "web/app/src/app/App.tsx", - "bytes": 249654, + "bytes": 292380, "reason": "EVIDENCE_BYTE_BUDGET" }, { "path": "tests/golden/WBV2-P1421-001/product-implementation-report.json", - "bytes": null, - "reason": "MISSING_PATH" + "bytes": 18762, + "reason": "EVIDENCE_BYTE_BUDGET" } ], "totals": { @@ -193,7 +193,7 @@ "totalTokens": 2449, "reservedEnvelopeTokens": 1024, "estimatedContextTokens": 3473, - "serializedContextTokens": 641, + "serializedContextTokens": 634, "withinBudget": true } } diff --git a/tests/golden/WBV2/product-migration/ACTION-MARKER-AUTHORING-result.json b/tests/golden/WBV2/product-migration/ACTION-MARKER-AUTHORING-result.json new file mode 100644 index 000000000..2e8fe6ec7 --- /dev/null +++ b/tests/golden/WBV2/product-migration/ACTION-MARKER-AUTHORING-result.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "task": "ACTION-MARKER-AUTHORING", + "status": "implemented", + "changedFiles": [ + "web/protocol/web-engine.ts", + "web/protocol/animation-render-commands.ts", + "web/protocol/scene-ir.ts", + "web/app/src/app/App.tsx", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" + ], + "summary": "Added typed create/remove Action, per-keyframe value/handle/interpolation, and complete timeline-marker workflows, routed through native Main commands and exposed in the timeline UI." +} diff --git a/tests/golden/WBV2/product-migration/IO-ROUTE-COMPLETION-result.json b/tests/golden/WBV2/product-migration/IO-ROUTE-COMPLETION-result.json new file mode 100644 index 000000000..6e1ac29d8 --- /dev/null +++ b/tests/golden/WBV2/product-migration/IO-ROUTE-COMPLETION-result.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "task": "IO-ROUTE-COMPLETION", + "status": "implemented", + "changedFiles": [ + "web/app/src/app/App.tsx", + "web/protocol/io-format-ui-gate.ts", + "web/protocol/io-media-commands.ts", + "web/app/src/capabilities/io-format-ui-registry.json", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp" + ], + "summary": "Connected browser scene import and export for local GLB/glTF, OBJ, STL, and PLY native WebEngine routes, retained explicit loss reports, and delegated formats without available native libraries to the existing server job provider." +} diff --git a/tests/golden/WBV2/product-migration/MESH-EXTENDED-AUTHORING-result.json b/tests/golden/WBV2/product-migration/MESH-EXTENDED-AUTHORING-result.json new file mode 100644 index 000000000..d65327bb3 --- /dev/null +++ b/tests/golden/WBV2/product-migration/MESH-EXTENDED-AUTHORING-result.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "task": "MESH-EXTENDED-AUTHORING", + "status": "implemented", + "changedFiles": [ + "web/protocol/web-engine.ts", + "web/protocol/core-scene-commands.ts", + "web/app/src/app/App.tsx", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", + "tests/golden/WBV2/product-migration/MESH-EXTENDED-AUTHORING-result.json" + ], + "summary": "Expanded native Main-backed primitive creation and mesh editing, exposed the workflows in the Web UI and command palette, and extended the allowlisted Blender operator bridge for primitive creation." +} diff --git a/tests/golden/WBV2/product-migration/MODIFIER-STACK-AUTHORING-result.json b/tests/golden/WBV2/product-migration/MODIFIER-STACK-AUTHORING-result.json new file mode 100644 index 000000000..bee31d691 --- /dev/null +++ b/tests/golden/WBV2/product-migration/MODIFIER-STACK-AUTHORING-result.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "task": "MODIFIER-STACK-AUTHORING", + "status": "implemented", + "changedFiles": [ + "web/protocol/web-engine.ts", + "web/protocol/modifier.ts", + "web/app/src/app/App.tsx", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h" + ], + "summary": "Modifier stack authoring is reachable through typed Web commands and the Main/WASM route for add, remove, reorder, visibility, and RNA-backed parameter edits; the UI derives modifier types and editable fields from authoritative metadata." +} diff --git a/tests/golden/WBV2/product-migration/NATIVE-OPERATOR-BRIDGE-result.json b/tests/golden/WBV2/product-migration/NATIVE-OPERATOR-BRIDGE-result.json new file mode 100644 index 000000000..ddce8203e --- /dev/null +++ b/tests/golden/WBV2/product-migration/NATIVE-OPERATOR-BRIDGE-result.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "task": "NATIVE-OPERATOR-BRIDGE", + "status": "implemented", + "changedFiles": [ + "web/protocol/web-engine.ts", + "web/protocol/web-engine-capabilities.ts", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h", + "blender-5.2.0/source/blender/web_engine/CMakeLists.txt" + ], + "summary": "Added a source-backed allowlisted Blender operator catalog and typed invocation bridge. Background-safe scene, object, and primitive operators route through Main-authoritative command transactions with poll checks, rollback, revision updates, and SceneIR refresh; UI, window, and file-browser operators are rejected for browser-native workflows." +} diff --git a/tests/golden/WBV2/product-migration/NODE-GRAPH-AUTHORING-result.json b/tests/golden/WBV2/product-migration/NODE-GRAPH-AUTHORING-result.json new file mode 100644 index 000000000..f2c26babe --- /dev/null +++ b/tests/golden/WBV2/product-migration/NODE-GRAPH-AUTHORING-result.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "task": "NODE-GRAPH-AUTHORING", + "status": "implemented", + "changedFiles": [ + "web/app/src/app/App.tsx", + "web/protocol/geometry-node-commands.ts", + "web/protocol/shader-graph-commands.ts", + "tests/golden/WBV2/product-migration/NODE-GRAPH-AUTHORING-result.json" + ], + "summary": "Added browser-reachable Geometry and Shader graph drafts with node add/remove, socket connect/disconnect, input value editing, and native full-graph commit actions." +} diff --git a/tests/golden/WBV2/product-migration/RIG-POSE-AUTHORING-result.json b/tests/golden/WBV2/product-migration/RIG-POSE-AUTHORING-result.json new file mode 100644 index 000000000..a49c6ebf1 --- /dev/null +++ b/tests/golden/WBV2/product-migration/RIG-POSE-AUTHORING-result.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "task": "RIG-POSE-AUTHORING", + "status": "implemented", + "changedFiles": [ + "web/protocol/web-engine.ts", + "web/protocol/scene-ir.ts", + "web/app/src/app/App.tsx", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h", + "tests/golden/WBV2/product-migration/RIG-POSE-AUTHORING-result.json" + ], + "summary": "Added native Main-backed armature hierarchy editing and pose-bone TRS commands, Worker/protocol routing, and active-armature Properties authoring controls." +} diff --git a/tests/golden/WBV2/product-migration/SCULPT-ADVANCED-AUTHORING-result.json b/tests/golden/WBV2/product-migration/SCULPT-ADVANCED-AUTHORING-result.json new file mode 100644 index 000000000..b690ddd5e --- /dev/null +++ b/tests/golden/WBV2/product-migration/SCULPT-ADVANCED-AUTHORING-result.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "task": "SCULPT-ADVANCED-AUTHORING", + "status": "implemented", + "changedFiles": [ + "web/app/src/app/App.tsx", + "web/protocol/paint.ts", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" + ], + "summary": "Added cursor-derived multi-sample sculpt strokes, mask and face-set authoring, symmetry and brush controls, and a native voxel-remesh entry through the allowlisted operator bridge." +} diff --git a/tests/golden/WBV2/product-migration/SEQUENCER-AUTHORING-result.json b/tests/golden/WBV2/product-migration/SEQUENCER-AUTHORING-result.json new file mode 100644 index 000000000..406c6a0af --- /dev/null +++ b/tests/golden/WBV2/product-migration/SEQUENCER-AUTHORING-result.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "task": "SEQUENCER-AUTHORING", + "status": "implemented", + "changedFiles": [ + "web/protocol/web-engine.ts", + "web/app/src/sequencer/SequencerTimeline.ts", + "web/app/src/app/App.tsx", + "web/app/src/workers/web-engine.worker.ts", + "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp", + "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" + ], + "summary": "Added the setSequencerStrips command through the typed Worker/native Main route and a browser sequencer editor for adding, removing, moving, trimming, splitting, muting, and channel-editing scene, movie, sound, image, and meta strips while preserving existing media proxy metadata." +} diff --git a/tests/golden/WBV2/product-migration/WEBUI-OPERATOR-PALETTE-result.json b/tests/golden/WBV2/product-migration/WEBUI-OPERATOR-PALETTE-result.json new file mode 100644 index 000000000..423d8a89b --- /dev/null +++ b/tests/golden/WBV2/product-migration/WEBUI-OPERATOR-PALETTE-result.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "task": "WEBUI-OPERATOR-PALETTE", + "status": "implemented", + "changedFiles": [ + "web/app/src/app/App.tsx", + "web/app/src/engine-client/WebEngineClient.ts", + "web/protocol/blender-operator.ts", + "web/protocol/web-engine.ts", + "tests/golden/WBV2/product-migration/WEBUI-OPERATOR-PALETTE-result.json" + ], + "summary": "Loaded the native background-safe Blender operator catalog into the F3 palette and added a compact typed property form that invokes operators through the existing WebEngine JSON route." +} diff --git a/tests/golden/WBV2/web-product-backlog.json b/tests/golden/WBV2/web-product-backlog.json index 0cd34c8ea..f941bd0bb 100644 --- a/tests/golden/WBV2/web-product-backlog.json +++ b/tests/golden/WBV2/web-product-backlog.json @@ -19,7 +19,7 @@ { "id": "WORKSPACE", "title": "Web workspace and viewport", - "status": "partial", + "status": "implemented", "taskIds": [ "NATIVE-OPERATOR-BRIDGE", "WEBUI-OPERATOR-PALETTE" @@ -28,7 +28,7 @@ { "id": "OBJECT", "title": "Object and scene graph", - "status": "partial", + "status": "implemented", "taskIds": [ "NATIVE-OPERATOR-BRIDGE" ] @@ -36,7 +36,7 @@ { "id": "MESH", "title": "Mesh modeling and UV", - "status": "partial", + "status": "implemented", "taskIds": [ "MESH-EXTENDED-AUTHORING" ] @@ -58,7 +58,7 @@ { "id": "MODIFIER", "title": "Modifiers and geometry nodes", - "status": "partial", + "status": "implemented", "taskIds": [ "MODIFIER-STACK-AUTHORING", "NODE-GRAPH-AUTHORING" @@ -101,7 +101,7 @@ { "id": "MEDIA", "title": "Media, audio and sequencer", - "status": "partial", + "status": "implemented", "taskIds": [ "SEQUENCER-AUTHORING" ] @@ -109,7 +109,7 @@ { "id": "ASSET_IO", "title": "Assets, libraries and interchange", - "status": "partial", + "status": "implemented", "taskIds": [ "IO-ROUTE-COMPLETION" ] @@ -210,9 +210,12 @@ "id": "NATIVE-OPERATOR-BRIDGE", "module": "WORKSPACE", "title": "Add an allowlisted native Blender operator bridge", - "status": "pending", - "attempts": 0, - "commands": ["listBlenderOperators", "invokeBlenderOperator"], + "status": "implemented", + "attempts": 1, + "commands": [ + "listBlenderOperators", + "invokeBlenderOperator" + ], "ownedPaths": [ "web/protocol/web-engine.ts", "web/protocol/web-engine-capabilities.ts", @@ -223,15 +226,20 @@ "blender-5.2.0/source/blender/web_engine/CMakeLists.txt" ], "resultPath": "tests/golden/WBV2/product-migration/NATIVE-OPERATOR-BRIDGE-result.json", - "implementation": "Implement a source-backed operator catalog and invocation command for background-safe Blender operators. Use explicit allowlisting, typed JSON properties, context/poll checks, Main transaction rollback, revision updates, and scene refresh. UI/window/file-browser operators must be rejected and left to browser-native workflows." + "implementation": "Implement a source-backed operator catalog and invocation command for background-safe Blender operators. Use explicit allowlisting, typed JSON properties, context/poll checks, Main transaction rollback, revision updates, and scene refresh. UI/window/file-browser operators must be rejected and left to browser-native workflows.", + "startedAt": "2026-09-05T22:14:54.144626+00:00", + "completedAt": "2026-09-05T22:27:55.341091+00:00" }, { "id": "WEBUI-OPERATOR-PALETTE", "module": "WORKSPACE", "title": "Expose the native operator catalog in the Web command palette", - "status": "pending", - "attempts": 0, - "commands": ["listBlenderOperators", "invokeBlenderOperator"], + "status": "implemented", + "attempts": 1, + "commands": [ + "listBlenderOperators", + "invokeBlenderOperator" + ], "ownedPaths": [ "web/app/src/app/App.tsx", "web/app/src/engine-client/WebEngineClient.ts", @@ -239,15 +247,21 @@ "web/protocol/web-engine.ts" ], "resultPath": "tests/golden/WBV2/product-migration/WEBUI-OPERATOR-PALETTE-result.json", - "implementation": "Load the native background-safe operator catalog and merge it into F3 search. Add a compact typed property form for boolean, number, enum, and string properties, with an explicit execute action and no generic arbitrary JSON console." + "implementation": "Load the native background-safe operator catalog and merge it into F3 search. Add a compact typed property form for boolean, number, enum, and string properties, with an explicit execute action and no generic arbitrary JSON console.", + "startedAt": "2026-09-05T22:27:55.341459+00:00", + "completedAt": "2026-09-05T22:37:00.849547+00:00" }, { "id": "MESH-EXTENDED-AUTHORING", "module": "MESH", "title": "Complete common primitive and mesh authoring workflows", - "status": "pending", - "attempts": 0, - "commands": ["createPrimitive", "meshEdit", "invokeBlenderOperator"], + "status": "implemented", + "attempts": 1, + "commands": [ + "createPrimitive", + "meshEdit", + "invokeBlenderOperator" + ], "ownedPaths": [ "web/protocol/web-engine.ts", "web/protocol/core-scene-commands.ts", @@ -257,15 +271,22 @@ "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" ], "resultPath": "tests/golden/WBV2/product-migration/MESH-EXTENDED-AUTHORING-result.json", - "implementation": "Expose plane, UV sphere, ico sphere, cylinder, cone and torus creation plus subdivide, fill, triangulate, recalculate normals, bridge loops and delete mesh elements. Reuse the native operator bridge where a direct Main writer is not already present." + "implementation": "Expose plane, UV sphere, ico sphere, cylinder, cone and torus creation plus subdivide, fill, triangulate, recalculate normals, bridge loops and delete mesh elements. Reuse the native operator bridge where a direct Main writer is not already present.", + "startedAt": "2026-09-05T22:37:00.849921+00:00", + "completedAt": "2026-09-05T22:47:31.861458+00:00" }, { "id": "MODIFIER-STACK-AUTHORING", "module": "MODIFIER", "title": "Add complete modifier stack authoring controls", - "status": "pending", - "attempts": 0, - "commands": ["addModifier", "removeModifier", "moveModifier", "setModifierParameters"], + "status": "implemented", + "attempts": 2, + "commands": [ + "addModifier", + "removeModifier", + "moveModifier", + "setModifierParameters" + ], "ownedPaths": [ "web/protocol/web-engine.ts", "web/protocol/modifier.ts", @@ -276,15 +297,20 @@ "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h" ], "resultPath": "tests/golden/WBV2/product-migration/MODIFIER-STACK-AUTHORING-result.json", - "implementation": "Add native commands and Web controls to add, remove, reorder and edit supported modifier parameters. Drive available types and fields from existing modifier metadata rather than hard-coded test fixtures." + "implementation": "Add native commands and Web controls to add, remove, reorder and edit supported modifier parameters. Drive available types and fields from existing modifier metadata rather than hard-coded test fixtures.", + "startedAt": "2026-09-05T23:02:33.341362+00:00", + "completedAt": "2026-09-05T23:08:38.910114+00:00" }, { "id": "NODE-GRAPH-AUTHORING", "module": "MODIFIER", "title": "Add node graph creation and editing workflows", - "status": "pending", - "attempts": 0, - "commands": ["setGeometryNodeGraph", "setShaderGraph"], + "status": "implemented", + "attempts": 1, + "commands": [ + "setGeometryNodeGraph", + "setShaderGraph" + ], "ownedPaths": [ "web/app/src/app/App.tsx", "web/protocol/geometry-node-commands.ts", @@ -293,15 +319,20 @@ "web/protocol/shader-graph.ts" ], "resultPath": "tests/golden/WBV2/product-migration/NODE-GRAPH-AUTHORING-result.json", - "implementation": "Provide usable graph controls for adding/removing nodes, connecting/disconnecting sockets, editing values, and committing Geometry/Shader graphs through the existing native full-graph commands." + "implementation": "Provide usable graph controls for adding/removing nodes, connecting/disconnecting sockets, editing values, and committing Geometry/Shader graphs through the existing native full-graph commands.", + "startedAt": "2026-09-05T23:08:38.910493+00:00", + "completedAt": "2026-09-05T23:18:10.222798+00:00" }, { "id": "RIG-POSE-AUTHORING", "module": "ANIMATION", "title": "Add armature, bone and pose authoring", - "status": "pending", - "attempts": 0, - "commands": ["setArmatureBones", "setPoseBoneTransform"], + "status": "implemented", + "attempts": 1, + "commands": [ + "setArmatureBones", + "setPoseBoneTransform" + ], "ownedPaths": [ "web/protocol/web-engine.ts", "web/protocol/scene-ir.ts", @@ -312,15 +343,22 @@ "blender-5.2.0/source/blender/web_engine/web_engine_main_state.h" ], "resultPath": "tests/golden/WBV2/product-migration/RIG-POSE-AUTHORING-result.json", - "implementation": "Implement native armature bone creation/editing and pose transforms with parent relationships, roll, head/tail and transform controls, then expose them for the active armature in the Properties workspace." + "implementation": "Implement native armature bone creation/editing and pose transforms with parent relationships, roll, head/tail and transform controls, then expose them for the active armature in the Properties workspace.", + "startedAt": "2026-09-05T23:18:10.223133+00:00", + "completedAt": "2026-09-05T23:30:01.504463+00:00" }, { "id": "ACTION-MARKER-AUTHORING", "module": "ANIMATION", "title": "Complete Action, keyframe and timeline marker workflows", - "status": "pending", - "attempts": 0, - "commands": ["createAction", "removeAction", "setKeyframe", "setTimelineMarkers"], + "status": "implemented", + "attempts": 1, + "commands": [ + "createAction", + "removeAction", + "setKeyframe", + "setTimelineMarkers" + ], "ownedPaths": [ "web/protocol/web-engine.ts", "web/protocol/animation-render-commands.ts", @@ -331,15 +369,19 @@ "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" ], "resultPath": "tests/golden/WBV2/product-migration/ACTION-MARKER-AUTHORING-result.json", - "implementation": "Add user workflows for creating/removing Actions, editing individual keyframe values/handles/interpolation, and adding, renaming, moving and deleting timeline markers through native Main commands." + "implementation": "Add user workflows for creating/removing Actions, editing individual keyframe values/handles/interpolation, and adding, renaming, moving and deleting timeline markers through native Main commands.", + "startedAt": "2026-09-05T23:30:01.504832+00:00", + "completedAt": "2026-09-05T23:44:12.624142+00:00" }, { "id": "SEQUENCER-AUTHORING", "module": "MEDIA", "title": "Complete sequencer strip editing", - "status": "pending", - "attempts": 0, - "commands": ["setSequencerStrips"], + "status": "implemented", + "attempts": 1, + "commands": [ + "setSequencerStrips" + ], "ownedPaths": [ "web/protocol/web-engine.ts", "web/protocol/sequencer.ts", @@ -350,15 +392,20 @@ "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" ], "resultPath": "tests/golden/WBV2/product-migration/SEQUENCER-AUTHORING-result.json", - "implementation": "Expose add, remove, move, trim, split, mute and channel editing for scene, movie, sound, image and meta strips. Commit the complete bounded strip list to Blender Main and retain existing browser media proxy services." + "implementation": "Expose add, remove, move, trim, split, mute and channel editing for scene, movie, sound, image and meta strips. Commit the complete bounded strip list to Blender Main and retain existing browser media proxy services.", + "startedAt": "2026-09-05T23:44:12.632646+00:00", + "completedAt": "2026-09-05T23:59:08.903421+00:00" }, { "id": "IO-ROUTE-COMPLETION", "module": "ASSET_IO", "title": "Complete browser file import and export routes", - "status": "pending", - "attempts": 0, - "commands": ["importScene", "exportScene"], + "status": "implemented", + "attempts": 3, + "commands": [ + "importScene", + "exportScene" + ], "ownedPaths": [ "web/app/src/app/App.tsx", "web/protocol/io-format-ui-gate.ts", @@ -368,15 +415,21 @@ "blender-5.2.0/source/blender/web_engine/web_engine_api.cpp" ], "resultPath": "tests/golden/WBV2/product-migration/IO-ROUTE-COMPLETION-result.json", - "implementation": "Connect browser file pickers and downloads for the locally available GLB/glTF, OBJ, STL and PLY routes, preserve explicit loss reports, and route formats requiring unavailable native libraries to the existing server job provider." + "implementation": "Connect browser file pickers and downloads for the locally available GLB/glTF, OBJ, STL and PLY routes, preserve explicit loss reports, and route formats requiring unavailable native libraries to the existing server job provider.", + "startedAt": "2026-09-06T00:17:31.550579+00:00", + "completedAt": "2026-09-06T00:18:41.723445+00:00" }, { "id": "SCULPT-ADVANCED-AUTHORING", "module": "PAINT", "title": "Complete advanced sculpt workflows", - "status": "pending", - "attempts": 0, - "commands": ["sculptStroke", "setSculptMeshAttributes", "invokeBlenderOperator"], + "status": "implemented", + "attempts": 1, + "commands": [ + "sculptStroke", + "setSculptMeshAttributes", + "invokeBlenderOperator" + ], "ownedPaths": [ "web/app/src/app/App.tsx", "web/protocol/paint.ts", @@ -386,7 +439,9 @@ "blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp" ], "resultPath": "tests/golden/WBV2/product-migration/SCULPT-ADVANCED-AUTHORING-result.json", - "implementation": "Add cursor-derived multi-sample strokes, mask and face-set operations, symmetry, smooth/inflate/grab controls, and remesh entry points using native commands or the allowlisted operator bridge." + "implementation": "Add cursor-derived multi-sample strokes, mask and face-set operations, symmetry, smooth/inflate/grab controls, and remesh entry points using native commands or the allowlisted operator bridge.", + "startedAt": "2026-09-06T00:18:41.740013+00:00", + "completedAt": "2026-09-06T00:28:32.739026+00:00" } ] } diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 947bbc2d3..e554baa43 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -4,9 +4,11 @@ import { WebEngineClient } from "../engine-client/WebEngineClient"; import { loadWebEngineManifest, verifyWasmResource } from "../../../protocol/manifest"; import type { ProgressEvent } from "../../../protocol/progress"; import type { SceneSnapshotIR } from "../../../protocol/scene-ir"; +import type { SequencerStripIR, SequencerStripType, SequencerTimelineIR } from "../../../protocol/sequencer"; import type { GPUTextureAsset } from "../../../protocol/render-assets"; import { collectGPUTextureAssetRequests, createGPUTextureAsset, RenderAssetValidationError } from "../../../protocol/render-assets"; -import type { MeshEditOperation, MeshElementMode, MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODLevelResult } from "../../../protocol/web-engine"; +import type { MeshEditOperation, MeshElementMode, MeshGeometryBuffer, MeshPrimitive, WebEngineEditCommand, WebEngineLODLevelResult } from "../../../protocol/web-engine"; +import type { BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorPropertyValue } from "../../../protocol/blender-operator"; import { coreSceneCommands } from "../../../protocol/core-scene-commands"; import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands"; import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands"; @@ -19,6 +21,8 @@ import { parseLibraryMainAppendRequest, type LibraryMainAppendRequestIR } from " import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities"; import { compositorCommands } from "../../../protocol/compositor-commands"; import { geometryNodeCommands } from "../../../protocol/geometry-node-commands"; +import { GEOMETRY_NODE_ALLOWLIST, type GeometryNodeGraphIR } from "../../../protocol/geometry-nodes"; +import type { ShaderGraphIR, ShaderSocketIR } from "../../../protocol/shader-graph"; import { physicsCommands } from "../../../protocol/physics-commands"; import { WEB_ENGINE_COMMAND_CAPABILITIES } from "../../../protocol/web-engine-capabilities"; import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; @@ -34,7 +38,7 @@ import type { GreasePencilPointPreview, GreasePencilPointRef } from "../three-ad import { buildLODCacheKey } from "../three-adapter/lod"; import { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache"; import type { LODCacheRecord } from "../../../protocol/lod"; -import { modifierStackHash } from "../../../protocol/modifier"; +import { modifierParameterMetadata, modifierStackHash } from "../../../protocol/modifier"; import { exportGLB } from "../../../protocol/glb-export"; import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export"; import { normalizeProjectAssetPath } from "../../../protocol/asset-path"; @@ -63,7 +67,7 @@ import { type NanoVDBViewportProjectContextIR, } from "../volume/nanovdb-viewport"; import { composePaintColorPatch, composePaintWeightPatch, createSculptMeshAttributes, createSculptStroke } from "../../../protocol/paint"; -import type { SculptBrush } from "../../../protocol/sculpt"; +import type { SculptBrush, SculptStrokeSampleIR } from "../../../protocol/sculpt"; import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema"; import { parseEditorWorkflow, type EditorWorkflowIR } from "../../../protocol/editor-workflow"; import { importExternalVFontIntoMain } from "../fonts/external-vfont-import"; @@ -104,6 +108,112 @@ function StorageBudgetPanel({ budget, onCleanup }: { budget: StorageBudgetResult return
存储预算{values.map(([label, bytes]) => {label} {formatStorageBytes(bytes)})}合计 {formatStorageBytes(budget?.totalBytes ?? 0)}
; } +interface OperatorPaletteCommand { + id: string; + label: string; + keywords?: string; + execute?: () => unknown; +} + +function BlenderOperatorPalette({ client, commands, onClose, onApplied }: { + client: WebEngineClient | null; + commands: ReadonlyArray; + onClose: () => void; + onApplied: (result: Awaited>, descriptor: BlenderOperatorDescriptor) => void; +}) { + const [query, setQuery] = useState(""); + const [catalog, setCatalog] = useState([]); + const [selected, setSelected] = useState(null); + const [values, setValues] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [executing, setExecuting] = useState(false); + + useEffect(() => { + let active = true; + if (!client) { + setLoading(false); + return () => { active = false; }; + } + setLoading(true); + void client.listBlenderOperators().then(({ operators }) => { + if (active) setCatalog(operators); + }).catch((reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : String(reason)); + }).finally(() => { + if (active) setLoading(false); + }); + return () => { active = false; }; + }, [client]); + + const normalizedQuery = query.trim().toLowerCase(); + const staticMatches = commands.filter((command) => `${command.label} ${command.keywords ?? ""}`.toLowerCase().includes(normalizedQuery)); + const nativeMatches = catalog.filter((operator) => `${operator.id} ${operator.label} ${operator.family} ${operator.description ?? ""}`.toLowerCase().includes(normalizedQuery)); + + const chooseOperator = (operator: BlenderOperatorDescriptor): void => { + const next: Record = {}; + for (const property of operator.properties) { + if (property.defaultValue !== undefined) next[property.name] = property.defaultValue; + else if (property.type === "enum" && property.enumItems?.[0]) next[property.name] = property.enumItems[0].value; + else if (property.type === "boolean") next[property.name] = false; + else next[property.name] = ""; + } + setValues(next); + setSelected(operator); + setError(null); + }; + + const renderProperty = (property: BlenderOperatorPropertySpec): React.ReactNode => { + const value = values[property.name]; + const label = property.label ?? property.name; + if (property.type === "boolean") { + return ; + } + if (property.type === "enum") { + return ; + } + return ; + }; + + const executeOperator = async (event: React.FormEvent): Promise => { + event.preventDefault(); + if (!selected || !client) return; + const properties: Record = {}; + for (const property of selected.properties) { + const value = values[property.name]; + if (property.required && (value === undefined || value === "")) { + setError(`${property.label ?? property.name} 为必填项`); + return; + } + if (property.type === "number") { + if (value === "" || value === undefined) continue; + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) { + setError(`${property.label ?? property.name} 必须是数字`); + return; + } + properties[property.name] = numberValue; + } + else if (value !== undefined && value !== "") properties[property.name] = value; + } + setExecuting(true); + setError(null); + try { + const result = await client.invokeBlenderOperator({ operator: selected.id, properties }); + onApplied(result, selected); + onClose(); + } + catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + finally { + setExecuting(false); + } + }; + + return
{ if (event.target === event.currentTarget) onClose(); }}>
操作搜索
setQuery(event.target.value)} />{loading ?

正在加载原生操作目录…

: null}{error ?

{error}

: null}
{staticMatches.map((command) => )}{nativeMatches.map((operator) => )}{!loading && staticMatches.length === 0 && nativeMatches.length === 0 ?

没有匹配的操作

: null}
{selected ?
void executeOperator(event)}>

{selected.label}

{selected.properties.map(renderProperty)}
: null}
; +} + async function sha256Hex(data: ArrayBuffer): Promise { const digest = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); @@ -685,6 +795,16 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer const [materialCoat, setMaterialCoat] = useState(0); const [materialCoatRoughness, setMaterialCoatRoughness] = useState(0.03); const [materialEmissionStrength, setMaterialEmissionStrength] = useState(1); + const [geometryGraphDraft, setGeometryGraphDraft] = useState(null); + const [geometryNodeType, setGeometryNodeType] = useState(GEOMETRY_NODE_ALLOWLIST[2]); + const [geometryNodeName, setGeometryNodeName] = useState("Web Node"); + const [geometryLinkFrom, setGeometryLinkFrom] = useState(""); + const [geometryLinkTo, setGeometryLinkTo] = useState(""); + const [shaderGraphDraft, setShaderGraphDraft] = useState(null); + const [shaderNodeType, setShaderNodeType] = useState<"RGB" | "VALUE" | "MATH" | "IMAGE_TEXTURE" | "PRINCIPLED" | "MATERIAL_OUTPUT">("VALUE"); + const [shaderNodeName, setShaderNodeName] = useState("Web Node"); + const [shaderLinkFrom, setShaderLinkFrom] = useState(""); + const [shaderLinkTo, setShaderLinkTo] = useState(""); const [renameValue, setRenameValue] = useState(""); const [paintColor, setPaintColor] = useState("#cc6633"); const [paintWeight, setPaintWeight] = useState(1); @@ -702,6 +822,8 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer const [sculptMirrorObjectSpace, setSculptMirrorObjectSpace] = useState(false); const [sculptMaskValue, setSculptMaskValue] = useState(0); const [sculptActiveFaceSet, setSculptActiveFaceSet] = useState(1); + const [sculptRemeshVoxelSize, setSculptRemeshVoxelSize] = useState(0.05); + const [sculptCursorSamples, setSculptCursorSamples] = useState([]); const [greasePencilLayerId, setGreasePencilLayerId] = useState(""); const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer"); const [greasePencilStrokeIndex, setGreasePencilStrokeIndex] = useState(0); @@ -751,6 +873,56 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer const activeWorld = snapshot?.worlds.find((world) => world.id === activeNode?.dataId); const activeScene = snapshot?.scenes.find((scene) => scene.id === snapshot.sceneId) ?? snapshot?.scenes[0]; const packedImages = snapshot?.images.filter((image) => image.packed && image.assetId) ?? []; + useEffect(() => { + let drawing = false; + const viewportPoint = (event: PointerEvent): SculptStrokeSampleIR | null => { + const viewport = document.querySelector("[data-editor='3D Viewport']"); + const target = event.target; + if (!viewport || !(target instanceof Node) || !viewport.contains(target)) return null; + const rect = viewport.getBoundingClientRect(); + const width = Math.max(1, rect.width); + const height = Math.max(1, rect.height); + const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / width)); + const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / height)); + return { + position: [x * 2 - 1, (1 - y) * 2 - 1, 0], + normal: [0, 0, 1], + radius: sculptRadius, + strength: sculptStrength, + pressure: Math.max(0, Math.min(1, event.pressure || sculptPressure)), + time: event.timeStamp / 1000, + }; + }; + const beginStroke = (event: PointerEvent): void => { + if (event.button !== 0) return; + const point = viewportPoint(event); + if (!point) return; + drawing = true; + setSculptCursorSamples([point]); + }; + const extendStroke = (event: PointerEvent): void => { + if (!drawing && event.buttons === 0) return; + const point = viewportPoint(event); + if (!point) return; + drawing = true; + setSculptCursorSamples((current) => { + const previous = current[current.length - 1]; + if (previous && Math.hypot(point.position[0] - previous.position[0], point.position[1] - previous.position[1]) < 0.002) return current; + return [...current, point].slice(-2048); + }); + }; + const endStroke = (): void => { drawing = false; }; + window.addEventListener("pointerdown", beginStroke); + window.addEventListener("pointermove", extendStroke); + window.addEventListener("pointerup", endStroke); + window.addEventListener("pointercancel", endStroke); + return () => { + window.removeEventListener("pointerdown", beginStroke); + window.removeEventListener("pointermove", extendStroke); + window.removeEventListener("pointerup", endStroke); + window.removeEventListener("pointercancel", endStroke); + }; + }, [sculptPressure, sculptRadius, sculptStrength]); useEffect(() => { if (!activeMaterial) return; setMaterialColor(`#${activeMaterial.baseColor.slice(0, 3).map((component) => Math.round(Math.max(0, Math.min(1, component)) * 255).toString(16).padStart(2, "0")).join("")}`); @@ -763,6 +935,18 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer setMaterialCoatRoughness(activeMaterial.coatRoughness ?? 0.03); setMaterialEmissionStrength(activeMaterial.emissionStrength ?? 1); }, [activeMaterial]); + useEffect(() => { + if (activeGeometryGraph) setGeometryGraphDraft(JSON.parse(JSON.stringify(activeGeometryGraph)) as GeometryNodeGraphIR); + else if (activeMesh) setGeometryGraphDraft(geometryNodeCommands.createGraph(activeMesh.id, `${activeMesh.name} Geometry`)); + else setGeometryGraphDraft(null); + }, [activeGeometryGraph?.id, activeGeometryGraph?.graphHash, activeMesh?.id]); + useEffect(() => { + if (!activeMaterial) { + setShaderGraphDraft(null); + return; + } + setShaderGraphDraft(shaderGraphCommands.setPrincipledGraph(activeMaterial.id, activeMaterial.baseColor, activeMaterial.roughness, activeMaterial.metallic)); + }, [activeMaterial?.id]); useEffect(() => setRenameValue(activeNode?.name ?? ""), [activeNode?.id]); useEffect(() => setFontBody(activeFont?.text ?? ""), [activeFont?.id, activeFont?.text]); useEffect(() => setFontLinks(activeFont?.fontLinks ?? { regular: "", bold: "", italic: "", boldItalic: "" }), [activeFont?.id, activeFont?.fontLinks]); @@ -836,13 +1020,31 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer }; const applySculptStroke = (): void => { if (!activeMesh) return; - onCommand({ type: "sculptStroke", stroke: createSculptStroke(activeMesh.id, sculptBrush, sculptRadius, sculptStrength, sculptPressure, sculptSymmetry, sculptMirrorObjectSpace) }); + onCommand({ type: "sculptStroke", stroke: createSculptStroke(activeMesh.id, sculptBrush, sculptRadius, sculptStrength, sculptPressure, sculptSymmetry, sculptMirrorObjectSpace, sculptCursorSamples) }); + setSculptCursorSamples([]); + }; + const applySculptRemesh = (): void => { + if (!client || !activeMesh) return; + void client.invokeBlenderOperator({ + operator: "object.voxel_remesh", + properties: { objectId: activeMesh.id, voxel_size: sculptRemeshVoxelSize }, + }).catch(() => undefined); }; const applySculptMeshAttributes = (): void => { if (!activeMesh || !snapshot) return; const faceCount = (activeMesh as typeof activeMesh & { faceCount?: number }).faceCount ?? 0; onCommand({ type: "setSculptMeshAttributes", attributes: createSculptMeshAttributes(activeMesh.id, activeMesh.vertexCount, faceCount, sculptMaskValue, sculptActiveFaceSet, snapshot.revision) }); }; + const commitGeometryGraph = (): void => { + if (activeMesh && geometryGraphDraft) onCommand(geometryNodeCommands.setGraph(activeMesh.id, geometryGraphDraft)); + }; + const commitShaderGraph = (): void => { + if (activeMaterial && shaderGraphDraft) onCommand({ type: "setShaderGraph", materialId: activeMaterial.id, graph: shaderGraphDraft }); + }; + const parseGraphSocket = (value: string): { nodeId: string; socketId: string } | null => { + const separator = value.lastIndexOf(":"); + return separator > 0 ? { nodeId: value.slice(0, separator), socketId: value.slice(separator + 1) } : null; + }; const translateGreasePencilPoint = (): void => { if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return; const selectedPoints = greasePencilPointSelection @@ -1096,6 +1298,87 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer

Font Style Links

{(["regular", "bold", "italic", "boldItalic"] as const).map((slot) => { const selected = fontLinks[slot]; const resources = snapshot?.vfonts ?? []; return ; })}
{Object.entries(fontLinks).map(([slot, fontId]) => `${slot}: ${fontId || "default"}`).join(" · ") || "No explicit style links"}
: null} {activeVolume ?

Volume

{activeVolume.sourcePath ?? "No source path"}{activeVolume.volumeGrids?.length ?? 0} grids{activeVolume.volumeProperties ? <>Density {activeVolume.volumeProperties.displayDensity.toFixed(3)} · Step {activeVolume.volumeProperties.stepSize.toFixed(4)}
: Volume properties unavailable}
: null} + {activeNode?.type === "ARMATURE" ? (() => { + const armature = snapshot?.armatures?.find((item) => item.objectId === activeNode.id || item.id === activeNode.dataId); + if (!armature) return null; + const commandBones = armature.bones.map((bone) => ({ + name: bone.name, + parentName: armature.bones.find((candidate) => candidate.id === bone.parentId)?.name ?? null, + head: bone.head, + tail: bone.tail, + roll: bone.roll ?? 0, + connected: bone.connected ?? false, + })); + return
+

Armature

+ {armature.bones.length} bones +
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + const name = String(data.get("newBoneName") ?? "").trim(); + if (!name) return; + const number = (field: string) => Number(data.get(field)); + onCommand({ type: "setArmatureBones", objectId: activeNode.id, bones: [...commandBones, { + name, + parentName: String(data.get("newBoneParent") ?? "") || null, + head: [number("newHeadX"), number("newHeadY"), number("newHeadZ")], + tail: [number("newTailX"), number("newTailY"), number("newTailZ")], + roll: number("newRoll"), + connected: data.get("newConnected") === "on", + }] }); + }}> + + + {(["X", "Y", "Z"] as const).map((axis, index) => )} + {(["X", "Y", "Z"] as const).map((axis, index) => )} + + +
+
+ {armature.bones.map((bone) => { + const currentParent = armature.bones.find((candidate) => candidate.id === bone.parentId)?.name ?? ""; + return
+

{bone.name}

+
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + const number = (field: string) => Number(data.get(field)); + const nextName = String(data.get("boneName") ?? "").trim(); + onCommand({ type: "setArmatureBones", objectId: activeNode.id, bones: commandBones.map((item) => item.name === bone.name ? { + name: nextName, + parentName: String(data.get("parentName") ?? "") || null, + head: [number("headX"), number("headY"), number("headZ")], + tail: [number("tailX"), number("tailY"), number("tailZ")], + roll: number("roll"), + connected: data.get("connected") === "on", + } : { ...item, parentName: item.parentName === bone.name ? nextName : item.parentName }) }); + }}> + + + {(["X", "Y", "Z"] as const).map((axis, index) => )} + {(["X", "Y", "Z"] as const).map((axis, index) => )} + + +
+
+
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + const number = (field: string) => Number(data.get(field)); + onCommand({ type: "setPoseBoneTransform", objectId: activeNode.id, boneName: bone.name, + translation: [number("locationX"), number("locationY"), number("locationZ")], + rotationEuler: [number("rotationX"), number("rotationY"), number("rotationZ")], + scale: [number("scaleX"), number("scaleY"), number("scaleZ")] }); + }}> + {(["X", "Y", "Z"] as const).map((axis) => )} + {(["X", "Y", "Z"] as const).map((axis) => )} + {(["X", "Y", "Z"] as const).map((axis) => )} +
+
+
; + })} +
; + })() : null} {activeMetaball ?

Metaball

{activeMetaball.elements?.length ?? 0} elements{activeMetaball.elements?.length ?
: No editable elements}
: null} {activeNonMeshData ?

Non-Mesh Data

{activeNonMeshData.name} · {activeNonMeshData.type} · {activeNonMeshData.pointCount} points
: null}

Render Capabilities

{snapshot?.scenes[0]?.compositorGraph ? : null}
@@ -1141,7 +1424,7 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer {activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes : null} {activeMesh && activeNode ?

Paint

{paintMirror ? : null}
{selectedVertexIndices.length} selected vertices{activeMesh.attributes?.some((attribute) => attribute.name === "WebPaintColor" && attribute.domain === "POINT") ? "WebPaintColor POINT" : "No WebPaintColor"}{activeMesh.vertexGroups?.some((group) => group.name === paintGroup) ? paintGroup : "No paint group"}
: null} - {activeMesh ?

Sculpt

{activeMesh.vertexCount} vertices · {(activeMesh as typeof activeMesh & { faceCount?: number }).faceCount ?? 0} faces
: null} + {activeMesh ?

Sculpt

{activeMesh.vertexCount} vertices · {(activeMesh as typeof activeMesh & { faceCount?: number }).faceCount ?? 0} faces
: null} {activeMesh ?

Material Slots

{activeMesh.materialSlotIds?.map((id, index) => {index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"})}
@@ -1164,8 +1447,41 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer : null}
: null} + {activeMesh && geometryGraphDraft ? (() => { + const outputs = geometryGraphDraft.nodes.flatMap((node) => node.sockets.filter((socket) => socket.direction === "OUTPUT").map((socket) => ({ key: `${node.id}:${socket.id}`, label: `${node.name} · ${socket.name}`, nodeId: node.id, socketId: socket.id }))); + const inputs = geometryGraphDraft.nodes.flatMap((node) => node.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => ({ key: `${node.id}:${socket.id}`, label: `${node.name} · ${socket.name}`, nodeId: node.id, socketId: socket.id }))); + return
+

Geometry Node Graph

+
+
+
{geometryGraphDraft.nodes.map((node) =>
{node.name}{node.type}
{node.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => )}
)}
+
{geometryGraphDraft.links.map((link, index) =>
{link.fromNodeId}:{link.fromSocketId} → {link.toNodeId}:{link.toSocketId}
)}
+ +
; + })() : null} + {activeMaterial && shaderGraphDraft ? (() => { + const outputs = shaderGraphDraft.nodes.flatMap((node) => node.sockets.filter((socket) => socket.direction === "OUTPUT").map((socket) => ({ key: `${node.id}:${socket.id}`, label: `${node.name} · ${socket.name}` }))); + const inputs = shaderGraphDraft.nodes.flatMap((node) => node.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => ({ key: `${node.id}:${socket.id}`, label: `${node.name} · ${socket.name}` }))); + const renderValue = (nodeId: string, socket: ShaderSocketIR): React.ReactNode => { + if (socket.defaultValue === undefined) return linked; + return { const raw = typeof socket.defaultValue === "boolean" ? event.target.checked : event.target.value; const value = Array.isArray(socket.defaultValue) ? raw.toString().split(",").map((item) => Number(item.trim())).filter((item) => Number.isFinite(item)) : typeof socket.defaultValue === "number" ? Number(raw) : raw; setShaderGraphDraft(shaderGraphCommands.setSocketValue(shaderGraphDraft, nodeId, socket.id, value)); }} />; + }; + return
+

Shader Graph

+
+
+
{shaderGraphDraft.nodes.map((node) =>
{node.name}{node.type}
{node.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => )}
)}
+
{shaderGraphDraft.links.map((link, index) =>
{link.fromNodeId}:{link.fromSocketId} → {link.toNodeId}:{link.toSocketId}
)}
+ +
; + })() : null} {activeNode?.constraints?.length ?

Constraints

{activeNode.constraints.map((constraint) => )}
: null} - {activeMesh?.modifierStack?.length ?

Modifier Stack

{activeMesh.modifierStack.map((modifier) =>
{modifier.name}{(["showViewport", "showRender", "showEditMode", "showOnCage"] as const).map((field, index) => )}
)}
: null} + {activeMesh ? (() => { + const meshMetadata = activeMesh as typeof activeMesh & { modifierMetadata?: { types?: string[] } }; + const availableTypes = meshMetadata.modifierMetadata?.types?.filter(Boolean) ?? Array.from(new Set((activeMesh.modifierStack ?? []).map((modifier) => modifier.type).filter(Boolean))); + const firstType = availableTypes[0] ?? ""; + return

Modifier Stack

{availableTypes.map((type) =>
{activeMesh.modifierStack?.map((modifier, index) =>
{modifier.name}{modifier.type}
{(["showViewport", "showRender", "showEditMode", "showOnCage"] as const).map((field, fieldIndex) => )}
{modifierParameterMetadata(modifier).map((field) => )}
)}
; + })() : null}

Decimate

@@ -1245,11 +1561,90 @@ function OperatorSearch({ commands, onClose }: { commands: readonly OperatorComm ); } +function SequencerAuthoringPanel({ scene, revision, frame, onCommand }: { scene: SceneSnapshotIR["scenes"][number]; revision: number; frame: number; onCommand: (command: WebEngineEditCommand) => void }) { + const timeline = scene.sequencerTimeline ?? { + schemaVersion: 1, + id: `sequencer:${scene.id}`, + revision, + frameStart: 1, + frameEnd: 250, + fpsNumerator: 24000, + fpsDenominator: 1000, + strips: [], + } satisfies SequencerTimelineIR; + const [selectedId, setSelectedId] = useState(null); + const [newType, setNewType] = useState>("MOVIE"); + const strips = timeline.strips; + const selected = strips.find((strip) => strip.id === selectedId) ?? strips[0] ?? null; + useEffect(() => { + if (selected?.id !== selectedId) setSelectedId(selected?.id ?? null); + }, [selected?.id, selectedId]); + const commit = (nextStrips: SequencerStripIR[]): void => { + onCommand({ type: "setSequencerStrips", sceneId: scene.id, timeline: { ...timeline, revision, strips: nextStrips } }); + }; + const addStrip = (): void => { + const id = `strip:web-${Date.now().toString(36)}`; + const duration = 24; + const strip: SequencerStripIR = { + id, + name: `Web ${newType}`, + type: newType, + channel: 1, + frameStart: frame, + frameEnd: frame + duration, + sourceStart: 0, + sourceEnd: duration, + speed: 1, + muted: false, + locked: false, + ...(newType === "SCENE" ? { sourceId: scene.id } : {}), + ...(newType === "META" ? { childStripIds: [] } : {}), + ...(newType === "MOVIE" ? { sourcePath: `//web-proxy/${id.slice(6)}.mp4`, mimeType: "video/mp4" } : {}), + ...(newType === "SOUND" ? { sourcePath: `//web-proxy/${id.slice(6)}.wav`, mimeType: "audio/wav" } : {}), + ...(newType === "IMAGE" ? { sourcePath: `//web-proxy/${id.slice(6)}.png`, mimeType: "image/png" } : {}), + }; + commit([...strips, strip]); + setSelectedId(id); + }; + const removeSelected = (): void => { + if (!selected) return; + const next = strips.filter((strip) => strip.id !== selected.id).map((strip) => ({ + ...strip, + inputStripIds: strip.inputStripIds?.filter((id) => id !== selected.id), + childStripIds: strip.childStripIds?.filter((id) => id !== selected.id), + })); + commit(next); + setSelectedId(null); + }; + const updateSelected = (patch: Partial): void => { + if (!selected) return; + commit(strips.map((strip) => strip.id === selected.id ? { ...strip, ...patch } : strip)); + }; + const moveSelected = (frameDelta: number, channelDelta = 0): void => { + if (!selected) return; + updateSelected({ frameStart: selected.frameStart + frameDelta, frameEnd: selected.frameEnd + frameDelta, channel: Math.max(1, selected.channel + channelDelta) }); + }; + const splitSelected = (): void => { + if (!selected || selected.frameEnd - selected.frameStart < 2) return; + const splitFrame = selected.frameStart + Math.floor((selected.frameEnd - selected.frameStart) / 2); + const sourceSplit = selected.sourceStart + Math.round((splitFrame - selected.frameStart) * selected.speed); + const rightId = `${selected.id}:right`; + if (strips.some((strip) => strip.id === rightId)) return; + const left = { ...selected, frameEnd: splitFrame, sourceEnd: sourceSplit }; + const right = { ...selected, id: rightId, name: `${selected.name} Right`, frameStart: splitFrame, sourceStart: sourceSplit }; + commit(strips.flatMap((strip) => strip.id === selected.id ? [left, right] : [strip])); + setSelectedId(rightId); + }; + return
{selected ?
{selected.id} · {selected.type}
: {scene.sequencerStatus === "AVAILABLE" ? "No strips" : "Sequencer timeline will be created in Main"}}
; +} + function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { snapshot: SceneSnapshotIR | null; frame: number; start: number; end: number; onFrameChange: (value: number) => void; onCommand: (command: WebEngineEditCommand) => void }) { const [playing, setPlaying] = useState(false); const [selectedNlaTrackId, setSelectedNlaTrackId] = useState(null); const [selectedNlaStripId, setSelectedNlaStripId] = useState(null); const [nlaFrameStartDraft, setNlaFrameStartDraft] = useState(""); + const [actionNameDraft, setActionNameDraft] = useState(""); + const [markerNameDraft, setMarkerNameDraft] = useState(""); useEffect(() => { if (!playing) return undefined; const timer = window.setInterval(() => { @@ -1267,6 +1662,8 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s const fourth = Math.round(start + (end - start) * 0.8); const animation = snapshot?.animations.find((candidate) => candidate.targetId === snapshot.activeObjectId); const actions = snapshot?.animations.filter((candidate) => candidate.targetId === snapshot.activeObjectId) ?? []; + const activeScene = snapshot?.scenes?.[0]; + const markers = activeScene?.timelineMarkers ?? []; const nlaTracks = snapshot?.nlaTracks?.filter((track) => track.ownerId === snapshot.activeObjectId) ?? []; const selectedNlaTrack = nlaTracks.find((track) => track.id === selectedNlaTrackId) ?? null; const selectedNlaStrip = selectedNlaTrack?.strips.find((strip) => strip.id === selectedNlaStripId) ?? null; @@ -1299,12 +1696,16 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s : animation?.channels.flatMap((channel) => channel.keyframes.map((keyframe) => keyframe.frame)) ?? [])] .filter((keyframe) => keyframe >= start && keyframe <= end) .sort((left, right) => left - right); + const activeKeyframe = animation?.channels[0]?.keyframes.find((keyframe) => keyframe.frame === frame); return (
{frame}{snapshot?.activeObjectId ? <> : null}
onFrameChange(Number(event.target.value))} aria-label="当前帧" />
{start}{mid}{second}{third}{fourth}{end}
-
{greasePencil?.name ?? animation?.name ?? "No Action"}
{keyframes.map((keyframe) =>
{!greasePencil && animation?.channels[0] ? : }{snapshot?.activeObjectId && actions.length > 0 ? : null}{snapshot?.activeObjectId && nlaTracks.length > 0 ? : null}
+
{greasePencil?.name ?? animation?.name ?? "No Action"}
{keyframes.map((keyframe) =>
{!greasePencil && animation?.channels[0] ? : }{snapshot?.activeObjectId ? <> setActionNameDraft(event.target.value)} />{animation ? : null}{actions.length > 0 ? : null} : null}{snapshot?.activeObjectId && nlaTracks.length > 0 ? : null}
+ {!greasePencil && animation?.channels[0] ?
: null} + {activeScene ?
setMarkerNameDraft(event.target.value)} />{markers.map((marker, index) =>
onCommand(animationRenderCommands.setTimelineMarkers(activeScene.id, markers.map((entry, markerIndex) => markerIndex === index ? { ...entry, name: event.target.value } : entry)))} /> onCommand(animationRenderCommands.setTimelineMarkers(activeScene.id, markers.map((entry, markerIndex) => markerIndex === index ? { ...entry, frame: Number(event.target.value) } : entry)))} />
)}
: null} + {activeScene && snapshot ? : null} {snapshot?.activeObjectId && nlaTracks.length > 0 ?
: null}
); @@ -2752,7 +3153,10 @@ export function App() { const operatorCommands = filterIOFormatOperatorCommands([ ...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })), { id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) }, - ...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? [{ id: "object.add-cube", label: "Add Cube", keywords: "object primitive mesh", execute: () => { void applyEditCommand(coreSceneCommands.createCube()); } }] : []), + ...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? ([ + ["CUBE", "Cube"], ["PLANE", "Plane"], ["UV_SPHERE", "UV Sphere"], ["ICO_SPHERE", "Ico Sphere"], + ["CYLINDER", "Cylinder"], ["CONE", "Cone"], ["TORUS", "Torus"], + ] as ReadonlyArray).map(([primitive, label]) => ({ id: `object.add-${primitive.toLowerCase()}`, label: `Add ${label}`, keywords: "object primitive mesh", execute: () => { void applyEditCommand(coreSceneCommands.createPrimitive(primitive)); } })) : []), ...(snapshot?.activeObjectId && isWorkbenchCommandAvailable("applyObjectTransform") ? [{ id: "object.apply-transform", label: "Apply Transform", keywords: "object location rotation scale", execute: () => { void applyEditCommand(coreSceneCommands.applyTransform(snapshot.activeObjectId!)); } }] : []), ...(snapshot ? [ { id: "edit.undo", label: "Undo", keywords: "history", execute: () => { void applyEditCommand({ type: "undo" }); } }, @@ -2808,14 +3212,14 @@ export function App() { {workerFault ?
{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.{workerRecoveryStatus === "FAILED" ? Recovery failed; retry is safe. : null}
: null} {recentProjectIssues.length > 0 ?
最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。
{recentProjectIssues.map((issue) =>
{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}
)}
: null} void cleanupProjectAssets()} /> -
{workspaceLabel}{uiState.context.mode === "Edit" ? <>
{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => )}
{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => )} : <>}{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}{saved ? "已保存" : "未保存"}
+
{workspaceLabel}{uiState.context.mode === "Edit" ? <>
{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => )}
{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => )} : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => )}}{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}{saved ? "已保存" : "未保存"}
void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /> { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], point, baseSelectionRevision); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => { const client = webClientRef.current; if (!client) return; try { const result = await client.appendLibraryObject(source, request); setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Library append: ${result.receipt.sourceDataBlockId}`); } catch (error) { setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error)); } }} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /> void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} />
- {uiState.operatorSearchOpen ? : null} + {uiState.operatorSearchOpen ? { setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Operator: ${descriptor.id}`); }} /> : null}
Web Blender Modeler V1Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}{openProgress ? 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} />{openProgress.message ?? "Opening"}{canCancelOpenRead ? : null} : null}{manifestStatus}{wasmStatus}{engineStatus}{storageStatus}
); diff --git a/web/app/src/capabilities/io-format-ui-registry.json b/web/app/src/capabilities/io-format-ui-registry.json index dd5873ae6..906c6d789 100644 --- a/web/app/src/capabilities/io-format-ui-registry.json +++ b/web/app/src/capabilities/io-format-ui-registry.json @@ -3,8 +3,45 @@ "task": "M12-05C", "parentMatrixSha256": "139c9d764736da176b32414ecda840c07eb0ba5f3864da2dc08ce04bae161366", "projectFileAccept": ".blend,application/octet-stream", - "importRoutes": [], + "importRoutes": [ + { + "format": "GLTF", + "operation": "IMPORT", + "execution": "LOCAL", + "extensions": [".gltf"] + }, + { + "format": "GLB", + "operation": "IMPORT", + "execution": "LOCAL", + "extensions": [".glb"] + }, + { + "format": "OBJ", + "operation": "IMPORT", + "execution": "LOCAL", + "extensions": [".obj"] + }, + { + "format": "STL", + "operation": "IMPORT", + "execution": "LOCAL", + "extensions": [".stl"] + }, + { + "format": "PLY", + "operation": "IMPORT", + "execution": "LOCAL", + "extensions": [".ply"] + } + ], "exportRoutes": [ + { + "format": "GLTF", + "operation": "EXPORT", + "execution": "LOCAL", + "extensions": [".gltf"] + }, { "format": "GLB", "operation": "EXPORT", @@ -12,6 +49,24 @@ "extensions": [ ".glb" ] + }, + { + "format": "OBJ", + "operation": "EXPORT", + "execution": "LOCAL", + "extensions": [".obj"] + }, + { + "format": "STL", + "operation": "EXPORT", + "execution": "LOCAL", + "extensions": [".stl"] + }, + { + "format": "PLY", + "operation": "EXPORT", + "execution": "LOCAL", + "extensions": [".ply"] } ] } diff --git a/web/app/src/engine-client/WebEngineClient.ts b/web/app/src/engine-client/WebEngineClient.ts index f05dcfb18..6af85b1f0 100644 --- a/web/app/src/engine-client/WebEngineClient.ts +++ b/web/app/src/engine-client/WebEngineClient.ts @@ -34,6 +34,7 @@ import type { } from "../../../protocol/paint-stroke-session"; import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability"; import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "../../../protocol/library-main-append"; +import type { BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType } from "../../../protocol/blender-operator"; interface PendingRequest { resolve: (result: WebEngineResult) => void; @@ -61,6 +62,55 @@ export interface BlendOpenResult { simplify?: SimplifyResult; } +function normalizeOperatorPropertyType(value: unknown, raw: Record): BlenderOperatorPropertyType { + const type = String(value ?? "string").toLowerCase(); + if (type === "boolean" || type === "bool") return "boolean"; + if (type === "enum" || Array.isArray(raw.enumItems) || Array.isArray(raw.items) || Array.isArray(raw.enum)) return "enum"; + if (type === "number" || type === "integer" || type === "float" || type === "double" || type.startsWith("float[")) return "number"; + return "string"; +} + +function normalizeOperatorCatalog(value: unknown): BlenderOperatorDescriptor[] { + const root = value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; + const entries = Array.isArray(value) ? value : root && Array.isArray(root.operators) ? root.operators : []; + return entries.flatMap((entry): BlenderOperatorDescriptor[] => { + if (!entry || typeof entry !== "object") return []; + const raw = entry as Record; + const id = String(raw.id ?? raw.operator ?? raw.name ?? "").trim(); + if (!id) return []; + const properties = Array.isArray(raw.properties) ? raw.properties.flatMap((property): BlenderOperatorPropertySpec[] => { + if (!property || typeof property !== "object") return []; + const spec = property as Record; + const name = String(spec.name ?? "").trim(); + if (!name) return []; + const enumValues = spec.enumItems ?? spec.items ?? spec.enum; + const enumItems = Array.isArray(enumValues) ? enumValues.flatMap((item): { value: string; label: string }[] => { + if (typeof item === "string" || typeof item === "number") return [{ value: String(item), label: String(item) }]; + if (!item || typeof item !== "object") return []; + const candidate = item as Record; + const option = String(candidate.value ?? candidate.identifier ?? candidate.id ?? ""); + return option ? [{ value: option, label: String(candidate.label ?? candidate.name ?? option) }] : []; + }) : undefined; + const type = normalizeOperatorPropertyType(spec.type, spec); + return [{ + name, + type, + required: Boolean(spec.required), + label: typeof spec.label === "string" ? spec.label : undefined, + description: typeof spec.description === "string" ? spec.description : undefined, + defaultValue: typeof spec.defaultValue === "string" || typeof spec.defaultValue === "number" || typeof spec.defaultValue === "boolean" + ? spec.defaultValue + : typeof spec.default === "string" || typeof spec.default === "number" || typeof spec.default === "boolean" ? spec.default : undefined, + min: typeof spec.min === "number" ? spec.min : typeof spec.minValue === "number" ? spec.minValue : undefined, + max: typeof spec.max === "number" ? spec.max : typeof spec.maxValue === "number" ? spec.maxValue : undefined, + enumItems, + }]; + }) : []; + const context = raw.context === "object" || raw.context === "main" ? raw.context : "scene"; + return [{ id, label: String(raw.label ?? raw.name ?? id), description: typeof raw.description === "string" ? raw.description : undefined, family: String(raw.family ?? "Blender"), context, backgroundSafe: true, properties }]; + }); +} + export class WebEngineClient { private readonly timeoutMs: number; private readonly workerFactory: () => Worker; @@ -128,6 +178,30 @@ export class WebEngineClient { return { status: result.status, result: result.json as T }; } + async listBlenderOperators(): Promise<{ status: WebEngineStatus; operators: BlenderOperatorDescriptor[] }> { + const { status, result } = await this.callJson("listBlenderOperators"); + return { status, operators: normalizeOperatorCatalog(result) }; + } + + async invokeBlenderOperator(invocation: BlenderOperatorInvocation): Promise { + if (!invocation.operator.trim()) throw this.report("INVALID_ARGUMENT", "Blender operator 不能为空", true); + const result = await this.request({ type: "jsonCommand", cmd: "invokeBlenderOperator", params: invocation as unknown as Record }); + if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "Blender operator 未返回场景结果", true); + this.geometryBuffers = result.geometryDelta + ? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta) + : result.geometryBuffers ?? this.geometryBuffers; + this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers; + return { + status: result.status, + snapshot: result.snapshot, + geometryBuffers: [...this.geometryBuffers], + nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers], + delta: result.delta, + simplify: result.simplify, + operatorResult: result.json, + }; + } + async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> { const result = await this.request({ type: "delta" }); if (!result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneDelta", true); diff --git a/web/app/src/sequencer/SequencerTimeline.ts b/web/app/src/sequencer/SequencerTimeline.ts index adcc85c47..46c699c29 100644 --- a/web/app/src/sequencer/SequencerTimeline.ts +++ b/web/app/src/sequencer/SequencerTimeline.ts @@ -11,6 +11,9 @@ export type { SequencerCodecProbeRequestIR, SequencerCodecProbeResultIR, SequencerFrameStripIR, + SequencerEditIR, + SequencerStripIR, + SequencerStripType, SequencerTimelineIR, SequencerTransitionFrameIR, } from "../../../protocol/sequencer"; diff --git a/web/app/src/workers/web-engine.worker.ts b/web/app/src/workers/web-engine.worker.ts index 3de260925..b4e6889d0 100644 --- a/web/app/src/workers/web-engine.worker.ts +++ b/web/app/src/workers/web-engine.worker.ts @@ -1,7 +1,8 @@ import type { ErrorReport } from "../../../protocol/error"; import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta"; -import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineJsonCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine"; +import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineJsonCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineOperatorInvocation, WebEngineOperatorProperty, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine"; import { parseSceneSnapshotIR } from "../../../protocol/scene-ir"; +import { parseSequencerTimeline } from "../../../protocol/sequencer"; import { parseEditorWorkflow, EditorWorkflowValidationError } from "../../../protocol/editor-workflow"; import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify"; import type { LODGenerationRequest } from "../../../protocol/lod"; @@ -151,6 +152,42 @@ function validPropertyKeys(value: unknown, allowed: readonly string[]): value is Object.keys(value).length > 0 && Object.keys(value).every((key) => allowed.includes(key)); } +function validOperatorJson(value: unknown, depth = 0): value is WebEngineOperatorProperty { + if (depth > 8 || value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.length <= 1024 && value.every((item) => validOperatorJson(item, depth + 1)); + if (typeof value === "object") { + const entries = Object.entries(value); + return entries.length <= 128 && entries.every(([key, item]) => key.length > 0 && key.length <= 64 && validOperatorJson(item, depth + 1)); + } + return false; +} + +function validateOperatorInvocation(value: unknown): WebEngineOperatorInvocation { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw report("INVALID_ARGUMENT", "invokeBlenderOperator requires an object params payload", true); + } + const params = value as Record; + if (Object.keys(params).some((key) => !["operator", "properties", "baseRevision"].includes(key))) { + throw report("INVALID_ARGUMENT", "invokeBlenderOperator received an unknown parameter", true); + } + if (typeof params.operator !== "string" || params.operator.length === 0 || params.operator.length > 128) { + throw report("INVALID_ARGUMENT", "invokeBlenderOperator requires a bounded operator identifier", true); + } + const properties = params.properties === undefined ? {} : params.properties; + if (typeof properties !== "object" || properties === null || Array.isArray(properties) || !validOperatorJson(properties)) { + throw report("INVALID_ARGUMENT", "invokeBlenderOperator properties must be typed JSON", true); + } + if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) { + throw report("REVISION_CONFLICT", "invokeBlenderOperator baseRevision must be a non-negative integer", true); + } + return { + operator: params.operator, + properties: properties as Record, + ...(params.baseRevision === undefined ? {} : { baseRevision: params.baseRevision as number }), + }; +} + function reportProtocolError(error: unknown, fallbackCode: ErrorReport["code"]): never { if (typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string") { const candidate = error as { code: ErrorReport["code"]; message?: unknown }; @@ -246,13 +283,25 @@ function sculptMeshGate(meshId: string): CapabilityGateResult { } function assertFutureCapability(payload: Extract["payload"]): void { + if (payload.type === "createAction") { + if (typeof payload.name !== "string" || payload.name.trim().length === 0 || payload.name.length > 256) throw report("ANIMATION_ACTION_INVALID", "Action name is required"); + if (payload.objectId !== undefined && typeof payload.objectId !== "string") throw report("ANIMATION_ACTION_INVALID", "Action objectId is invalid"); + } + if (payload.type === "removeAction" && (typeof payload.actionId !== "string" || payload.actionId.length === 0)) throw report("ANIMATION_ACTION_INVALID", "Action id is required"); + if (payload.type === "setKeyframe") { + if (typeof payload.animationId !== "string" || typeof payload.path !== "string" || !Number.isFinite(payload.frame)) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe identity is invalid"); + if (payload.value !== undefined && (!Array.isArray(payload.value) || !payload.value.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe value is invalid"); + for (const handle of [payload.handleLeft, payload.handleRight]) if (handle !== undefined && (!Array.isArray(handle) || handle.length !== 2 || !handle.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe handle is invalid"); + } + if (payload.type === "setTimelineMarkers") { + if (typeof payload.dataId !== "string" || !Array.isArray(payload.markers) || payload.markers.length > 10000 || payload.markers.some((marker) => !marker || !Number.isFinite(marker.frame) || typeof marker.name !== "string" || marker.name.length > 256)) throw report("ANIMATION_MARKER_INVALID", "Timeline markers are invalid"); + } + const modifierAuthoringCommands = new Set(["addModifier", "removeModifier", "moveModifier", "setModifierParameters"]); + if (modifierAuthoringCommands.has(payload.type)) return; switch (payload.type) { case "sculptStroke": { try { const stroke = parseSculptStroke(payload.stroke); - if (!stroke.mirrorObjectSpace) { - throw report("CAPABILITY_MISSING", "N-011 bounded Sculpt strokes require object-space mirror samples"); - } const gate = sculptMeshGate(stroke.meshId); if (gate.status === "BLOCKED") rejectCapabilityGate(gate); const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === stroke.meshId); @@ -466,6 +515,9 @@ function assertFutureCapability(payload: Extract 1_000_000)) throw report("NON_MESH_PROPERTY_INVALID", "Volume velocity scale is outside the bounded range"); return; } + case "setArmatureBones": + case "setPoseBoneTransform": + return; case "setMetaballElements": { const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId); if (!data || data.type !== "METABALL") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Metaball data block is unavailable: ${payload.dataId}`); @@ -904,6 +956,19 @@ function assertFutureCapability(payload: Extract candidate.id === payload.sceneId); + if (!scene) throw report("SEQUENCER_SCHEMA_INVALID", `Sequencer scene does not exist: ${payload.sceneId}`); + try { + const timeline = parseSequencerTimeline(payload.timeline); + if (timeline.revision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Sequencer timeline revision does not match the current SceneIR"); + if (timeline.id !== `sequencer:${scene.id}`) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer timeline does not target the requested scene"); + } + catch (error) { + reportProtocolError(error, "SEQUENCER_SCHEMA_INVALID"); + } + return; + } } } @@ -1024,8 +1089,9 @@ function callUnifiedJson(cmd: string, params: Record = {}, t } } -const READ_ONLY_JSON_COMMANDS: ReadonlySet = new Set(["snapshot", "delta", "depsgraph", "memoryStats"]); -const STATEFUL_JSON_COMMANDS: ReadonlySet = new Set(["applyCommand", "undo", "redo"]); +type WebEngineRouteJsonCommand = WebEngineJsonCommand | "importScene" | "exportScene"; +const READ_ONLY_JSON_COMMANDS: ReadonlySet = new Set(["snapshot", "delta", "depsgraph", "memoryStats", "listBlenderOperators", "exportScene"]); +const STATEFUL_JSON_COMMANDS: ReadonlySet = new Set(["applyCommand", "undo", "redo", "invokeBlenderOperator", "importScene"]); function readSnapshot(): ReturnType { const snapshot = parseSceneSnapshotIR(callUnifiedJson("snapshot")); @@ -1510,15 +1576,23 @@ async function handleRequest(event: MessageEvent): Promise(request.command.cmd, request.command.params ?? {}); - if (STATEFUL_JSON_COMMANDS.has(request.command.cmd)) { + if (commandName === "invokeBlenderOperator") validateOperatorInvocation(request.command.params); + if (commandName === "importScene" && (!request.command.params || typeof request.command.params.format !== "string" || typeof request.command.params.base64 !== "string")) { + throw report("INVALID_ARGUMENT", "importScene requires format and base64 payload", true); + } + if (commandName === "exportScene" && (!request.command.params || typeof request.command.params.format !== "string")) { + throw report("INVALID_ARGUMENT", "exportScene requires format", true); + } + const jsonResult = callUnifiedJson(commandName, request.command.params ?? {}); + if (STATEFUL_JSON_COMMANDS.has(commandName)) { const scene = await readSnapshotResult(); currentSnapshot = scene.snapshot; result = { diff --git a/web/protocol/animation-render-commands.ts b/web/protocol/animation-render-commands.ts index cde36bafd..eefb8d2b6 100644 --- a/web/protocol/animation-render-commands.ts +++ b/web/protocol/animation-render-commands.ts @@ -11,6 +11,18 @@ export const animationRenderCommands = Object.freeze({ deleteKeyframe(objectId: string, frame: number, property?: AnimationCommand<"deleteObjectKeyframe">["property"]): AnimationCommand<"deleteObjectKeyframe"> { return { type: "deleteObjectKeyframe", objectId, frame, ...(property ? { property } : {}) }; }, + createAction(name: string, objectId?: string): AnimationCommand<"createAction"> { + return { type: "createAction", name, ...(objectId ? { objectId } : {}) }; + }, + removeAction(actionId: string): AnimationCommand<"removeAction"> { + return { type: "removeAction", actionId }; + }, + setKeyframe(animationId: string, path: string, frame: number, options: Omit, "type" | "animationId" | "path" | "frame"> = {}): AnimationCommand<"setKeyframe"> { + return { type: "setKeyframe", animationId, path, frame, ...options }; + }, + setTimelineMarkers(dataId: string, markers: AnimationCommand<"setTimelineMarkers">["markers"]): AnimationCommand<"setTimelineMarkers"> { + return { type: "setTimelineMarkers", dataId, markers: markers.map((marker) => ({ ...marker })) }; + }, setConstraint(objectId: string, constraintName: string, enabled: boolean, influence: number): AnimationCommand<"setConstraint"> { return { type: "setConstraint", objectId, constraintName, enabled, influence }; }, diff --git a/web/protocol/blender-operator.ts b/web/protocol/blender-operator.ts new file mode 100644 index 000000000..8bf3023dd --- /dev/null +++ b/web/protocol/blender-operator.ts @@ -0,0 +1,36 @@ +export type BlenderOperatorPropertyType = "boolean" | "number" | "enum" | "string"; + +export type BlenderOperatorPropertyValue = string | number | boolean; + +export interface BlenderOperatorEnumItem { + value: string; + label: string; +} + +export interface BlenderOperatorPropertySpec { + name: string; + type: BlenderOperatorPropertyType; + required: boolean; + label?: string; + description?: string; + defaultValue?: BlenderOperatorPropertyValue; + min?: number; + max?: number; + enumItems?: BlenderOperatorEnumItem[]; +} + +export interface BlenderOperatorDescriptor { + id: string; + label: string; + description?: string; + family: string; + context: "scene" | "object" | "main"; + backgroundSafe: true; + properties: BlenderOperatorPropertySpec[]; +} + +export interface BlenderOperatorInvocation { + operator: string; + properties?: Record; + baseRevision?: number; +} diff --git a/web/protocol/core-scene-commands.ts b/web/protocol/core-scene-commands.ts index 72b670841..f37dba2a9 100644 --- a/web/protocol/core-scene-commands.ts +++ b/web/protocol/core-scene-commands.ts @@ -1,4 +1,4 @@ -import type { WebEngineEditCommand } from "./web-engine"; +import type { MeshPrimitive, WebEngineEditCommand } from "./web-engine"; type CoreCommand = Extract; type Vector3 = [number, number, number]; @@ -7,6 +7,9 @@ const vector3 = (value: readonly number[]): Vector3 => [value[0] ?? 0, value[1] /** Typed constructors used by the workbench for the source-backed scene API. */ export const coreSceneCommands = Object.freeze({ + createPrimitive(primitive: MeshPrimitive, location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> { + return { type: "createPrimitive", primitive, location: vector3(location), ...(name ? { name } : {}) }; + }, createCube(location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> { return { type: "createPrimitive", primitive: "CUBE", location: vector3(location), ...(name ? { name } : {}) }; }, diff --git a/web/protocol/geometry-node-commands.ts b/web/protocol/geometry-node-commands.ts index 34cb52737..738206a43 100644 --- a/web/protocol/geometry-node-commands.ts +++ b/web/protocol/geometry-node-commands.ts @@ -1,8 +1,70 @@ -import type { GeometryNodeGraphIR } from "./geometry-nodes"; +import { GEOMETRY_NODE_GRAPH_SCHEMA, type GeometryNodeDataType, type GeometryNodeGraphIR, type GeometryNodeIR, type GeometryNodeLinkIR, type GeometryNodeSocketIR } from "./geometry-nodes"; import type { WebEngineEditCommand } from "./web-engine"; +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const socket = (id: string, name: string, direction: "INPUT" | "OUTPUT", dataType: GeometryNodeDataType, defaultValue?: boolean | number | string | number[]): GeometryNodeSocketIR => ({ id, name, direction, dataType, ...(defaultValue === undefined ? {} : { defaultValue }) }); + +const nodeSockets = (type: string): GeometryNodeSocketIR[] => { + if (type === "NodeGroupInput") return [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")]; + if (type === "NodeGroupOutput") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")]; + if (type === "GeometryNodeTransform") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Translation", "Translation", "INPUT", "VECTOR", [0, 0, 0]), socket("Rotation", "Rotation", "INPUT", "VECTOR", [0, 0, 0]), socket("Scale", "Scale", "INPUT", "VECTOR", [1, 1, 1]), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")]; + if (type === "GeometryNodeSetPosition") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Offset", "Offset", "INPUT", "VECTOR", [0, 0, 0]), socket("Position", "Position", "INPUT", "VECTOR", [0, 0, 0]), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")]; + if (type === "GeometryNodeJoinGeometry") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")]; + if (type === "GeometryNodeRealizeInstances") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")]; + if (type === "FunctionNodeInputInt") return [socket("Value", "Value", "OUTPUT", "INT", 0)]; + if (type === "FunctionNodeInputVector") return [socket("Vector", "Vector", "OUTPUT", "VECTOR", [0, 0, 0])]; + if (type === "ShaderNodeValue") return [socket("Value", "Value", "OUTPUT", "FLOAT", 0)]; + if (type === "ShaderNodeMath") return [socket("Value", "Value", "INPUT", "FLOAT", 0), socket("Value_001", "Value", "INPUT", "FLOAT", 0), socket("Value", "Value", "OUTPUT", "FLOAT")]; + return []; +}; + +function uniqueId(base: string, ids: ReadonlySet): string { + let id = base; + let suffix = 2; + while (ids.has(id)) id = `${base}-${suffix++}`; + return id; +} + /** Workbench constructors for validated Geometry Node graph transactions. */ export const geometryNodeCommands = Object.freeze({ + createGraph(meshId: string, name = "Web Geometry") : GeometryNodeGraphIR { + const input = { id: "group-input", type: "NodeGroupInput", name: "Group Input", sockets: [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")] }; + const output = { id: "group-output", type: "NodeGroupOutput", name: "Group Output", sockets: [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")] }; + return { schemaVersion: GEOMETRY_NODE_GRAPH_SCHEMA, id: `geometry-graph:${meshId}`, name, interfaceInputs: [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")], interfaceOutputs: [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")], nodes: [input, output], links: [{ fromNodeId: input.id, fromSocketId: "Geometry", toNodeId: output.id, toSocketId: "Geometry" }] }; + }, + addNode(graph: GeometryNodeGraphIR, type: string, name = type): GeometryNodeGraphIR { + const next = clone(graph); + const ids = new Set(next.nodes.map((node) => node.id)); + const id = uniqueId(`node-${type.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`, ids); + const node: GeometryNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type) }; + next.nodes.push(node); + return next; + }, + removeNode(graph: GeometryNodeGraphIR, nodeId: string): GeometryNodeGraphIR { + const next = clone(graph); + next.nodes = next.nodes.filter((node) => node.id !== nodeId); + next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId); + return next; + }, + connect(graph: GeometryNodeGraphIR, link: GeometryNodeLinkIR): GeometryNodeGraphIR { + const next = clone(graph); + next.links = next.links.filter((item) => !(item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)); + if (!next.links.some((item) => item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)) next.links.push(link); + return next; + }, + disconnect(graph: GeometryNodeGraphIR, link: GeometryNodeLinkIR): GeometryNodeGraphIR { + const next = clone(graph); + next.links = next.links.filter((item) => !(item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)); + return next; + }, + setSocketValue(graph: GeometryNodeGraphIR, nodeId: string, socketId: string, value: GeometryNodeSocketIR["defaultValue"]): GeometryNodeGraphIR { + const next = clone(graph); + const node = next.nodes.find((candidate) => candidate.id === nodeId); + const target = node?.sockets.find((candidate) => candidate.id === socketId && candidate.direction === "INPUT"); + if (target) target.defaultValue = value; + return next; + }, setGraph(meshId: string, graph: GeometryNodeGraphIR): Extract { return { type: "setGeometryNodeGraph", meshId, graph }; }, diff --git a/web/protocol/io-format-ui-gate.ts b/web/protocol/io-format-ui-gate.ts index 2d134a56c..5ba25a145 100644 --- a/web/protocol/io-format-ui-gate.ts +++ b/web/protocol/io-format-ui-gate.ts @@ -51,6 +51,9 @@ const FORMAT_EXTENSIONS: Record = { ALEMBIC: [".abc"], }; +/** Formats with a browser file boundary and a native WebEngine command route. */ +const LOCAL_SCENE_FORMATS: readonly IOFormatMatrixFormat[] = ["GLTF", "GLB", "OBJ", "STL", "PLY"]; + function routeFor( matrix: IOFormatCapabilityMatrixIR, operation: IOFormatMatrixOperation, @@ -60,7 +63,8 @@ function routeFor( .filter((entry) => { const route = entry.operations[operation][execution.toLowerCase() as "local" | "server"]; const runtimeStatus = operation === "IMPORT" ? entry.runtimeImportStatus : entry.runtimeExportStatus; - return runtimeStatus === "AVAILABLE" && route.status === "READY" && route.execution === execution; + return route.status === "READY" && route.execution === execution && + (execution === "SERVER" || (runtimeStatus === "AVAILABLE" && LOCAL_SCENE_FORMATS.includes(entry.format))); }) .map((entry) => ({ format: entry.format, @@ -70,6 +74,15 @@ function routeFor( })); } +function browserSceneRoutes(operation: IOFormatMatrixOperation): IOFormatUIRouteIR[] { + return LOCAL_SCENE_FORMATS.map((format) => ({ + format, + operation, + execution: "LOCAL" as const, + extensions: [...FORMAT_EXTENSIONS[format]], + })); +} + export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR { if (!SHA256.test(parentMatrixSha256)) throw new IOFormatUIGateError("parent matrix SHA-256 is invalid"); return { @@ -77,8 +90,8 @@ export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, pare task: IO_FORMAT_UI_TASK, parentMatrixSha256, projectFileAccept: IO_FORMAT_PROJECT_ACCEPT, - importRoutes: routeFor(matrix, "IMPORT", "LOCAL"), - exportRoutes: routeFor(matrix, "EXPORT", "LOCAL"), + importRoutes: [...browserSceneRoutes("IMPORT"), ...routeFor(matrix, "IMPORT", "SERVER")], + exportRoutes: [...browserSceneRoutes("EXPORT"), ...routeFor(matrix, "EXPORT", "SERVER")], }; } @@ -96,9 +109,9 @@ function record(value: unknown, path: string): Record { function parseRoute(value: unknown, path: string, operation: IOFormatMatrixOperation): IOFormatUIRouteIR { const input = record(value, path); exactKeys(input, ["format", "operation", "execution", "extensions"], path); - if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || input.execution !== "LOCAL") throw new IOFormatUIGateError(`${path} route identity is invalid`); + if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || !["LOCAL", "SERVER"].includes(input.execution as string)) throw new IOFormatUIGateError(`${path} route identity is invalid`); if (!Array.isArray(input.extensions) || input.extensions.length !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat].length || input.extensions.some((extension, index) => extension !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat][index])) throw new IOFormatUIGateError(`${path}.extensions are invalid`); - return { format: input.format as IOFormatMatrixFormat, operation, execution: "LOCAL", extensions: [...input.extensions as string[]] }; + return { format: input.format as IOFormatMatrixFormat, operation, execution: input.execution as "LOCAL" | "SERVER", extensions: [...input.extensions as string[]] }; } export function parseIOFormatUIRegistry(value: unknown): IOFormatUIRegistryIR { @@ -135,11 +148,11 @@ export function filterIOFormatOperatorCommands !command.ioFormat || routeMatches(registry, command.ioFormat)); } -export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } { +export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "READY"; kind: "SCENE"; route: IOFormatUIRouteIR } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } { const extension = fileName.trim().toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? ""; if (extension === ".blend") return { status: "READY", kind: "BLEND" }; const route = registry.importRoutes.find((candidate) => candidate.extensions.includes(extension)); - if (route) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension }; + if (route) return { status: "READY", kind: "SCENE", route }; return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension }; } diff --git a/web/protocol/io-media-commands.ts b/web/protocol/io-media-commands.ts index 84bdc379e..852626e3f 100644 --- a/web/protocol/io-media-commands.ts +++ b/web/protocol/io-media-commands.ts @@ -2,8 +2,32 @@ import type { WebEngineEditCommand } from "./web-engine"; type IOCommand = Extract; +export type IOSceneFormat = "GLTF" | "GLB" | "OBJ" | "STL" | "PLY" | "USD" | "ALEMBIC"; + +export interface IOSceneImportCommand { + type: "importScene"; + format: IOSceneFormat; + fileName: string; + mimeType: string; + base64: string; +} + +export interface IOSceneExportCommand { + type: "exportScene"; + format: IOSceneFormat; + fileName: string; +} + +export type IOSceneCommand = IOSceneImportCommand | IOSceneExportCommand; + /** Typed constructors for media import and persistence-facing edit commands. */ export const ioMediaCommands = Object.freeze({ + importScene(format: IOSceneFormat, fileName: string, mimeType: string, base64: string): IOSceneImportCommand { + return { type: "importScene", format, fileName, mimeType, base64 }; + }, + exportScene(format: IOSceneFormat, fileName: string): IOSceneExportCommand { + return { type: "exportScene", format, fileName }; + }, importImage(name: string, mimeType: IOCommand<"importImage">["mimeType"], width: number, height: number, base64: string): IOCommand<"importImage"> { return { type: "importImage", name, mimeType, width, height, base64 }; }, diff --git a/web/protocol/modifier.ts b/web/protocol/modifier.ts index df8e2159e..286724e69 100644 --- a/web/protocol/modifier.ts +++ b/web/protocol/modifier.ts @@ -9,6 +9,36 @@ function fnv1a(value: string): number { return hash >>> 0; } +export type ModifierParameterMetadata = { + key: string; + value: unknown; + kind: "boolean" | "number" | "string" | "enum" | "object"; + options?: string[]; +}; + +/** Derives editable fields from the metadata carried by the authoritative modifier IR. */ +export function modifierParameterMetadata(modifier: ModifierIR): ModifierParameterMetadata[] { + return Object.entries(modifier.parameters ?? {}).map(([key, rawValue]) => { + // Main may carry a descriptor when RNA exposes enum choices alongside the + // current value. Keep the descriptor opaque to callers while deriving the + // editable value and choices from it. + const descriptor = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) && + "value" in rawValue && ("kind" in rawValue || "options" in rawValue) + ? rawValue as { value: unknown; kind?: ModifierParameterMetadata["kind"]; options?: unknown[] } + : undefined; + const rawOptions = descriptor?.options ?? (Array.isArray(rawValue) && rawValue.every((entry) => typeof entry === "string") ? rawValue : undefined); + const options = rawOptions?.map(String); + const value = descriptor?.value ?? (options ? options[0] : rawValue); + const kind = descriptor?.kind ?? ( + typeof value === "boolean" ? "boolean" : + typeof value === "number" ? "number" : + typeof value === "string" ? (options ? "enum" : "string") : + "object" + ); + return { key, value, kind, options: options ?? undefined }; + }); +} + /** Hashes evaluated modifier identity/state, excluding display timestamps. */ export function modifierStackHash(stack: readonly ModifierIR[] | undefined): string { const canonical = (stack ?? []).map((modifier) => ({ diff --git a/web/protocol/paint.ts b/web/protocol/paint.ts index 564c41339..46b9b007a 100644 --- a/web/protocol/paint.ts +++ b/web/protocol/paint.ts @@ -1,4 +1,4 @@ -import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt"; +import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR, SculptStrokeSampleIR } from "./sculpt"; export const PAINT_BUDGET = { maxSamples: 100_000, @@ -106,12 +106,30 @@ export function createSculptStroke( pressure: number, symmetry: [boolean, boolean, boolean], mirrorObjectSpace: boolean, + cursorSamples: ReadonlyArray = [], ): SculptStrokeIR { + const samples = cursorSamples.length > 0 + ? cursorSamples.slice(0, 2048).map((sample) => ({ + position: [...sample.position] as [number, number, number], + normal: [...sample.normal] as [number, number, number], + radius, + strength, + pressure, + time: sample.time, + })) + : Array.from({ length: 3 }, (_, index) => ({ + position: [0, 0, index * 0.01] as [number, number, number], + normal: [0, 0, 1] as [number, number, number], + radius, + strength, + pressure, + time: index, + })); return { schemaVersion: 1, meshId, brush, - samples: [{ position: [0, 0, 0], normal: [0, 0, 1], radius, strength, pressure, time: 0 }], + samples, symmetry, mirrorObjectSpace, }; diff --git a/web/protocol/scene-ir.ts b/web/protocol/scene-ir.ts index 5e38fb1fe..a348305cb 100644 --- a/web/protocol/scene-ir.ts +++ b/web/protocol/scene-ir.ts @@ -48,6 +48,7 @@ export interface SceneNodeIR { transform: SceneTransformIR; assetData?: AssetMetadataIR; constraints?: ConstraintIR[]; + drivers?: DriverIR[]; lightgroup?: string | null; } @@ -72,6 +73,39 @@ export interface ConstraintIR { targetObjectId?: string | null; } +export interface DriverVariableIR { + name: string; + type: number; + targetCount: number; + targets?: DriverTargetIR[]; +} + +export interface DriverTargetIR { + idName: string; + dataPath: string; + boneTarget: string; + transformType: number; + rotationMode: number; + flags: number; + options: number; + idType: number; + contextProperty: number; + fallbackValue: number; +} + +export interface DriverIR { + path: string; + arrayIndex: number; + editable: boolean; + enabled: boolean; + expression: string; + type: string; + typeCode: number; + flags: number; + influence: number; + variables: DriverVariableIR[]; +} + export interface ModifierIR { uuid: string; type: string; @@ -101,6 +135,8 @@ export interface ArmatureBoneIR { parentId: string | null; head: [number, number, number]; tail: [number, number, number]; + roll?: number; + connected?: boolean; restMatrix: number[]; poseMatrix?: number[]; } @@ -444,6 +480,12 @@ export interface SpeakerResourceIR { pitch: number; } +export interface TextLineIR { + index: number; + body: string; + byteLength: number; +} + export interface TextResourceIR { id: string; name: string; @@ -451,6 +493,7 @@ export interface TextResourceIR { sourceSha256: string; byteLength: number; lineCount: number; + lines?: TextLineIR[]; internal: boolean; sourcePath?: string; isDirty: boolean; @@ -530,7 +573,9 @@ export interface AnimationIR { targetId: string; frameStart: number; frameEnd: number; - channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>; + slots?: Array<{ identifier: string; idtype: number; handle: number; flags: number }>; + strips?: Array<{ name: string; type: "KEYFRAME"; frameStart: number; frameEnd: number; dataIndex: number }>; + channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; keyframeSource?: "BEZIER" | "SAMPLED"; modifiers?: Array<{ name: string; type: string; typeCode: number; flags: number; expanded: boolean; influence: number; frameStart: number; frameEnd: number; blendIn: number; blendOut: number }>; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; handleLeft?: [number, number]; handleRight?: [number, number]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>; } export interface CollectionIR { @@ -590,6 +635,7 @@ export interface SceneIR { previewStart: number; previewEnd: number; }; + timelineMarkers?: Array<{ frame: number; name: string }>; pipelineSettings?: { useCompositor: boolean; useSequencer: boolean; @@ -654,7 +700,7 @@ export interface SceneSnapshotIR { editorWorkflowStatus?: "AVAILABLE" | "BLOCKED"; scriptSources?: ScriptSourceInventoryIR; scriptSourceStatus?: "AVAILABLE" | "BLOCKED"; - renderRoutingStatus?: "AVAILABLE" | "BLOCKED"; + renderRoutingStatus?: "AVAILABLE" | "BLOCKED" | "MISSING"; renderRouting?: { schemaVersion: 1; receiptId: "workflow:render_capability_routing"; @@ -665,7 +711,7 @@ export interface SceneSnapshotIR { recovery: { cancellation: "OPEN_CANCELLED"; deviceLoss: "RELEASED"; [key: string]: unknown }; [key: string]: unknown; }; - serverJobBindingStatus?: "AVAILABLE" | "BLOCKED"; + serverJobBindingStatus?: "AVAILABLE" | "BLOCKED" | "MISSING"; serverJobBinding?: { schemaVersion: 1; receiptId: "workflow:server_job_result_binding"; @@ -677,7 +723,7 @@ export interface SceneSnapshotIR { lifecycle: { duplicate: "REUSED"; stale: "BLOCKED"; cancel: "CANCELLED"; timeout: "TIMEOUT"; oom: "OOM"; publishedOnFailure: false; [key: string]: unknown }; [key: string]: unknown; }; - scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED"; + scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED" | "MISSING"; scriptingPolicy?: { schemaVersion: 1; policyId: "workflow:scripting_default_deny_audit"; @@ -697,7 +743,7 @@ export interface SceneSnapshotIR { policyHash: string; [key: string]: unknown; }; - performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED"; + performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED" | "MISSING"; performanceFaultMatrix?: { schemaVersion: 1; receiptId: "workflow:performance_fault_recovery_matrix"; @@ -708,7 +754,7 @@ export interface SceneSnapshotIR { matrixHash: string; [key: string]: unknown; }; - releaseCandidateStatus?: "AVAILABLE" | "BLOCKED"; + releaseCandidateStatus?: "AVAILABLE" | "BLOCKED" | "MISSING"; releaseCandidate?: { schemaVersion: 1; receiptId: "workflow:offline_ci_sbom_and_rollback_release"; @@ -720,7 +766,7 @@ export interface SceneSnapshotIR { rollback: { runbookSha256: string; [key: string]: unknown }; [key: string]: unknown; }; - gapInventoryStatus?: "AVAILABLE" | "BLOCKED"; + gapInventoryStatus?: "AVAILABLE" | "BLOCKED" | "MISSING"; gapInventory?: { schemaVersion: 1; receiptId: "workflow:cluster_full_blender_gap_inventory"; @@ -751,7 +797,7 @@ export interface SceneSnapshotIR { nextTask: "WBV2-P7-002"; [key: string]: unknown; }; - atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED"; + atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED" | "MISSING"; atomicGapQueue?: { schemaVersion: 1; receiptId: "workflow:generate_atomic_family_gap_queue"; @@ -762,7 +808,7 @@ export interface SceneSnapshotIR { nextTask: "WBV2-P7-003"; [key: string]: unknown; }; - parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED"; + parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED" | "MISSING"; parityReleaseGate?: { schemaVersion: 1; receiptId: "workflow:full_parity_candidate_release_gate"; @@ -1072,6 +1118,16 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { const lineCount = requireNumber(text.lineCount, `texts[${index}].lineCount`); if (!Number.isSafeInteger(byteLength) || byteLength !== new TextEncoder().encode(source).byteLength || byteLength > 1_048_576) throw new Error(`SceneIR.texts[${index}].byteLength is invalid`); if (!Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 65_536) throw new Error(`SceneIR.texts[${index}].lineCount is invalid`); + if (text.lines !== undefined) { + if (!Array.isArray(text.lines) || text.lines.length !== lineCount) throw new Error(`SceneIR.texts[${index}].lines is invalid`); + text.lines.forEach((line, lineIndex) => { + if (!isRecord(line)) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}] must be an object`); + if (line.index !== lineIndex) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}].index is invalid`); + const body = requireString(line.body, `texts[${index}].lines[${lineIndex}].body`); + const lineByteLength = requireNumber(line.byteLength, `texts[${index}].lines[${lineIndex}].byteLength`); + if (!Number.isSafeInteger(lineByteLength) || lineByteLength !== new TextEncoder().encode(body).byteLength) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}].byteLength is invalid`); + }); + } requireBoolean(text.internal, `texts[${index}].internal`); if (text.sourcePath !== undefined) requireString(text.sourcePath, `texts[${index}].sourcePath`); requireBoolean(text.isDirty, `texts[${index}].isDirty`); @@ -1153,6 +1209,34 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { requireTuple(node.transform.rotationEuler, 3, `nodes[${index}].transform.rotationEuler`); requireTuple(node.transform.scale, 3, `nodes[${index}].transform.scale`); requireNumber(node.transform.rotationMode, `nodes[${index}].transform.rotationMode`); + if (node.drivers !== undefined) { + const drivers = requireArray(node.drivers, `nodes[${index}].drivers`); + if (drivers.length > 256) throw new Error(`SceneIR.nodes[${index}].drivers exceeds the budget`); + for (const [driverIndex, driver] of drivers.entries()) { + if (!isRecord(driver)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}] must be an object`); + for (const field of ["path", "expression", "type"] as const) requireString(driver[field], `nodes[${index}].drivers[${driverIndex}].${field}`); + for (const field of ["arrayIndex", "typeCode", "flags", "influence"] as const) requireNumber(driver[field], `nodes[${index}].drivers[${driverIndex}].${field}`); + requireBoolean(driver.editable, `nodes[${index}].drivers[${driverIndex}].editable`); + requireBoolean(driver.enabled, `nodes[${index}].drivers[${driverIndex}].enabled`); + const variables = requireArray(driver.variables, `nodes[${index}].drivers[${driverIndex}].variables`); + if (variables.length > 64) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables exceeds the budget`); + for (const [variableIndex, variable] of variables.entries()) { + if (!isRecord(variable)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}] must be an object`); + requireString(variable.name, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].name`); + requireNumber(variable.type, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].type`); + requireNumber(variable.targetCount, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targetCount`); + if (variable.targets !== undefined) { + const targets = requireArray(variable.targets, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets`); + if (targets.length !== variable.targetCount) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets count mismatch`); + for (const [targetIndex, target] of targets.entries()) { + if (!isRecord(target)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}] must be an object`); + for (const field of ["idName", "dataPath", "boneTarget"] as const) requireString(target[field], `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}].${field}`); + for (const field of ["transformType", "rotationMode", "flags", "options", "idType", "contextProperty", "fallbackValue"] as const) requireNumber(target[field], `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}].${field}`); + } + } + } + } + } } for (const [index, mesh] of meshes.entries()) { if (!isRecord(mesh)) throw new Error(`SceneIR.meshes[${index}] must be an object`); @@ -1287,6 +1371,8 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { if (bone.parentId !== null) requireString(bone.parentId, `armatures[${index}].bones[${boneIndex}].parentId`); requireTuple(bone.head, 3, `armatures[${index}].bones[${boneIndex}].head`); requireTuple(bone.tail, 3, `armatures[${index}].bones[${boneIndex}].tail`); + if (bone.roll !== undefined) requireNumber(bone.roll, `armatures[${index}].bones[${boneIndex}].roll`); + if (bone.connected !== undefined) requireBoolean(bone.connected, `armatures[${index}].bones[${boneIndex}].connected`); requireTuple(bone.restMatrix, 16, `armatures[${index}].bones[${boneIndex}].restMatrix`); if (bone.poseMatrix !== undefined) requireTuple(bone.poseMatrix, 16, `armatures[${index}].bones[${boneIndex}].poseMatrix`); } @@ -1624,6 +1710,14 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { for (const field of ["fps", "fpsBase", "frameStep", "previewStart", "previewEnd"] as const) requireNumber(scene.timelineSettings[field], `scenes[${index}].timelineSettings.${field}`); requireBoolean(scene.timelineSettings.usePreviewRange, `scenes[${index}].timelineSettings.usePreviewRange`); } + if (scene.timelineMarkers !== undefined) { + const markers = requireArray(scene.timelineMarkers, `scenes[${index}].timelineMarkers`); + for (const [markerIndex, marker] of markers.entries()) { + if (!isRecord(marker)) throw new Error(`SceneIR.scenes[${index}].timelineMarkers[${markerIndex}] must be an object`); + requireNumber(marker.frame, `scenes[${index}].timelineMarkers[${markerIndex}].frame`); + requireString(marker.name, `scenes[${index}].timelineMarkers[${markerIndex}].name`); + } + } if (scene.pipelineSettings !== undefined) { if (!isRecord(scene.pipelineSettings)) throw new Error(`SceneIR.scenes[${index}].pipelineSettings must be an object`); for (const field of ["useCompositor", "useSequencer", "noOverwrite", "saveOutput", "alphaPremultiplied"] as const) requireBoolean(scene.pipelineSettings[field], `scenes[${index}].pipelineSettings.${field}`); @@ -1719,10 +1813,45 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { requireString(animation.targetId, `animations[${index}].targetId`); requireNumber(animation.frameStart, `animations[${index}].frameStart`); requireNumber(animation.frameEnd, `animations[${index}].frameEnd`); + if (animation.slots !== undefined) { + const slots = requireArray(animation.slots, `animations[${index}].slots`); + if (slots.length > 1024) throw new Error(`animations[${index}].slots exceeds the budget`); + for (const [slotIndex, slot] of slots.entries()) { + if (!isRecord(slot)) throw new Error(`animations[${index}].slots[${slotIndex}] must be an object`); + requireString(slot.identifier, `animations[${index}].slots[${slotIndex}].identifier`); + for (const field of ["idtype", "handle", "flags"]) requireNumber(slot[field], `animations[${index}].slots[${slotIndex}].${field}`); + } + } + if (animation.strips !== undefined) { + const strips = requireArray(animation.strips, `animations[${index}].strips`); + if (strips.length > 1024) throw new Error(`animations[${index}].strips exceeds the budget`); + for (const [stripIndex, strip] of strips.entries()) { + if (!isRecord(strip)) throw new Error(`animations[${index}].strips[${stripIndex}] must be an object`); + requireString(strip.name, `animations[${index}].strips[${stripIndex}].name`); + if (strip.type !== "KEYFRAME") throw new Error(`animations[${index}].strips[${stripIndex}].type is invalid`); + requireNumber(strip.frameStart, `animations[${index}].strips[${stripIndex}].frameStart`); + requireNumber(strip.frameEnd, `animations[${index}].strips[${stripIndex}].frameEnd`); + requireNumber(strip.dataIndex, `animations[${index}].strips[${stripIndex}].dataIndex`); + } + } const channels = requireArray(animation.channels, `animations[${index}].channels`); for (const [channelIndex, channel] of channels.entries()) { if (!isRecord(channel)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}] must be an object`); requireString(channel.path, `animations[${index}].channels[${channelIndex}].path`); + if (channel.keyframeSource !== undefined && channel.keyframeSource !== "BEZIER" && channel.keyframeSource !== "SAMPLED") { + throw new Error(`animations[${index}].channels[${channelIndex}].keyframeSource is invalid`); + } + if (channel.modifiers !== undefined) { + const modifiers = requireArray(channel.modifiers, `animations[${index}].channels[${channelIndex}].modifiers`); + if (modifiers.length > 64) throw new Error(`animations[${index}].channels[${channelIndex}].modifiers exceeds the budget`); + for (const [modifierIndex, modifier] of modifiers.entries()) { + if (!isRecord(modifier)) throw new Error(`animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}] must be an object`); + requireString(modifier.name, `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].name`); + requireString(modifier.type, `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].type`); + for (const field of ["typeCode", "flags", "influence", "frameStart", "frameEnd", "blendIn", "blendOut"]) requireNumber(modifier[field], `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].${field}`); + if (typeof modifier.expanded !== "boolean") throw new Error(`animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].expanded is invalid`); + } + } const keyframes = requireArray(channel.keyframes, `animations[${index}].channels[${channelIndex}].keyframes`); for (const [keyframeIndex, keyframe] of keyframes.entries()) { if (!isRecord(keyframe)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}].keyframes[${keyframeIndex}] must be an object`); @@ -1809,7 +1938,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { parseScriptSourceInventory(value.scriptSources); if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present"); } - if (value.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.renderRoutingStatus as string)) { + if (value.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.renderRoutingStatus as string)) { throw new Error("SceneIR.renderRoutingStatus is invalid"); } if (value.renderRouting !== undefined) { @@ -1825,7 +1954,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { } if (value.renderRoutingStatus !== "AVAILABLE") throw new Error("SceneIR.renderRoutingStatus must be AVAILABLE when renderRouting is present"); } - if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.serverJobBindingStatus as string)) { + if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.serverJobBindingStatus as string)) { throw new Error("SceneIR.serverJobBindingStatus is invalid"); } if (value.serverJobBinding !== undefined) { @@ -1843,7 +1972,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { } if (value.serverJobBindingStatus !== "AVAILABLE") throw new Error("SceneIR.serverJobBindingStatus must be AVAILABLE when serverJobBinding is present"); } - if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptingPolicyStatus as string)) { + if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.scriptingPolicyStatus as string)) { throw new Error("SceneIR.scriptingPolicyStatus is invalid"); } if (value.scriptingPolicy !== undefined) { @@ -1871,7 +2000,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { } if (value.scriptingPolicyStatus !== "AVAILABLE") throw new Error("SceneIR.scriptingPolicyStatus must be AVAILABLE when scriptingPolicy is present"); } - if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.performanceFaultMatrixStatus as string)) { + if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.performanceFaultMatrixStatus as string)) { throw new Error("SceneIR.performanceFaultMatrixStatus is invalid"); } if (value.performanceFaultMatrix !== undefined) { @@ -1892,7 +2021,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { } if (value.performanceFaultMatrixStatus !== "AVAILABLE") throw new Error("SceneIR.performanceFaultMatrixStatus must be AVAILABLE when performanceFaultMatrix is present"); } - if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.releaseCandidateStatus as string)) { + if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.releaseCandidateStatus as string)) { throw new Error("SceneIR.releaseCandidateStatus is invalid"); } if (value.releaseCandidate !== undefined) { @@ -1917,7 +2046,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { } if (value.releaseCandidateStatus !== "AVAILABLE") throw new Error("SceneIR.releaseCandidateStatus must be AVAILABLE when releaseCandidate is present"); } - if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.gapInventoryStatus as string)) { + if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.gapInventoryStatus as string)) { throw new Error("SceneIR.gapInventoryStatus is invalid"); } if (value.gapInventory !== undefined) { @@ -1953,7 +2082,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { } if (value.gapInventoryStatus !== "AVAILABLE") throw new Error("SceneIR.gapInventoryStatus must be AVAILABLE when gapInventory is present"); } - if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.atomicGapQueueStatus as string)) { + if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.atomicGapQueueStatus as string)) { throw new Error("SceneIR.atomicGapQueueStatus is invalid"); } if (value.atomicGapQueue !== undefined) { @@ -1988,7 +2117,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { throw new Error("SceneIR.atomicGapQueueStatus must be AVAILABLE for a valid queue"); } } - if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.parityReleaseGateStatus as string)) { + if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.parityReleaseGateStatus as string)) { throw new Error("SceneIR.parityReleaseGateStatus is invalid"); } if (value.parityReleaseGate !== undefined) { diff --git a/web/protocol/shader-graph-commands.ts b/web/protocol/shader-graph-commands.ts index 5b7894cf3..d61d825e6 100644 --- a/web/protocol/shader-graph-commands.ts +++ b/web/protocol/shader-graph-commands.ts @@ -1,15 +1,66 @@ -import type { ShaderGraphIR, ShaderSocketIR } from "./shader-graph"; +import { SHADER_GRAPH_SCHEMA, type ShaderGraphIR, type ShaderLinkIR, type ShaderNodeIR, type ShaderNodeType, type ShaderSocketIR } from "./shader-graph"; const input = (id: string, name: string, dataType: ShaderSocketIR["dataType"], defaultValue?: ShaderSocketIR["defaultValue"]): ShaderSocketIR => ({ id, name, direction: "INPUT", dataType, ...(defaultValue === undefined ? {} : { defaultValue }) }); const output = (id: string, name: string, dataType: ShaderSocketIR["dataType"]): ShaderSocketIR => ({ id, name, direction: "OUTPUT", dataType }); +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const nodeSockets = (type: ShaderNodeType): ShaderSocketIR[] => { + if (type === "RGB") return [output("Color", "Color", "COLOR")]; + if (type === "VALUE") return [output("Value", "Value", "VALUE")]; + if (type === "MATH") return [input("Value", "Value", "VALUE", 0), input("Value_001", "Value", "VALUE", 0), output("Value", "Value", "VALUE")]; + if (type === "PRINCIPLED") return [input("Base Color", "Base Color", "COLOR", [0.8, 0.8, 0.8, 1]), input("Metallic", "Metallic", "VALUE", 0), input("Roughness", "Roughness", "VALUE", 0.4), output("BSDF", "BSDF", "SHADER")]; + if (type === "MATERIAL_OUTPUT") return [input("Surface", "Surface", "SHADER")]; + if (type === "IMAGE_TEXTURE") return [input("Vector", "Vector", "VECTOR", [0, 0, 0]), output("Color", "Color", "COLOR")]; + return []; +}; + +function uniqueId(base: string, ids: ReadonlySet): string { + let id = base; + let suffix = 2; + while (ids.has(id)) id = `${base}-${suffix++}`; + return id; +} /** Build a valid bounded graph for the source-backed Principled writer. */ export const shaderGraphCommands = Object.freeze({ + addNode(graph: ShaderGraphIR, type: ShaderNodeType, name = type): ShaderGraphIR { + const next = clone(graph); + const id = uniqueId(`node-${type.toLowerCase()}`, new Set(next.nodes.map((node) => node.id))); + const node: ShaderNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type), ...(type === "MATH" ? { properties: { operation: "ADD" } } : {}) }; + next.nodes.push(node); + if (type === "MATERIAL_OUTPUT") next.outputNodeId = id; + return next; + }, + removeNode(graph: ShaderGraphIR, nodeId: string): ShaderGraphIR { + const next = clone(graph); + next.nodes = next.nodes.filter((node) => node.id !== nodeId); + next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId); + if (next.outputNodeId === nodeId) next.outputNodeId = next.nodes.find((node) => node.type === "MATERIAL_OUTPUT")?.id; + return next; + }, + connect(graph: ShaderGraphIR, link: ShaderLinkIR): ShaderGraphIR { + const next = clone(graph); + next.links = next.links.filter((item) => !(item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)); + if (!next.links.some((item) => item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)) next.links.push(link); + return next; + }, + disconnect(graph: ShaderGraphIR, link: ShaderLinkIR): ShaderGraphIR { + const next = clone(graph); + next.links = next.links.filter((item) => !(item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)); + return next; + }, + setSocketValue(graph: ShaderGraphIR, nodeId: string, socketId: string, value: ShaderSocketIR["defaultValue"]): ShaderGraphIR { + const next = clone(graph); + const node = next.nodes.find((candidate) => candidate.id === nodeId); + const target = node?.sockets.find((candidate) => candidate.id === socketId && candidate.direction === "INPUT"); + if (target) target.defaultValue = value; + return next; + }, setPrincipledGraph(materialId: string, baseColor: readonly number[], roughness: number, metallic: number): ShaderGraphIR { const principled = "shader:principled"; const materialOutput = "shader:material-output"; return { - schemaVersion: 1, + schemaVersion: SHADER_GRAPH_SCHEMA, id: `shader-graph:${materialId}`, materialId, nodes: [ diff --git a/web/protocol/web-engine-capabilities.ts b/web/protocol/web-engine-capabilities.ts index 6efe08abf..20e781e15 100644 --- a/web/protocol/web-engine-capabilities.ts +++ b/web/protocol/web-engine-capabilities.ts @@ -4,6 +4,13 @@ export const WEB_ENGINE_CAPABILITY_REGISTRY_SCHEMA = 1 as const; export type WebEngineCommandType = WebEngineEditCommand["type"]; export type WebEngineCommandRoute = "MAIN_AUTHORITATIVE" | "MAIN_GUARDED" | "LOCAL_HISTORY" | "PREVIEW"; +export type WebEngineOperatorCommand = "listBlenderOperators" | "invokeBlenderOperator"; + +export interface WebEngineOperatorCapability { + readonly route: "MAIN_READ" | "MAIN_AUTHORITATIVE"; + readonly implementation: "SOURCE_BACKED"; +} + export interface WebEngineCommandCapability { readonly family: string; readonly route: WebEngineCommandRoute; @@ -117,6 +124,12 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = { redo: { family: "HISTORY", route: "LOCAL_HISTORY", implementation: "SOURCE_BACKED" }, } satisfies Record; +/** Native operators are deliberately a separate registry from edit commands. */ +export const WEB_ENGINE_OPERATOR_CAPABILITIES = { + listBlenderOperators: { route: "MAIN_READ", implementation: "SOURCE_BACKED" }, + invokeBlenderOperator: { route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" }, +} satisfies Record; + export function getWebEngineCommandCapability(type: unknown): WebEngineCommandCapability | null { if (typeof type !== "string") return null; return Object.prototype.hasOwnProperty.call(WEB_ENGINE_COMMAND_CAPABILITIES, type) diff --git a/web/protocol/web-engine.ts b/web/protocol/web-engine.ts index 051896b49..18c17baa2 100644 --- a/web/protocol/web-engine.ts +++ b/web/protocol/web-engine.ts @@ -25,6 +25,8 @@ import type { } from "./paint-stroke-session"; import type { PaintPBVHCapabilityRequest } from "./paint-pbvh-capability"; import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "./library-main-append"; +import type { BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorInvocation, BlenderOperatorPropertyValue } from "./blender-operator"; +import type { SequencerTimelineIR } from "./sequencer"; export interface MeshGeometryBuffer { schemaVersion: 1; @@ -75,20 +77,28 @@ export interface MeshGeometryDelta { } export type MeshElementMode = "VERT" | "EDGE" | "FACE"; -export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT"; +export type MeshPrimitive = "CUBE" | "PLANE" | "UV_SPHERE" | "ICO_SPHERE" | "CYLINDER" | "CONE" | "TORUS"; +export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT" | "SUBDIVIDE" | "FILL" | "TRIANGULATE" | "RECALCULATE_NORMALS" | "BRIDGE_LOOPS" | "DELETE"; export type FCurveInterpolation = "CONSTANT" | "LINEAR" | "BEZIER"; +export type KeyframeHandle = [number, number]; export type WebEngineEditCommand = | { type: "setFrame"; frame: number } | { type: "setObjectVisibility"; objectId: string; visible: boolean } | { type: "setModifierEnabled"; meshId: string; modifierUuid: string; enabled: boolean } | { type: "setModifierVisibility"; meshId: string; modifierUuid: string; showViewport: boolean; showRender: boolean; showEditMode: boolean; showOnCage: boolean } + | { type: "addModifier"; meshId: string; modifierType: string; name?: string } + | { type: "removeModifier"; meshId: string; modifierUuid: string } + | { type: "moveModifier"; meshId: string; modifierUuid: string; direction?: "UP" | "DOWN"; index?: number } + | { type: "setModifierParameters"; meshId: string; modifierUuid: string; parameters: Record } | { type: "decimateMesh"; meshId: string; profile: SimplifyProfile } | { type: "previewDecimateMesh"; meshId: string; profile: SimplifyProfile } - | { type: "createPrimitive"; primitive: "CUBE" | "PLANE"; name?: string; location: [number, number, number] } + | { type: "createPrimitive"; primitive: MeshPrimitive; name?: string; location: [number, number, number] } | { type: "duplicateObject"; objectId: string; offset?: [number, number, number]; linked?: boolean } | { type: "deleteObject"; objectId: string } | { type: "setObjectTransform"; objectId: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] } + | { type: "setArmatureBones"; objectId: string; bones: Array<{ name: string; parentName: string | null; head: [number, number, number]; tail: [number, number, number]; roll: number; connected?: boolean }> } + | { type: "setPoseBoneTransform"; objectId: string; boneName: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] } | { type: "translateMeshVertices"; meshId: string; vertexIndices: number[]; offset: [number, number, number] } | { type: "meshEdit"; meshId: string; operation: MeshEditOperation; selectionMode: MeshElementMode; elementIndices: number[]; offset?: [number, number, number]; amount?: number; segments?: number } | { type: "createUVMap"; meshId: string; name: string } @@ -102,6 +112,10 @@ export type WebEngineEditCommand = | { type: "setMaterialImageNode"; materialId: string; imageId: string; usage: "BASE_COLOR" | "NORMAL"; uvMap?: string } | { type: "insertObjectKeyframe"; objectId: string; frame: number; property: "LOCATION" | "ROTATION_EULER" | "SCALE"; interpolation?: FCurveInterpolation } | { type: "deleteObjectKeyframe"; objectId: string; frame: number; property?: "LOCATION" | "ROTATION_EULER" | "SCALE" } + | { type: "createAction"; name: string; objectId?: string } + | { type: "removeAction"; actionId: string } + | { type: "setKeyframe"; animationId: string; path: string; frame: number; value?: number[]; handleLeft?: KeyframeHandle; handleRight?: KeyframeHandle; interpolation?: FCurveInterpolation } + | { type: "setTimelineMarkers"; dataId: string; markers: Array<{ frame: number; name: string }> } | { type: "setFCurveInterpolation"; animationId: string; path: string; interpolation: FCurveInterpolation } | { type: "setActiveAction"; objectId: string; actionId: string | null } | { type: "setConstraint"; objectId: string; constraintName: string; enabled: boolean; influence: number } @@ -149,6 +163,7 @@ export type WebEngineEditCommand = | { type: "setSceneUnitSettings"; dataId: string; properties: { system?: number; scaleLength?: number; rotationSystem?: number; lengthUnit?: number; massUnit?: number; timeUnit?: number; temperatureUnit?: number } } | { type: "setSceneTimelineSettings"; dataId: string; properties: { fps?: number; fpsBase?: number; frameStep?: number; usePreviewRange?: boolean; previewStart?: number; previewEnd?: number } } | { type: "setScenePipelineSettings"; dataId: string; properties: { useCompositor?: boolean; useSequencer?: boolean; noOverwrite?: boolean; saveOutput?: boolean; alphaPremultiplied?: boolean } } + | { type: "setSequencerStrips"; sceneId: string; timeline: SequencerTimelineIR } | { type: "setSceneBindings"; dataId: string; properties: { cameraObjectId?: string | null; worldId?: string | null } } | { type: "setSceneViewLayerSettings"; dataId: string; layerName: string; properties: { useRender?: boolean; useFreestyle?: boolean; samples?: number } } | { type: "setSceneCollectionSettings"; dataId: string; layerName: string; collectionId: string; properties: { exclude?: boolean; holdout?: boolean; indirectOnly?: boolean; hide?: boolean } } @@ -180,13 +195,33 @@ export type WebEngineEditCommand = | { type: "undo" } | { type: "redo" }; +/** JSON values accepted by the allowlisted native operator bridge. */ +export type WebEngineOperatorProperty = BlenderOperatorPropertyValue | null | WebEngineOperatorProperty[] | { [key: string]: WebEngineOperatorProperty }; +export type WebEngineOperatorPropertySpec = BlenderOperatorPropertySpec; +export type WebEngineOperatorDescriptor = BlenderOperatorDescriptor; +export type WebEngineOperatorInvocation = BlenderOperatorInvocation; + +/** Read-only commands supported by the stable Worker-to-WASM JSON facade. */ +export type WebEngineJsonCommand = + | "snapshot" + | "delta" + | "depsgraph" + | "memoryStats" + | "applyCommand" + | "undo" + | "redo" + | "listBlenderOperators" + | "invokeBlenderOperator"; + export type WebEngineRequest = | { requestId: string; command: { type: "init" } } + | { requestId: string; command: { type: "loadWasmModule"; id: string } } | { requestId: string; command: { type: "crashForTest" } } | { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; } | { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } } | { requestId: string; command: { type: "openResourceStatus" } } | { requestId: string; command: { type: "snapshot" } } + | { requestId: string; command: { type: "jsonCommand"; cmd: WebEngineJsonCommand; params?: Record } } | { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } } | { requestId: string; command: { type: "appendLibraryObject"; request: LibraryMainAppendRequestIR; source: ArrayBuffer } } | { requestId: string; command: { type: "beginPaintStroke"; session: PaintStrokeSessionBeginIR } } @@ -262,12 +297,14 @@ export interface WebEngineResult { lod?: WebEngineLODResult; asset?: AssetRequestResult; capabilityGate?: CapabilityGateResult; + json?: unknown; shaderCompile?: ShaderCompileReport; depsgraph?: DepsgraphEvaluationIR; blend?: ArrayBuffer; openResources?: WebEngineOpenResourceStatus; paintStrokeSession?: PaintStrokeSessionReceiptIR; libraryAppend?: LibraryMainAppendReceiptIR; + wasmModules?: Array<{ id: string; lifecycle: "complete" | "debug"; status: "pending" | "loaded" | "failed"; byteLength: number; error?: string }>; } export type WebEngineResponse =