feat(web): close remaining editor, layout and persistence gaps

- Area headers get a per-area editor switcher backed by a shared
  AreaEditorContext; Preferences can hide it.
- Animation camera area is a real navigable viewport aligned to the
  scene camera; Graph Editor adds channel selection, keyframe drag/
  insert/delete and interpolation.
- Geometry Nodes node locations round-trip through the native writer
  and reader, so saved graphs keep their layout.
- Texture Paint can make linked library images local via a new native
  makeImageLocal command before packing strokes.
- Compositor IMAGE and Render Layers nodes now persist to Main and
  execute against packed images / the last PBR render.
- Spreadsheet row cap raised to 250k; Outliner gains Ctrl+A and arrow/
  Home/End navigation.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 07:47:10 +08:00
parent d11387ef6b
commit d0b6d2a164
31 changed files with 877 additions and 61 deletions

View File

@@ -2454,7 +2454,7 @@ std::string refresh_error;
type == "createUVMap" || type == "setActiveUVMap" ||
type == "unwrapUV" || type == "addMaterialSlot" || type == "assignMaterialSlot" || type == "removeMaterialSlot" ||
type == "assignMaterialFaces" || type == "setMaterialPrincipled" ||
type == "importImage" || type == "packImage" || type == "setMaterialImageNode" || type == "setShaderGraph" || type == "editShaderNode" || type == "createText" || type == "editEditorContext" ||
type == "importImage" || type == "packImage" || type == "makeImageLocal" || type == "setMaterialImageNode" || type == "setShaderGraph" || type == "editShaderNode" || type == "createText" || type == "editEditorContext" ||
type == "setGeometryNodeGraph" || type == "setPhysicsSimulation" ||
type == "setRigidBodySettings" || type == "setClothSettings" ||
type == "setFluidSettings" || type == "setParticleCache" ||
@@ -2686,6 +2686,10 @@ std::string refresh_error;
command.value("mimeType", "").c_str(), command.value("width", 0), command.value("height", 0),
command.value("base64", "").c_str(), packed_image_update, main_error);
}
else if (type == "makeImageLocal") {
applied = web_engine_blend_main_make_image_local(
engine->authoritative_main, command.value("imageId", "").c_str(), main_error);
}
else if (type == "setMaterialImageNode") {
applied = web_engine_blend_main_set_material_image(
engine->authoritative_main, command.value("materialId", "").c_str(),

View File

@@ -898,6 +898,7 @@ std::optional<json> compositor_graph_from_scene(const ParsedBlend &blend,
json nodes = json::array();
json links = json::array();
json resources = json::array();
std::unordered_map<uint64_t, std::string> node_ids;
std::unordered_set<std::string> used_node_ids;
std::string output_node_id;
@@ -950,6 +951,34 @@ std::optional<json> compositor_graph_from_scene(const ParsedBlend &blend,
node_ir["properties"] = {{"color", {values[0], values[1], values[2], values[3]}}};
}
}
else if (blender_type == "CompositorNodeImage") {
if (const std::optional<uint64_t> id_pointer = read_pointer(*blend.sdna, node, "id")) {
if (const std::optional<ElementRef> image = element_for_pointer(blend, *id_pointer)) {
const std::string image_name = read_id_name(*blend.sdna, *image);
if (!image_name.empty()) {
const std::string resource_id = "compositor-resource:" + node_id;
node_ir["type"] = "IMAGE";
node_ir.erase("blenderType");
node_ir["properties"] = {{"resourceId", resource_id}};
json resource = {{"id", resource_id}, {"kind", "IMAGE"},
{"sourceId", "image:" + image_name}};
const int64_t width = read_integer(*blend.sdna, *image, "x").value_or(0);
const int64_t height = read_integer(*blend.sdna, *image, "y").value_or(0);
if (width > 0 && width <= 8192) resource["width"] = width;
if (height > 0 && height <= 8192) resource["height"] = height;
resources.push_back(std::move(resource));
}
}
}
}
else if (blender_type == "CompositorNodeRLayers") {
const std::string resource_id = "compositor-resource:" + node_id;
node_ir["type"] = "RENDER_LAYER";
node_ir.erase("blenderType");
node_ir["properties"] = {{"resourceId", resource_id}};
resources.push_back({{"id", resource_id}, {"kind", "RENDER_LAYER"},
{"sourceId", "render-result"}});
}
else if (blender_type == "CompositorNodeExposure") {
const std::optional<ElementRef> exposure = node_socket_default_element(
blend, node, "Exposure");
@@ -1100,7 +1129,7 @@ std::optional<json> compositor_graph_from_scene(const ParsedBlend &blend,
{"outputNodeId", output_node_id},
{"nodes", std::move(nodes)},
{"links", std::move(links)},
{"resources", json::array()}}}};
{"resources", std::move(resources)}}}};
}
std::optional<json> sequencer_timeline_from_scene(const ParsedBlend &blend,

View File

@@ -15113,6 +15113,33 @@ bool web_engine_blend_main_import_image(WebBlendMainState *state,
return true;
}
bool web_engine_blend_main_make_image_local(WebBlendMainState *state,
const char *image_id,
std::string &error)
{
if (state == nullptr || state->main == nullptr || image_id == nullptr) {
error = "makeImageLocal requires an authoritative Main and image identifier";
return false;
}
Image *image = find_image(state->main, image_id);
if (image == nullptr) {
error = "makeImageLocal requires an existing image data-block";
return false;
}
if (image->id.lib == nullptr) return true;
if (!BKE_lib_id_make_local(state->main, &image->id, LIB_ID_MAKELOCAL_ASSET_DATA_CLEAR)) {
error = "makeImageLocal could not copy the linked image into the current file";
return false;
}
Image *local = find_image(state->main, image_id);
if (local == nullptr || local->id.lib != nullptr) {
error = "makeImageLocal did not create a local image data-block";
return false;
}
local->id.recalc |= ID_RECALC_SOURCE;
return true;
}
bool web_engine_blend_main_pack_image(WebBlendMainState *state,
const char *image_id,
const char *mime_type,
@@ -19979,6 +20006,10 @@ bool web_engine_blend_main_geometry_node_graphs_json(WebBlendMainState *state,
{"type", node_type},
{"name", node->name[0] != '\0' ? node->name : node_type},
{"sockets", std::move(sockets)}};
if (std::isfinite(node->location[0]) && std::isfinite(node->location[1]) &&
std::abs(node->location[0]) <= 1.0e6f && std::abs(node->location[1]) <= 1.0e6f) {
node_json["location"] = {{"x", node->location[0]}, {"y", node->location[1]}};
}
if (node->id != nullptr && std::string(node->id->name, 2) == "NT") {
const std::string reference = geometry_id_reference(node->id);
node_json["groupTreeId"] = reference;
@@ -20163,6 +20194,13 @@ bool web_engine_blend_main_set_geometry_node_graph(WebBlendMainState *state,
}
STRNCPY_UTF8(native->name, node_name.c_str());
STRNCPY_UTF8(native->label, node_name.c_str());
if (node.contains("location") && node["location"].is_object()) {
const json &location = node["location"];
if (location["x"].is_number() && location["y"].is_number()) {
native->location[0] = location["x"].get<float>();
native->location[1] = location["y"].get<float>();
}
}
native_nodes.emplace(node_id, native);
}
const auto socket_for = [](bNode &node, const eNodeSocketInOut direction, const std::string &socket_id) -> bNodeSocket * {
@@ -20313,8 +20351,15 @@ bool web_engine_blend_main_set_compositor_node_graph(WebBlendMainState *state,
{"ALPHA_OVER", "CompositorNodeAlphaOver"},
{"TRANSFORM", "CompositorNodeTransform"},
{"VIEWER", "CompositorNodeViewer"},
{"IMAGE", "CompositorNodeImage"},
{"RENDER_LAYER", "CompositorNodeRLayers"},
{"COMPOSITE", "NodeGroupOutput"}};
std::unordered_map<std::string, json> resources;
for (const json &resource : graph.value("resources", json::array())) {
if (!resource.is_object() || !resource.contains("id") || !resource["id"].is_string()) continue;
resources[resource["id"].get<std::string>()] = resource;
}
std::unordered_set<std::string> node_ids;
for (const json &node : graph["nodes"]) {
const std::string node_id = node.value("id", "");
@@ -20328,6 +20373,21 @@ bool web_engine_blend_main_set_compositor_node_graph(WebBlendMainState *state,
error = "COMPOSITOR_GRAPH_INVALID: duplicate Compositor node identity";
return false;
}
if (node_type == "IMAGE" || node_type == "RENDER_LAYER") {
const json properties = node.value("properties", json::object());
const std::string resource_id = properties.value("resourceId", "");
const auto resource = resources.find(resource_id);
if (resource == resources.end() || !resource->second.contains("sourceId") ||
!resource->second["sourceId"].is_string()) {
error = "COMPOSITOR_GRAPH_INVALID: image node has no matching resource";
return false;
}
if (node_type == "IMAGE" && find_image(state->main,
resource->second["sourceId"].get<std::string>().c_str()) == nullptr) {
error = "COMPOSITOR_GRAPH_INVALID: image node resource is not an image data-block";
return false;
}
}
const json location = node.value("location", json::object());
for (const char *axis : {"x", "y"}) {
if (location.contains(axis)) {
@@ -20456,6 +20516,18 @@ bool web_engine_blend_main_set_compositor_node_graph(WebBlendMainState *state,
}
STRNCPY_UTF8(native->name, node.value("name", node_id).c_str());
STRNCPY_UTF8(native->label, node.value("name", node_id).c_str());
if (node_type == "IMAGE") {
const std::string resource_id = node.value("properties", json::object()).value("resourceId", "");
const auto resource = resources.find(resource_id);
Image *image = resource == resources.end() ? nullptr :
find_image(state->main, resource->second.value("sourceId", "").c_str());
if (image == nullptr) {
error = "COMPOSITOR_GRAPH_INVALID: image node resource is unavailable";
return false;
}
native->id = &image->id;
id_us_plus(&image->id);
}
const json location = node.value("location", json::object());
native->location[0] = location.value("x", 0.0f);
native->location[1] = location.value("y", 0.0f);

View File

@@ -436,6 +436,9 @@ bool web_engine_blend_main_assign_material_slot(WebBlendMainState *state,
const char *material_id,
int slot_index,
std::string &error);
bool web_engine_blend_main_make_image_local(WebBlendMainState *state,
const char *image_id,
std::string &error);
bool web_engine_blend_main_pack_image(WebBlendMainState *state,
const char *image_id,
const char *mime_type,