Advance M8-M11 parity workflows
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions

This commit is contained in:
mes123456
2026-08-17 04:37:07 -04:00
parent 7c16b279ae
commit 0fe8d2bb56
324 changed files with 31920 additions and 863 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
# Local toolchains and caches
.emcache/
.emscripten-web
__pycache__/
*.py[cod]
*.log
mylog.txt

View File

@@ -521,7 +521,7 @@ tests/files/web/large_scene.blend
| W-038 | `in_progress` | SceneDelta diff/apply、revision 冲突校验、原生 C ABI `get_scene_delta` 以及 `setFrame`/`setObjectVisibility` 最小命令路径已闭环mesh-buffer/material range、拓扑 delta 和完整 command registry 仍待完成 |
| W-039 | `in_progress` | basic_scene 已闭环;等待 W-033~W-038 的完整验收 |
| W-040 | `in_progress` | OPFS 项目路径校验、projects/<id> 场景/资产/缩略图/tmp/cache/lod 目录和临时文件写入辅助已实现;跨浏览器 rename/crash recovery 仍待完成 |
| W-041 | `in_progress` | IndexedDB schema v6 已包含 project/asset/snapshot/log/quarantine/LOD/Simulation manifest stores 并有逐版升级 E2E旧版本回滚测试仍待完成 |
| W-041 | `in_progress` | IndexedDB schema v7 已包含 project/asset/snapshot/log/quarantine/LOD/Simulation manifest/quarantine stores并有 v6→v7 migration 与逐版升级 E2E旧版本回滚测试仍待完成 |
| W-042 | `in_progress` | StorageWorker transferable save 已实现 OPFS tmp->replace 与 IndexedDB project revision/size metadata并有 E2E 证据hash/crash recovery/snapshot manifest 仍待完成 |
| W-043 | `in_progress` | 1.5 秒可取消 autosave 已接入 React dirty 状态并经 E2E 验证;命令数阈值、快照保留和大文件背压仍待完成 |
| W-044 | `in_progress` | operation_log schema、payload/inversePayload 写入和 StorageClient 接口已实现并经 E2E 验证replay、损坏隔离和 native command recovery 仍待完成 |

View File

@@ -5,12 +5,17 @@
/** \file
* \ingroup modifiers
*
* Native Web evaluator for the first bounded Geometry Nodes closure. It
* accepts a single Group Input -> Transform Geometry -> Group Output graph.
* Unsupported graphs remain visible and report an explicit modifier error.
* Native Web evaluator for the bounded Geometry Nodes allowlist. Unsupported
* graphs remain visible and report an explicit modifier error.
*/
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <optional>
#include <string>
#include <variant>
#include "MEM_guardedalloc.h"
@@ -18,19 +23,32 @@
#include "BLI_math_rotation.hh"
#include "BLI_listbase.h"
#include "BLI_string.h"
#include "BLI_vector.hh"
#include "BKE_attribute.hh"
#include "BKE_geometry_set.hh"
#include "BKE_instances.hh"
#include "BKE_lib_query.hh"
#include "BKE_mesh.hh"
#include "BKE_modifier.hh"
#include "BKE_node.hh"
#include "BKE_node_legacy_types.hh"
#include "BKE_object.hh"
#include "BLO_read_write.hh"
#include "DEG_depsgraph_build.hh"
#include "DEG_depsgraph_query.hh"
#include "DNA_collection_types.h"
#include "DNA_image_types.h"
#include "DNA_modifier_types.h"
#include "DNA_node_types.h"
#include "DNA_object_types.h"
#include "GEO_join_geometries.hh"
#include "GEO_realize_instances.hh"
#include "GEO_transform.hh"
#include "RNA_prototypes.hh"
#include "UI_resources.hh"
@@ -89,8 +107,39 @@ static void foreach_ID_link(ModifierData *md, Object *object, IDWalkFunc walk, v
static void update_depsgraph(ModifierData *md, const ModifierUpdateDepsgraphContext *ctx)
{
NodesModifierData *nmd = reinterpret_cast<NodesModifierData *>(md);
if (nmd->node_group != nullptr) {
DEG_add_node_tree_output_relation(ctx->node, nmd->node_group, "Web Geometry Nodes Modifier");
if (nmd->node_group == nullptr) {
return;
}
DEG_add_node_tree_output_relation(ctx->node, nmd->node_group, "Web Geometry Nodes Modifier");
for (const bNode &node : nmd->node_group->nodes) {
for (const bNodeSocket &socket : node.inputs) {
if (socket.default_value == nullptr) {
continue;
}
if (socket.type == SOCK_OBJECT) {
Object *object = static_cast<const bNodeSocketValueObject *>(socket.default_value)->value;
if (object != nullptr && object != ctx->object) {
DEG_add_object_relation(
ctx->node, object, DEG_OB_COMP_TRANSFORM, "Web Geometry Nodes Object Info");
DEG_add_object_relation(
ctx->node, object, DEG_OB_COMP_GEOMETRY, "Web Geometry Nodes Object Info");
}
}
else if (socket.type == SOCK_COLLECTION) {
Collection *collection =
static_cast<const bNodeSocketValueCollection *>(socket.default_value)->value;
if (collection != nullptr) {
DEG_add_collection_geometry_relation(
ctx->node, collection, "Web Geometry Nodes Collection Info");
}
}
else if (socket.type == SOCK_IMAGE) {
Image *image = static_cast<const bNodeSocketValueImage *>(socket.default_value)->value;
if (image != nullptr) {
DEG_add_generic_id_relation(ctx->node, &image->id, "Web Geometry Nodes Image Info");
}
}
}
}
}
@@ -102,7 +151,7 @@ static bool is_disabled(const Scene *, ModifierData *md, bool)
static const bNodeSocket *find_input(const bNode &node, const char *identifier)
{
for (const bNodeSocket &socket : node.inputs) {
if (STREQ(socket.identifier, identifier)) {
if (STREQ(socket.identifier, identifier) || STREQ(socket.name, identifier)) {
return &socket;
}
}
@@ -119,24 +168,12 @@ static const bNodeSocket *find_output(const bNode &node, const char *identifier)
return nullptr;
}
static bool has_link(const bNodeTree &tree,
const bNode &from_node,
const char *from_socket,
const bNode &to_node,
const char *to_socket)
static bool node_is(const bNode &node, const char *idname)
{
const auto matches_socket = [](const bNodeSocket &socket, const char *name) {
return STREQ(socket.identifier, name) || STREQ(socket.name, name);
};
for (const bNodeLink &link : tree.links) {
if (link.fromnode == &from_node && link.tonode == &to_node && link.fromsock != nullptr &&
link.tosock != nullptr && matches_socket(*link.fromsock, from_socket) &&
matches_socket(*link.tosock, to_socket))
{
return true;
}
if (STREQ(node.idname, idname)) {
return true;
}
return false;
return std::string(node.idname) == "Undefined[" + std::string(idname) + "]";
}
static bool has_input_link(const bNodeTree &tree, const bNode &node, const char *socket_name)
@@ -151,6 +188,659 @@ static bool has_input_link(const bNodeTree &tree, const bNode &node, const char
return false;
}
using WebNodeValue = std::variant<std::monostate,
bke::GeometrySet,
bool,
int,
float,
float3,
std::string,
Object *,
Collection *,
Image *>;
class WebGeometryNodeEvaluator {
public:
static constexpr int max_evaluations = 4096;
static constexpr int max_point_elements = 1'000'000;
static constexpr int max_edge_elements = 2'000'000;
static constexpr int max_face_elements = 2'000'000;
static constexpr int max_corner_elements = 4'000'000;
static constexpr int max_instance_elements = 100'000;
static constexpr int64_t max_field_bytes = 64 * 1024 * 1024;
private:
const bNodeTree &tree_;
const ModifierEvalContext &ctx_;
const Mesh &input_mesh_;
std::string error_;
int evaluation_count_ = 0;
bool mesh_within_domain_budget(const Mesh &mesh)
{
if (mesh.verts_num > max_point_elements || mesh.edges_num > max_edge_elements ||
mesh.faces_num > max_face_elements || mesh.corners_num > max_corner_elements)
{
error_ = "field/domain element budget exceeded";
return false;
}
return true;
}
bool geometry_within_domain_budget(const bke::GeometrySet &geometry)
{
if (const Mesh *mesh = geometry.get_mesh()) {
if (!mesh_within_domain_budget(*mesh)) {
return false;
}
}
if (const bke::Instances *instances = geometry.get_instances()) {
if (instances->instances_num() > max_instance_elements) {
error_ = "instance domain element budget exceeded";
return false;
}
}
return true;
}
WebNodeValue bounded_geometry(bke::GeometrySet geometry)
{
if (!geometry_within_domain_budget(geometry)) {
return {};
}
return std::move(geometry);
}
const bNode *owner_of(const bNodeSocket &socket) const
{
for (const bNode &node : tree_.nodes) {
for (const bNodeSocket &candidate : node.inputs) {
if (&candidate == &socket) {
return &node;
}
}
for (const bNodeSocket &candidate : node.outputs) {
if (&candidate == &socket) {
return &node;
}
}
}
return nullptr;
}
Vector<const bNodeLink *> input_links(const bNodeSocket &socket) const
{
Vector<const bNodeLink *> links;
for (const bNodeLink &link : tree_.links) {
if (link.tosock == &socket && link.fromnode != nullptr && link.fromsock != nullptr) {
links.append(&link);
}
}
std::ranges::sort(links, [](const bNodeLink *a, const bNodeLink *b) {
return a->multi_input_sort_id > b->multi_input_sort_id;
});
return links;
}
WebNodeValue default_value(const bNodeSocket &socket)
{
if (socket.default_value == nullptr) {
return {};
}
switch (socket.type) {
case SOCK_BOOLEAN:
return static_cast<const bNodeSocketValueBoolean *>(socket.default_value)->value != 0;
case SOCK_INT:
return static_cast<const bNodeSocketValueInt *>(socket.default_value)->value;
case SOCK_FLOAT:
return static_cast<const bNodeSocketValueFloat *>(socket.default_value)->value;
case SOCK_VECTOR:
return float3(static_cast<const bNodeSocketValueVector *>(socket.default_value)->value);
case SOCK_STRING:
return std::string(
static_cast<const bNodeSocketValueString *>(socket.default_value)->value);
case SOCK_OBJECT:
return static_cast<const bNodeSocketValueObject *>(socket.default_value)->value;
case SOCK_COLLECTION:
return static_cast<const bNodeSocketValueCollection *>(socket.default_value)->value;
case SOCK_IMAGE:
return static_cast<const bNodeSocketValueImage *>(socket.default_value)->value;
default:
return {};
}
}
std::optional<float> number(const WebNodeValue &value) const
{
if (const int *integer = std::get_if<int>(&value)) {
return float(*integer);
}
if (const float *scalar = std::get_if<float>(&value)) {
return *scalar;
}
return std::nullopt;
}
bke::GeometrySet copy_geometry(const bke::GeometrySet &geometry) const
{
if (const Mesh *mesh = geometry.get_mesh()) {
return bke::GeometrySet::from_mesh(BKE_mesh_copy_for_eval(*mesh));
}
bke::GeometrySet copy = geometry;
copy.ensure_owns_all_data();
return copy;
}
WebNodeValue evaluate_input(const bNodeSocket &socket)
{
const Vector<const bNodeLink *> links = input_links(socket);
if (links.size() == 1) {
return evaluate_output(*links.first()->fromsock);
}
if (links.size() > 1) {
error_ = "multiple links on a non-multi-input socket";
return {};
}
return default_value(socket);
}
std::optional<bke::GeometrySet> geometry_input(const bNode &node, const char *name)
{
const bNodeSocket *socket = find_input(node, name);
if (socket == nullptr) {
error_ = std::string("missing geometry input: ") + name;
return std::nullopt;
}
WebNodeValue value = evaluate_input(*socket);
if (bke::GeometrySet *geometry = std::get_if<bke::GeometrySet>(&value)) {
return std::move(*geometry);
}
error_ = std::string("geometry input did not evaluate to geometry: ") + name;
return std::nullopt;
}
WebNodeValue evaluate_compare(const bNode &node)
{
const NodeFunctionCompare *storage = static_cast<const NodeFunctionCompare *>(node.storage);
const bNodeSocket *a_socket = find_input(node, "A");
const bNodeSocket *b_socket = find_input(node, "B");
if (storage == nullptr || a_socket == nullptr || b_socket == nullptr ||
!ELEM(storage->data_type, SOCK_INT, SOCK_FLOAT))
{
error_ = "Compare supports bounded Int/Float scalar inputs only";
return {};
}
const std::optional<float> a = number(evaluate_input(*a_socket));
const std::optional<float> b = number(evaluate_input(*b_socket));
if (!a || !b) {
error_ = "Compare scalar input is unavailable";
return {};
}
switch (storage->operation) {
case NODE_COMPARE_LESS_THAN:
return *a < *b;
case NODE_COMPARE_LESS_EQUAL:
return *a <= *b;
case NODE_COMPARE_GREATER_THAN:
return *a > *b;
case NODE_COMPARE_GREATER_EQUAL:
return *a >= *b;
case NODE_COMPARE_EQUAL:
return *a == *b;
case NODE_COMPARE_NOT_EQUAL:
return *a != *b;
default:
error_ = "Compare operation is outside the bounded evaluator";
return {};
}
}
WebNodeValue evaluate_math(const bNode &node)
{
const bNodeSocket *a_socket = find_input(node, "Value");
const bNodeSocket *b_socket = nullptr;
for (const bNodeSocket &socket : node.inputs) {
if (STREQ(socket.identifier, "Value_001")) {
b_socket = &socket;
}
}
if (a_socket == nullptr || b_socket == nullptr) {
error_ = "Math sockets are incomplete";
return {};
}
const std::optional<float> a = number(evaluate_input(*a_socket));
const std::optional<float> b = number(evaluate_input(*b_socket));
if (!a || !b) {
error_ = "Math scalar input is unavailable";
return {};
}
switch (node.custom1) {
case NODE_MATH_ADD:
return *a + *b;
case NODE_MATH_SUBTRACT:
return *a - *b;
case NODE_MATH_MULTIPLY:
return *a * *b;
case NODE_MATH_DIVIDE:
return *b == 0.0f ? 0.0f : *a / *b;
case NODE_MATH_MINIMUM:
return std::min(*a, *b);
case NODE_MATH_MAXIMUM:
return std::max(*a, *b);
default:
error_ = "Math operation is outside the bounded evaluator";
return {};
}
}
WebNodeValue evaluate_collection_info(const bNode &node)
{
const bNodeSocket *collection_socket = find_input(node, "Collection");
const bNodeSocket *separate_socket = find_input(node, "Separate Children");
const bNodeSocket *reset_socket = find_input(node, "Reset Children");
if (collection_socket == nullptr || separate_socket == nullptr || reset_socket == nullptr) {
error_ = "Collection Info sockets are incomplete";
return {};
}
const WebNodeValue collection_value = evaluate_input(*collection_socket);
const WebNodeValue separate_value = evaluate_input(*separate_socket);
const WebNodeValue reset_value = evaluate_input(*reset_socket);
Collection *const *collection = std::get_if<Collection *>(&collection_value);
const bool *separate_children = std::get_if<bool>(&separate_value);
const bool *reset_children = std::get_if<bool>(&reset_value);
if (collection == nullptr || *collection == nullptr || separate_children == nullptr ||
reset_children == nullptr || !*separate_children)
{
error_ = "Collection Info requires a concrete collection with Separate Children enabled";
return {};
}
Vector<Object *> objects;
for (const CollectionObject &entry : (*collection)->gobject) {
if (entry.ob != nullptr && entry.ob != ctx_.object) {
objects.append(entry.ob);
}
}
if (objects.size() > max_instance_elements) {
error_ = "Collection Info instance domain budget exceeded";
return {};
}
auto instances = std::make_unique<bke::Instances>();
instances->resize(objects.size());
MutableSpan<int> handles = instances->reference_handles_for_write();
MutableSpan<float4x4> transforms = instances->transforms_for_write();
for (const int index : objects.index_range()) {
Object *evaluated = reinterpret_cast<Object *>(
DEG_get_evaluated_id(ctx_.depsgraph, &objects[index]->id));
if (evaluated == nullptr) {
error_ = "Collection Info object is not available in the evaluated dependency graph";
return {};
}
handles[index] = instances->add_reference(*evaluated);
transforms[index] = *reset_children ? float4x4::identity() : evaluated->object_to_world();
}
instances->tag_reference_handles_changed();
return bounded_geometry(bke::GeometrySet::from_instances(std::move(instances)));
}
WebNodeValue evaluate_output(const bNodeSocket &socket)
{
if (++evaluation_count_ > max_evaluations) {
error_ = "evaluation budget exceeded";
return {};
}
const bNode *node = owner_of(socket);
if (node == nullptr) {
error_ = "output socket owner is missing";
return {};
}
if (node_is(*node, "NodeGroupInput")) {
if (socket.type != SOCK_GEOMETRY) {
error_ = "only the Geometry group input is supported";
return {};
}
if (!mesh_within_domain_budget(input_mesh_)) {
return {};
}
return bounded_geometry(
bke::GeometrySet::from_mesh(BKE_mesh_copy_for_eval(input_mesh_)));
}
if (node_is(*node, "FunctionNodeInputInt")) {
const NodeInputInt *storage = static_cast<const NodeInputInt *>(node->storage);
return storage == nullptr ? WebNodeValue{} : WebNodeValue{storage->integer};
}
if (node_is(*node, "FunctionNodeInputVector")) {
const NodeInputVector *storage = static_cast<const NodeInputVector *>(node->storage);
if (storage == nullptr || storage->dimensions != 3) {
error_ = "Vector input requires exactly three dimensions";
return {};
}
return float3(storage->vector);
}
if (node_is(*node, "ShaderNodeValue")) {
return default_value(socket);
}
if (node_is(*node, "ShaderNodeMath")) {
return evaluate_math(*node);
}
if (node_is(*node, "FunctionNodeCompare")) {
return evaluate_compare(*node);
}
if (node_is(*node, "GeometryNodeImageInfo")) {
const bNodeSocket *image_socket = find_input(*node, "Image");
if (image_socket == nullptr) {
error_ = "Image Info image input is missing";
return {};
}
const WebNodeValue image_value = evaluate_input(*image_socket);
Image *const *image = std::get_if<Image *>(&image_value);
if (image == nullptr || *image == nullptr) {
error_ = "Image Info image is unavailable";
return {};
}
if (STREQ(socket.identifier, "Width")) {
return (*image)->gen_x;
}
if (STREQ(socket.identifier, "Height")) {
return (*image)->gen_y;
}
if (STREQ(socket.identifier, "Has Alpha")) {
return true;
}
if (STREQ(socket.identifier, "Frame Count")) {
return 1;
}
if (STREQ(socket.identifier, "FPS")) {
return 0.0f;
}
error_ = "Image Info output is unsupported";
return {};
}
if (node_is(*node, "GeometryNodeObjectInfo")) {
const bNodeSocket *object_socket = find_input(*node, "Object");
if (object_socket == nullptr) {
error_ = "Object Info object input is missing";
return {};
}
const WebNodeValue object_value = evaluate_input(*object_socket);
Object *const *object = std::get_if<Object *>(&object_value);
if (object == nullptr || *object == nullptr || *object == ctx_.object) {
error_ = "Object Info target is unavailable or recursive";
return {};
}
Object *evaluated = reinterpret_cast<Object *>(
DEG_get_evaluated_id(ctx_.depsgraph, &(*object)->id));
if (evaluated == nullptr) {
error_ = "Object Info target is not evaluated";
return {};
}
if (STREQ(socket.identifier, "Location")) {
return float3(evaluated->object_to_world().location());
}
if (STREQ(socket.identifier, "Scale")) {
return float3(evaluated->scale);
}
if (STREQ(socket.identifier, "Geometry")) {
Mesh *target_mesh = BKE_object_get_evaluated_mesh(evaluated);
if (target_mesh == nullptr) {
error_ = "Object Info target has no evaluated mesh";
return {};
}
if (!mesh_within_domain_budget(*target_mesh)) {
return {};
}
return bounded_geometry(
bke::GeometrySet::from_mesh(BKE_mesh_copy_for_eval(*target_mesh)));
}
error_ = "Object Info output is outside the bounded evaluator";
return {};
}
if (node_is(*node, "GeometryNodeCollectionInfo")) {
return evaluate_collection_info(*node);
}
if (node_is(*node, "GeometryNodeJoinGeometry")) {
const bNodeSocket *input = find_input(*node, "Geometry");
if (input == nullptr) {
error_ = "Join Geometry input is missing";
return {};
}
Vector<bke::GeometrySet> geometries;
for (const bNodeLink *link : input_links(*input)) {
WebNodeValue value = evaluate_output(*link->fromsock);
bke::GeometrySet *geometry = std::get_if<bke::GeometrySet>(&value);
if (geometry == nullptr) {
error_ = "Join Geometry received a non-geometry input";
return {};
}
geometries.append(std::move(*geometry));
}
if (geometries.is_empty()) {
return bke::GeometrySet();
}
int64_t points = 0;
int64_t edges = 0;
int64_t faces = 0;
int64_t corners = 0;
int64_t instances = 0;
for (const bke::GeometrySet &geometry : geometries) {
if (const Mesh *mesh = geometry.get_mesh()) {
points += mesh->verts_num;
edges += mesh->edges_num;
faces += mesh->faces_num;
corners += mesh->corners_num;
}
if (const bke::Instances *geometry_instances = geometry.get_instances()) {
instances += geometry_instances->instances_num();
}
}
if (points > max_point_elements || edges > max_edge_elements ||
faces > max_face_elements || corners > max_corner_elements ||
instances > max_instance_elements)
{
error_ = "Join Geometry field/domain budget exceeded";
return {};
}
return bounded_geometry(geometry::join_geometries(
geometries.as_span(), bke::AttributeFilter::default_filter()));
}
if (node_is(*node, "GeometryNodeSeparateGeometry")) {
std::optional<bke::GeometrySet> geometry = geometry_input(*node, "Geometry");
const bNodeSocket *selection_socket = find_input(*node, "Selection");
if (!geometry || selection_socket == nullptr) {
return {};
}
const WebNodeValue selection_value = evaluate_input(*selection_socket);
const bool *selection = std::get_if<bool>(&selection_value);
if (selection == nullptr) {
error_ = "Separate Geometry requires a bounded constant selection";
return {};
}
const bool selected_output = STREQ(socket.identifier, "Selection");
const bool inverted_output = STREQ(socket.identifier, "Inverted");
if (!selected_output && !inverted_output) {
error_ = "Separate Geometry output is invalid";
return {};
}
return bounded_geometry((*selection == selected_output) ? std::move(*geometry) :
bke::GeometrySet());
}
if (node_is(*node, "GeometryNodeRealizeInstances")) {
std::optional<bke::GeometrySet> geometry = geometry_input(*node, "Geometry");
if (!geometry) {
return {};
}
geometry::RealizeInstancesOptions options;
options.keep_original_ids = false;
options.realize_instance_attributes = true;
options.realize_to_point_domain = true;
geometry::RealizeInstancesResult result = geometry::realize_instances(std::move(*geometry),
options);
if (!result.errors.is_empty()) {
error_ = result.errors.first();
return {};
}
return bounded_geometry(std::move(result.geometry));
}
if (node_is(*node, "GeometryNodeStoreNamedAttribute")) {
std::optional<bke::GeometrySet> geometry = geometry_input(*node, "Geometry");
const bNodeSocket *selection_socket = find_input(*node, "Selection");
const bNodeSocket *name_socket = find_input(*node, "Name");
const bNodeSocket *value_socket = find_input(*node, "Value");
const NodeGeometryStoreNamedAttribute *storage =
static_cast<const NodeGeometryStoreNamedAttribute *>(node->storage);
if (!geometry || selection_socket == nullptr || name_socket == nullptr ||
value_socket == nullptr || storage == nullptr || storage->data_type != CD_PROP_FLOAT ||
storage->domain != int8_t(bke::AttrDomain::Point))
{
error_ = "Store Named Attribute supports Float point attributes only";
return {};
}
const WebNodeValue selection_value = evaluate_input(*selection_socket);
const WebNodeValue name_value = evaluate_input(*name_socket);
const std::optional<float> attribute_value = number(evaluate_input(*value_socket));
const bool *selection = std::get_if<bool>(&selection_value);
const std::string *name = std::get_if<std::string>(&name_value);
if (selection == nullptr || name == nullptr || name->empty() || name->size() > 64 ||
!attribute_value)
{
error_ = "Store Named Attribute inputs are invalid";
return {};
}
bke::GeometrySet result = copy_geometry(*geometry);
if (*selection) {
Mesh *result_mesh = result.get_mesh_for_write();
if (result_mesh == nullptr) {
error_ = "Store Named Attribute requires mesh geometry";
return {};
}
if (!mesh_within_domain_budget(*result_mesh) ||
int64_t(result_mesh->verts_num) * int64_t(sizeof(float)) > max_field_bytes)
{
error_ = "Store Named Attribute field budget exceeded";
return {};
}
bke::SpanAttributeWriter<float> writer =
result_mesh->attributes_for_write().lookup_or_add_for_write_span<float>(
*name, bke::AttrDomain::Point);
if (!writer) {
error_ = "Store Named Attribute could not allocate the point attribute";
return {};
}
writer.span.fill(*attribute_value);
writer.finish();
}
return bounded_geometry(std::move(result));
}
if (node_is(*node, "GeometryNodeTransform")) {
std::optional<bke::GeometrySet> geometry = geometry_input(*node, "Geometry");
const bNodeSocket *translation_socket = find_input(*node, "Translation");
const bNodeSocket *rotation_socket = find_input(*node, "Rotation");
const bNodeSocket *scale_socket = find_input(*node, "Scale");
if (!geometry || translation_socket == nullptr || rotation_socket == nullptr ||
scale_socket == nullptr || translation_socket->default_value == nullptr ||
rotation_socket->default_value == nullptr || scale_socket->default_value == nullptr ||
has_input_link(tree_, *node, "Rotation") || has_input_link(tree_, *node, "Scale"))
{
error_ = "Transform Geometry inputs are outside the bounded evaluator";
return {};
}
WebNodeValue translation_value = evaluate_input(*translation_socket);
const float3 *translation = std::get_if<float3>(&translation_value);
if (translation == nullptr) {
error_ = "Transform Geometry translation is unavailable";
return {};
}
const bNodeSocketValueRotation &rotation_value =
*static_cast<const bNodeSocketValueRotation *>(rotation_socket->default_value);
const math::Quaternion rotation = math::to_quaternion(
math::EulerXYZ(float3(rotation_value.value_euler)));
const float3 scale(
static_cast<const bNodeSocketValueVector *>(scale_socket->default_value)->value);
bke::GeometrySet result = copy_geometry(*geometry);
geometry::transform_geometry(
result, math::from_loc_rot_scale<float4x4>(*translation, rotation, scale));
return bounded_geometry(std::move(result));
}
if (node_is(*node, "GeometryNodeSetPosition")) {
std::optional<bke::GeometrySet> geometry = geometry_input(*node, "Geometry");
const bNodeSocket *selection_socket = find_input(*node, "Selection");
const bNodeSocket *offset_socket = find_input(*node, "Offset");
if (!geometry || selection_socket == nullptr || offset_socket == nullptr ||
has_input_link(tree_, *node, "Position"))
{
error_ = "Set Position inputs are outside the bounded evaluator";
return {};
}
const WebNodeValue selection_value = evaluate_input(*selection_socket);
const WebNodeValue offset_value = evaluate_input(*offset_socket);
const bool *selection = std::get_if<bool>(&selection_value);
const float3 *offset = std::get_if<float3>(&offset_value);
if (selection == nullptr || offset == nullptr) {
error_ = "Set Position requires a constant selection and vector offset";
return {};
}
bke::GeometrySet result = copy_geometry(*geometry);
if (*selection) {
geometry::translate_geometry(result, *offset);
}
return bounded_geometry(std::move(result));
}
error_ = std::string("unsupported node type: ") + node->idname;
return {};
}
public:
WebGeometryNodeEvaluator(const bNodeTree &tree,
const ModifierEvalContext &ctx,
const Mesh &input_mesh)
: tree_(tree), ctx_(ctx), input_mesh_(input_mesh)
{
}
Mesh *evaluate()
{
const bNode *group_output = nullptr;
for (const bNode &node : tree_.nodes) {
if (node_is(node, "NodeGroupOutput")) {
if (group_output != nullptr) {
error_ = "multiple Group Output nodes are unsupported";
return nullptr;
}
group_output = &node;
}
}
const bNodeSocket *geometry_socket = group_output == nullptr ? nullptr :
find_input(*group_output,
"Geometry");
if (geometry_socket == nullptr) {
error_ = "Geometry Group Output is missing";
return nullptr;
}
WebNodeValue value = evaluate_input(*geometry_socket);
bke::GeometrySet *geometry = std::get_if<bke::GeometrySet>(&value);
if (geometry == nullptr || !error_.empty()) {
if (error_.empty()) {
error_ = "Geometry Group Output did not evaluate to geometry";
}
return nullptr;
}
if (!geometry_within_domain_budget(*geometry)) {
return nullptr;
}
bke::MeshComponent &mesh_component =
geometry->get_component_for_write<bke::MeshComponent>();
mesh_component.ensure_owns_direct_data();
Mesh *result = mesh_component.release();
return result == nullptr ? BKE_mesh_new_nomain(0, 0, 0, 0) : result;
}
const std::string &error() const
{
return error_;
}
};
static Mesh *modify_mesh(ModifierData *md, const ModifierEvalContext *ctx, Mesh *mesh)
{
const NodesModifierData *nmd = reinterpret_cast<const NodesModifierData *>(md);
@@ -168,13 +858,8 @@ static Mesh *modify_mesh(ModifierData *md, const ModifierEvalContext *ctx, Mesh
return mesh;
}
const bNode *group_input = nullptr;
const bNode *group_output = nullptr;
const bNode *transform = nullptr;
const bNode *set_position = nullptr;
bool has_simulation_node = false;
int node_count = 0;
int link_count = 0;
for (const bNode &node : tree->nodes) {
node_count++;
if (ELEM(node.type_legacy,
@@ -188,25 +873,6 @@ static Mesh *modify_mesh(ModifierData *md, const ModifierEvalContext *ctx, Mesh
{
has_simulation_node = true;
}
if (find_input(node, "Translation") != nullptr && find_input(node, "Rotation") != nullptr &&
find_input(node, "Scale") != nullptr)
{
transform = &node;
}
else if (find_input(node, "Offset") != nullptr && find_input(node, "Position") != nullptr &&
find_input(node, "Selection") != nullptr)
{
set_position = &node;
}
else if (node.inputs.first == nullptr && node.outputs.first != nullptr) {
group_input = &node;
}
else if (node.inputs.first != nullptr && node.outputs.first == nullptr) {
group_output = &node;
}
}
for ([[maybe_unused]] const bNodeLink &link : tree->links) {
link_count++;
}
if (has_simulation_node) {
@@ -217,85 +883,20 @@ static Mesh *modify_mesh(ModifierData *md, const ModifierEvalContext *ctx, Mesh
return mesh;
}
const bNode *geometry_node = transform != nullptr ? transform : set_position;
const bool input_link_valid = group_input != nullptr && geometry_node != nullptr &&
has_link(*tree, *group_input, "Geometry", *geometry_node, "Geometry");
const bool output_link_valid = geometry_node != nullptr && group_output != nullptr &&
has_link(*tree, *geometry_node, "Geometry", *group_output, "Geometry");
if (node_count != 3 || link_count != 2 || group_input == nullptr ||
group_output == nullptr || geometry_node == nullptr || (transform != nullptr && set_position != nullptr) ||
!input_link_valid || !output_link_valid)
{
BKE_modifier_set_error(
ctx->object,
md,
"Web Geometry Nodes supports only a single constant Transform Geometry or Set Position node");
return mesh;
}
if (set_position != nullptr) {
const bNodeSocket *selection_socket = find_input(*set_position, "Selection");
const bNodeSocket *offset_socket = find_input(*set_position, "Offset");
if (selection_socket == nullptr || offset_socket == nullptr ||
selection_socket->default_value == nullptr || offset_socket->default_value == nullptr ||
has_input_link(*tree, *set_position, "Selection") ||
has_input_link(*tree, *set_position, "Position") ||
has_input_link(*tree, *set_position, "Offset"))
{
BKE_modifier_set_error(
ctx->object, md, "Web Geometry Nodes Set Position supports only unlinked constant inputs");
return mesh;
}
Mesh *result = BKE_mesh_copy_for_eval(*mesh);
const bool selected = static_cast<const bNodeSocketValueBoolean *>(
selection_socket->default_value)
->value;
if (selected) {
const float3 offset(
static_cast<const bNodeSocketValueVector *>(offset_socket->default_value)->value);
for (float3 &position : result->vert_positions_for_write()) {
position += offset;
}
result->tag_positions_changed();
}
return result;
}
const bNodeSocket *mode_socket = find_input(*transform, "Mode");
const int mode = mode_socket && mode_socket->default_value ?
static_cast<const bNodeSocketValueMenu *>(mode_socket->default_value)->value :
GEO_NODE_TRANSFORM_MODE_COMPONENTS;
if (mode != GEO_NODE_TRANSFORM_MODE_COMPONENTS) {
if (node_count > WebGeometryNodeEvaluator::max_evaluations) {
BKE_modifier_set_error(ctx->object,
md,
"Web Geometry Nodes Transform supports Components mode only");
"WEB_GEOMETRY_NODES_EVALUATOR_UNSUPPORTED: node budget exceeded");
return mesh;
}
const bNodeSocket *translation_socket = find_input(*transform, "Translation");
const bNodeSocket *rotation_socket = find_input(*transform, "Rotation");
const bNodeSocket *scale_socket = find_input(*transform, "Scale");
if (translation_socket == nullptr || rotation_socket == nullptr || scale_socket == nullptr ||
translation_socket->default_value == nullptr || rotation_socket->default_value == nullptr ||
scale_socket->default_value == nullptr)
{
BKE_modifier_set_error(ctx->object, md, "Web Geometry Nodes Transform sockets are incomplete");
WebGeometryNodeEvaluator evaluator(*tree, *ctx, *mesh);
Mesh *result = evaluator.evaluate();
if (result == nullptr) {
const std::string message = "WEB_GEOMETRY_NODES_EVALUATOR_UNSUPPORTED: " + evaluator.error();
BKE_modifier_set_error(ctx->object, md, "%s", message.c_str());
return mesh;
}
const float3 translation(
static_cast<const bNodeSocketValueVector *>(translation_socket->default_value)->value);
const bNodeSocketValueRotation &rotation_value =
*static_cast<const bNodeSocketValueRotation *>(rotation_socket->default_value);
const math::Quaternion rotation = math::to_quaternion(
math::EulerXYZ(float3(rotation_value.value_euler)));
const float3 scale(
static_cast<const bNodeSocketValueVector *>(scale_socket->default_value)->value);
Mesh *result = BKE_mesh_copy_for_eval(*mesh);
bke::mesh_transform(
*result, math::from_loc_rot_scale<float4x4>(translation, rotation, scale), false);
return result;
}

View File

@@ -829,6 +829,13 @@ bool refresh_scene_from_main(EngineState &engine,
return false;
}
snapshot["greasePencils"] = json::parse(grease_pencils_json);
std::string geometry_node_graphs_json;
if (!web_engine_blend_main_geometry_node_graphs_json(
engine.authoritative_main, geometry_node_graphs_json, error))
{
return false;
}
snapshot["geometryNodeGraphs"] = json::parse(geometry_node_graphs_json);
std::string physics_json;
if (!web_engine_blend_main_physics_simulation_json(
engine.authoritative_main, physics_json, error))
@@ -1132,6 +1139,15 @@ EMSCRIPTEN_KEEPALIVE int web_engine_open_blend(const int handle,
return last_error_code;
}
opened_snapshot["greasePencils"] = json::parse(grease_pencils_json);
std::string geometry_node_graphs_json;
if (!web_engine_blend_main_geometry_node_graphs_json(
authoritative_main, geometry_node_graphs_json, 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["geometryNodeGraphs"] = json::parse(geometry_node_graphs_json);
std::string physics_json;
if (!web_engine_blend_main_physics_simulation_json(
authoritative_main, physics_json, main_error))
@@ -1543,9 +1559,10 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
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 == "setFontLinks" || type == "setVolumeProperties" || type == "deleteNonMeshData" ||
type == "setFontProperties" || type == "setFontAdvanced" || type == "importVFont" || type == "setFontLinks" || type == "setVolumeProperties" || type == "deleteNonMeshData" ||
type == "setMetaballElements" || type == "createGreasePencilLayer" ||
type == "removeGreasePencilLayer" || type == "moveGreasePencilLayer" ||
type == "moveGreasePencilFrame" ||
type == "insertGreasePencilFrame" || type == "removeGreasePencilFrame" ||
type == "setGreasePencilStrokes" || type == "setVertexColors" ||
type == "setVertexWeights" || type == "setCameraProperties" || type == "setLightProperties" ||
@@ -1919,6 +1936,13 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
engine->authoritative_main, command.value("dataId", "").c_str(), characters,
text_boxes, command.value("activeTextBox", -1), main_error);
}
else if (type == "importVFont") {
std::string font_id;
applied = web_engine_blend_main_import_vfont(
engine->authoritative_main, command.value("name", "").c_str(),
command.value("sourcePath", "").c_str(), command.value("base64", "").c_str(),
font_id, main_error);
}
else if (type == "setFontLinks") {
const json links = command.value("links", json::object());
const std::array<const char *, 4> fields = {"regular", "bold", "italic", "boldItalic"};
@@ -1984,6 +2008,12 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
engine->authoritative_main, command.value("dataId", "").c_str(),
command.value("layerId", "").c_str(), command.value("direction", "UP").c_str(), main_error);
}
else if (type == "moveGreasePencilFrame") {
applied = web_engine_blend_main_move_grease_pencil_frame(
engine->authoritative_main, command.value("dataId", "").c_str(),
command.value("layerId", "").c_str(), command.value("frame", -1),
command.value("targetFrame", -1), command.value("drawingId", "").c_str(), main_error);
}
else if (type == "insertGreasePencilFrame") {
applied = web_engine_blend_main_insert_grease_pencil_frame(
engine->authoritative_main, command.value("dataId", "").c_str(),
@@ -2066,7 +2096,8 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
command.value("vertexGroup", "").c_str(),
command.value("indices", std::vector<uint32_t>()),
command.value("values", std::vector<float>()),
command.value("normalize", false), command.value("mirror", false), main_error);
command.value("normalize", false), command.value("limit", 0u), command.value("mirror", false),
command.value("mirrorAxis", 0), command.value("mirrorTolerance", 1e-4f), main_error);
}
else if (type == "setCameraProperties" || type == "setLightProperties" || type == "setWorldProperties")
{

View File

@@ -3513,7 +3513,16 @@ json scene_ir_from_blend(const ParsedBlend &blend,
{"emissionStrength", std::clamp(emission_strength, 0.0f, 1000000.0f)} };
if (!normal_image_id.empty()) material["normalImageId"] = normal_image_id;
if (!image_ids.empty()) material["imageIds"] = image_ids;
if (!material_nodes.empty()) material["nodes"] = std::move(material_nodes);
if (!material_nodes.empty()) {
/* The browser compiler binds its report to this exact reader graph. */
material["shaderGraphHash"] = sha256_hex(json({
{"schemaVersion", 1},
{"materialId", record.id},
{"nodes", material_nodes},
{"links", material_links},
}).dump());
material["nodes"] = std::move(material_nodes);
}
if (!material_links.empty()) material["links"] = std::move(material_links);
if (!warnings.empty()) material["warnings"] = warnings;
materials.push_back(std::move(material));
@@ -3636,9 +3645,20 @@ json scene_ir_from_blend(const ParsedBlend &blend,
}
else if (record.type_name == "VFont") {
const std::string filepath = read_string(*blend.sdna, element, "filepath");
vfonts.push_back({{"id", record.id}, {"name", record.name}, {"sourcePath", filepath},
{"builtin", filepath == "<builtin>"},
{"packed", read_pointer(*blend.sdna, element, "packedfile").value_or(0) != 0}});
const std::optional<uint64_t> packed_file = read_pointer(*blend.sdna, element, "packedfile");
const std::vector<uint8_t> packed_bytes = packed_file ? packed_file_bytes(blend, *packed_file) :
std::vector<uint8_t>();
json resource = {{"id", record.id},
{"name", record.name},
{"sourcePath", filepath},
{"builtin", filepath == "<builtin>"},
{"packed", !packed_bytes.empty()}};
if (!packed_bytes.empty()) {
resource["packedByteLength"] = packed_bytes.size();
resource["sha256"] = sha256_hex(std::string(
reinterpret_cast<const char *>(packed_bytes.data()), packed_bytes.size()));
}
vfonts.push_back(std::move(resource));
}
else if (record.type_name == "Camera") {
const int64_t camera_type = read_integer(*blend.sdna, element, "type").value_or(0);

View File

@@ -80,6 +80,7 @@ using json = nlohmann::json;
using namespace blender;
std::once_flag blender_runtime_once;
constexpr int64_t geometry_node_max_json_scalar_values = 65'536;
void initialize_blender_runtime()
{
@@ -341,6 +342,11 @@ json evaluated_modifier_report(const Object &object, const Object *object_eval)
entry["suggestion"] =
"Bake a deterministic cache in desktop Blender or disable the simulation zone";
}
else if (error.starts_with("WEB_GEOMETRY_NODES_EVALUATOR_UNSUPPORTED:")) {
entry["errorCode"] = "GEOMETRY_NODES_EVALUATOR_UNSUPPORTED";
entry["suggestion"] =
"Use only the versioned bounded Geometry Nodes evaluator closure";
}
else if (error.starts_with("WEB_DISPLACE_CONFIGURATION_UNAVAILABLE:")) {
entry["errorCode"] = "MODIFIER_CONFIGURATION_UNSUPPORTED";
entry["suggestion"] =
@@ -399,7 +405,15 @@ json evaluated_mesh_report(const Depsgraph *graph, Object *object)
{"modifiers", evaluated_modifier_report(*object, object_eval)},
{"worldMatrix", json::array()},
{"positions", json::array()},
{"indices", json::array()}};
{"indices", json::array()},
{"domainCardinality",
{{"POINT", mesh->verts_num},
{"EDGE", mesh->edges_num},
{"FACE", mesh->faces_num},
{"CORNER", mesh->corners_num},
{"CURVE", 0},
{"INSTANCE", 0},
{"LAYER", 0}}}};
for (int row = 0; row < 4; row++) {
for (int column = 0; column < 4; column++) {
/* The Web scene IR uses the same column-major flattening as Blender's Python API and glTF. */
@@ -411,6 +425,32 @@ json evaluated_mesh_report(const Depsgraph *graph, Object *object)
report["positions"].push_back(position.y);
report["positions"].push_back(position.z);
}
const bke::AttributeReader<float> m10_value = mesh->attributes().lookup<float>(
"m10_value", bke::AttrDomain::Point);
if (m10_value) {
json materialization = {{"schemaVersion", 1},
{"fieldId", "attribute:m10_value"},
{"domain", "POINT"},
{"dataType", "FLOAT"},
{"elementCount", positions.size()},
{"scalarValueCount", positions.size()},
{"materializedByteLength", positions.size() * sizeof(float)}};
if (positions.size() <= geometry_node_max_json_scalar_values) {
materialization["transport"] = "JSON";
report["attributes"] = {{"m10_value",
{{"domain", "POINT"},
{"dataType", "FLOAT"},
{"values", json::array()}}}};
for (const int index : positions.index_range()) {
report["attributes"]["m10_value"]["values"].push_back(m10_value.varray[index]);
}
}
else {
materialization["transport"] = "BINARY_REQUIRED";
materialization["errorCode"] = "GN_FIELD_JSON_BUDGET_EXCEEDED";
}
report["fieldMaterializations"] = json::array({std::move(materialization)});
}
for (const int3 &triangle : corner_tris) {
for (int index = 0; index < 3; index++) {
report["indices"].push_back(corner_verts[triangle[index]]);

View File

@@ -8,6 +8,8 @@
#include <cstring>
#include <limits>
#include <new>
#include <string_view>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
@@ -36,7 +38,9 @@
#include "BKE_nla.hh"
#include "BKE_object.hh"
#include "BKE_object_deform.h"
#include "BKE_packedFile.hh"
#include "BKE_scene.hh"
#include "BKE_vfont.hh"
#include "BKE_volume.hh"
#include "BKE_fcurve.hh"
#include "BKE_image.hh"
@@ -615,6 +619,217 @@ bool append_write_bytes(const void *data, const size_t size, void *user_data)
}
}
const char *geometry_socket_data_type(const eNodeSocketDatatype type)
{
switch (type) {
case SOCK_FLOAT: return "FLOAT";
case SOCK_VECTOR: return "VECTOR";
case SOCK_RGBA: return "COLOR";
case SOCK_SHADER: return "SHADER";
case SOCK_BOOLEAN: return "BOOLEAN";
case SOCK_INT: return "INT";
case SOCK_STRING: return "STRING";
case SOCK_OBJECT: return "OBJECT";
case SOCK_IMAGE: return "IMAGE";
case SOCK_GEOMETRY: return "GEOMETRY";
case SOCK_COLLECTION: return "COLLECTION";
case SOCK_TEXTURE: return "TEXTURE";
case SOCK_MATERIAL: return "MATERIAL";
case SOCK_ROTATION: return "ROTATION";
case SOCK_MENU: return "MENU";
case SOCK_MATRIX: return "MATRIX";
case SOCK_BUNDLE: return "BUNDLE";
case SOCK_CLOSURE: return "CLOSURE";
case SOCK_FONT: return "FONT";
case SOCK_SCENE: return "SCENE";
case SOCK_TEXT_ID: return "TEXT";
case SOCK_MASK: return "MASK";
case SOCK_SOUND: return "SOUND";
case SOCK_INT_VECTOR: return "INT_VECTOR";
case SOCK_CUSTOM: return "CUSTOM";
}
return "CUSTOM";
}
eNodeSocketDatatype geometry_interface_socket_type(const char *socket_type)
{
if (socket_type == nullptr) return SOCK_CUSTOM;
const std::string type(socket_type);
if (type.starts_with("NodeSocketIntVector")) return SOCK_INT_VECTOR;
if (type.starts_with("NodeSocketFloat")) return SOCK_FLOAT;
if (type.starts_with("NodeSocketVector")) return SOCK_VECTOR;
if (type == "NodeSocketColor") return SOCK_RGBA;
if (type == "NodeSocketShader") return SOCK_SHADER;
if (type == "NodeSocketBool") return SOCK_BOOLEAN;
if (type.starts_with("NodeSocketInt")) return SOCK_INT;
if (type.starts_with("NodeSocketString")) return SOCK_STRING;
if (type == "NodeSocketObject") return SOCK_OBJECT;
if (type == "NodeSocketImage") return SOCK_IMAGE;
if (type == "NodeSocketGeometry") return SOCK_GEOMETRY;
if (type == "NodeSocketCollection") return SOCK_COLLECTION;
if (type == "NodeSocketTexture") return SOCK_TEXTURE;
if (type == "NodeSocketMaterial") return SOCK_MATERIAL;
if (type == "NodeSocketRotation") return SOCK_ROTATION;
if (type == "NodeSocketMenu") return SOCK_MENU;
if (type == "NodeSocketMatrix") return SOCK_MATRIX;
if (type == "NodeSocketBundle") return SOCK_BUNDLE;
if (type == "NodeSocketClosure") return SOCK_CLOSURE;
if (type == "NodeSocketFont") return SOCK_FONT;
if (type == "NodeSocketScene") return SOCK_SCENE;
if (type == "NodeSocketText") return SOCK_TEXT_ID;
if (type == "NodeSocketMask") return SOCK_MASK;
if (type == "NodeSocketSound") return SOCK_SOUND;
return SOCK_CUSTOM;
}
std::string geometry_id_reference(const ID *id)
{
if (id == nullptr) return {};
const std::string code(id->name, 2);
const char *prefix = code == "OB" ? "object:" : code == "IM" ? "image:" :
code == "GR" ? "collection:" : code == "TE" ? "texture:" :
code == "MA" ? "material:" : code == "VF" ? "vfont:" :
code == "SC" ? "scene:" : code == "TX" ? "text:" :
code == "MS" ? "mask:" : code == "SO" ? "sound:" :
code == "NT" ? "node-group:" : "id:";
return std::string(prefix) + id_name(*id);
}
std::optional<json> geometry_socket_default(const eNodeSocketDatatype type,
const void *default_value)
{
if (default_value == nullptr) return std::nullopt;
switch (type) {
case SOCK_FLOAT: {
const float value = static_cast<const bNodeSocketValueFloat *>(default_value)->value;
return std::isfinite(value) ? std::optional<json>(value) : std::nullopt;
}
case SOCK_INT:
return static_cast<const bNodeSocketValueInt *>(default_value)->value;
case SOCK_BOOLEAN:
return static_cast<const bNodeSocketValueBoolean *>(default_value)->value != 0;
case SOCK_VECTOR: {
const bNodeSocketValueVector &value = *static_cast<const bNodeSocketValueVector *>(default_value);
const int dimensions = std::clamp(value.dimensions, 2, 4);
json result = json::array();
for (int index = 0; index < dimensions; index++) {
if (!std::isfinite(value.value[index])) return std::nullopt;
result.push_back(value.value[index]);
}
return result;
}
case SOCK_INT_VECTOR: {
const bNodeSocketValueIntVector &value = *static_cast<const bNodeSocketValueIntVector *>(default_value);
const int dimensions = std::clamp(value.dimensions, 2, 3);
json result = json::array();
for (int index = 0; index < dimensions; index++) result.push_back(value.value[index]);
return result;
}
case SOCK_ROTATION: {
const bNodeSocketValueRotation &value = *static_cast<const bNodeSocketValueRotation *>(default_value);
if (!std::all_of(std::begin(value.value_euler), std::end(value.value_euler),
[](const float component) { return std::isfinite(component); }))
{
return std::nullopt;
}
return json::array({value.value_euler[0], value.value_euler[1], value.value_euler[2]});
}
case SOCK_RGBA: {
const bNodeSocketValueRGBA &value = *static_cast<const bNodeSocketValueRGBA *>(default_value);
if (!std::all_of(std::begin(value.value), std::end(value.value),
[](const float component) { return std::isfinite(component); }))
{
return std::nullopt;
}
return json::array({value.value[0], value.value[1], value.value[2], value.value[3]});
}
case SOCK_STRING: {
const bNodeSocketValueString &value = *static_cast<const bNodeSocketValueString *>(default_value);
return std::string(value.value, strnlen(value.value, sizeof(value.value)));
}
case SOCK_MENU:
return static_cast<const bNodeSocketValueMenu *>(default_value)->value;
case SOCK_OBJECT:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueObject *>(default_value)->value));
case SOCK_IMAGE:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueImage *>(default_value)->value));
case SOCK_COLLECTION:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueCollection *>(default_value)->value));
case SOCK_TEXTURE:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueTexture *>(default_value)->value));
case SOCK_MATERIAL:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueMaterial *>(default_value)->value));
case SOCK_FONT:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueFont *>(default_value)->value));
case SOCK_SCENE:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueScene *>(default_value)->value));
case SOCK_TEXT_ID:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueText *>(default_value)->value));
case SOCK_MASK:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueMask *>(default_value)->value));
case SOCK_SOUND:
return geometry_id_reference(reinterpret_cast<const ID *>(
static_cast<const bNodeSocketValueSound *>(default_value)->value));
case SOCK_CUSTOM:
case SOCK_SHADER:
case SOCK_GEOMETRY:
case SOCK_MATRIX:
case SOCK_BUNDLE:
case SOCK_CLOSURE:
return std::nullopt;
}
return std::nullopt;
}
std::string geometry_node_id(const bNode &node)
{
return "geometry-node:" + std::to_string(node.identifier);
}
std::string geometry_node_type(const bNode &node)
{
std::string stored(node.idname);
constexpr std::string_view undefined_prefix = "Undefined[";
while (stored.starts_with(undefined_prefix) && stored.ends_with("]") &&
stored.size() > undefined_prefix.size() + 1)
{
stored = stored.substr(undefined_prefix.size(), stored.size() - undefined_prefix.size() - 1);
}
return stored;
}
std::string geometry_socket_id(const bNodeSocket &socket, const eNodeSocketInOut direction)
{
return std::string("geometry-socket:") + (direction == SOCK_IN ? "input:" : "output:") +
socket.identifier;
}
json geometry_socket_json(const bNodeSocket &socket, const eNodeSocketInOut direction)
{
json result = {{"id", geometry_socket_id(socket, direction)},
{"name", socket.name},
{"direction", direction == SOCK_IN ? "INPUT" : "OUTPUT"},
{"dataType", geometry_socket_data_type(socket.type)}};
if (const std::optional<json> value = geometry_socket_default(socket.type, socket.default_value)) {
if (!(value->is_string() && value->get_ref<const std::string &>().empty() &&
ELEM(socket.type, SOCK_OBJECT, SOCK_IMAGE, SOCK_COLLECTION, SOCK_TEXTURE, SOCK_MATERIAL,
SOCK_FONT, SOCK_SCENE, SOCK_TEXT_ID, SOCK_MASK, SOCK_SOUND)))
{
result["defaultValue"] = *value;
}
}
return result;
}
} // namespace
WebBlendMainState *web_engine_blend_main_open(const uint8_t *data,
@@ -3451,6 +3666,58 @@ bool web_engine_blend_main_set_font_advanced(
return true;
}
bool web_engine_blend_main_import_vfont(WebBlendMainState *state,
const char *name,
const char *source_path,
const char *base64,
std::string &font_id,
std::string &error)
{
if (state == nullptr || state->main == nullptr || name == nullptr || source_path == nullptr ||
base64 == nullptr || name[0] == '\0' || strlen(name) > 63 || strlen(source_path) >= FILE_MAX ||
strncmp(source_path, "//fonts/", 8) != 0 || strstr(source_path, "\\") != nullptr ||
strstr(source_path, "/../") != nullptr || strstr(source_path, "/./") != nullptr)
{
error = "NON_MESH_RESOURCE_OUTSIDE_PROJECT: VFont source must be a bounded //fonts project path";
return false;
}
for (const VFont &existing : state->main->fonts) {
if (STREQ(existing.filepath, source_path)) {
error = "NON_MESH_PROPERTY_INVALID: A VFont with the same project source path already exists";
return false;
}
}
const std::vector<uint8_t> decoded = decode_base64(base64);
if (decoded.empty() || decoded.size() > 32 * 1024 * 1024) {
error = "NON_MESH_DATA_BUDGET_EXCEEDED: VFont payload is empty or exceeds 32 MiB";
return false;
}
uint8_t *owned = MEM_new_array_uninitialized<uint8_t>(decoded.size(), "WebEngine packed VFont");
if (owned == nullptr) {
error = "NON_MESH_DATA_BUDGET_EXCEEDED: VFont packed allocation failed";
return false;
}
memcpy(owned, decoded.data(), decoded.size());
PackedFile *packed = BKE_packedfile_new_from_memory(owned, int(decoded.size()));
VFont *vfont = static_cast<VFont *>(BKE_libblock_alloc(state->main, ID_VF, name, 0));
if (packed == nullptr || vfont == nullptr) {
if (packed != nullptr) BKE_packedfile_free(packed);
else MEM_delete(owned);
error = "NON_MESH_BINARY_INVALID: Blender could not allocate the packed VFont";
return false;
}
STRNCPY(vfont->filepath, source_path);
vfont->packedfile = packed;
BKE_vfont_data_ensure(vfont);
if (vfont->data == nullptr) {
BKE_id_delete(state->main, vfont);
error = "NON_MESH_BINARY_INVALID: Blender rejected the external VFont bytes";
return false;
}
font_id = "vfont:" + id_name(vfont->id);
return true;
}
bool web_engine_blend_main_set_font_links(WebBlendMainState *state,
const char *data_id,
const std::array<std::string, 4> &font_ids,
@@ -3630,6 +3897,19 @@ bool web_engine_blend_main_grease_pencils_json(WebBlendMainState *state,
{{"name", "vertex_color"}, {"domain", "POINT"}, {"dataType", "FLOAT_COLOR"}, {"components", 4}},
{{"name", "cyclic"}, {"domain", "STROKE"}, {"dataType", "BOOL"}, {"components", 1}},
{{"name", "material_index"}, {"domain", "STROKE"}, {"dataType", "INT"}, {"components", 1}}})}};
const bke::greasepencil::Layer *active_layer = grease_pencil.get_active_layer();
if (active_layer == nullptr) {
for (const bke::greasepencil::Layer *layer : layers) {
if (layer != nullptr) {
active_layer = layer;
break;
}
}
}
if (active_layer != nullptr) {
data["activeLayerId"] = "grease-pencil-layer:" + id_name(grease_pencil.id) + ":" +
std::string(active_layer->name());
}
if (budget_blocked) {
data["errorCode"] = "GREASE_PENCIL_BUDGET_EXCEEDED";
grease_pencils.push_back(std::move(data));
@@ -3644,6 +3924,9 @@ bool web_engine_blend_main_grease_pencils_json(WebBlendMainState *state,
if (frame.is_end()) continue;
json strokes = json::array();
uint64_t drawing_point_count = 0;
const std::string drawing_suffix = id_name(grease_pencil.id) + ":" +
std::to_string(frame.drawing_index);
const std::string drawing_id = "grease-pencil-drawing:" + drawing_suffix;
const bke::greasepencil::Drawing *drawing = grease_pencil.get_drawing_at(*layer,
frame_number);
if (drawing != nullptr) {
@@ -3665,13 +3948,15 @@ bool web_engine_blend_main_grease_pencils_json(WebBlendMainState *state,
for (const int point_index : point_range) {
const float3 &position = positions[point_index];
const ColorGeometry4f color = vertex_colors[point_index];
points.push_back({{"position", json::array({position.x, position.y, position.z})},
points.push_back({{"id", "grease-pencil-point:" + drawing_suffix + ":" +
std::to_string(curve_index) + ":" +
std::to_string(point_index - point_range.start())},
{"position", json::array({position.x, position.y, position.z})},
{"radius", radii[point_index]},
{"opacity", opacities[point_index]},
{"vertexColor", json::array({color.r, color.g, color.b, color.a})}});
}
strokes.push_back({{"id", "grease-pencil-stroke:" + id_name(grease_pencil.id) + ":" +
std::to_string(frame.drawing_index) + ":" +
strokes.push_back({{"id", "grease-pencil-stroke:" + drawing_suffix + ":" +
std::to_string(curve_index)},
{"cyclic", cyclic[curve_index]},
{"pointCount", point_range.size()},
@@ -3682,8 +3967,7 @@ bool web_engine_blend_main_grease_pencils_json(WebBlendMainState *state,
json frame_entry = {
{"frame", frame_number},
{"drawing",
{{"id", "grease-pencil-drawing:" + id_name(grease_pencil.id) + ":" +
std::to_string(frame.drawing_index)},
{{"id", drawing_id},
{"strokeCount", strokes.size()},
{"pointCount", drawing_point_count},
{"strokes", std::move(strokes)}}}};
@@ -3707,6 +3991,183 @@ bool web_engine_blend_main_grease_pencils_json(WebBlendMainState *state,
return true;
}
bool web_engine_blend_main_geometry_node_graphs_json(WebBlendMainState *state,
std::string &geometry_node_graphs_json,
std::string &error)
{
if (state == nullptr || state->main == nullptr) {
error = "GN_INVALID_GRAPH: authoritative Main is not open";
return false;
}
constexpr size_t max_graphs = 4096;
constexpr size_t max_nodes_per_graph = 4096;
constexpr size_t max_links_per_graph = 16384;
constexpr size_t max_sockets_per_graph = 65536;
constexpr size_t max_interface_sockets_per_graph = 4096;
json graphs = json::array();
try {
for (const bNodeTree &tree : state->main->nodetrees) {
if (strcmp(tree.idname, "GeometryNodeTree") != 0) continue;
if (graphs.size() >= max_graphs) {
error = "GN_GRAPH_BUDGET_EXCEEDED: Geometry Node graph count exceeds 4096";
return false;
}
const size_t node_count = size_t(BLI_listbase_count(&tree.nodes));
const size_t link_count = size_t(BLI_listbase_count(&tree.links));
if (node_count > max_nodes_per_graph || link_count > max_links_per_graph ||
tree.interface_inputs().size() > max_interface_sockets_per_graph ||
tree.interface_outputs().size() > max_interface_sockets_per_graph)
{
error = "GN_GRAPH_BUDGET_EXCEEDED: Geometry Node graph topology exceeds its bounded reader limits";
return false;
}
const std::string graph_id = "node-group:" + id_name(tree.id);
json interface_inputs = json::array();
json interface_outputs = json::array();
auto append_interface_socket = [&](const bNodeTreeInterfaceSocket &socket,
const char *direction,
json &destination) -> bool {
if (socket.identifier == nullptr || socket.identifier[0] == '\0' ||
socket.name == nullptr || socket.socket_type == nullptr)
{
error = "GN_INVALID_GRAPH: Geometry Node interface socket has no stable identifier, name, or type";
return false;
}
const eNodeSocketDatatype type = geometry_interface_socket_type(socket.socket_type);
json socket_json = {
{"id", std::string("geometry-interface:") +
(strcmp(direction, "INPUT") == 0 ? "input:" : "output:") +
socket.identifier},
{"name", socket.name},
{"direction", direction},
{"dataType", geometry_socket_data_type(type)}};
if (const std::optional<json> value = geometry_socket_default(type, socket.socket_data)) {
if (!(value->is_string() && value->get_ref<const std::string &>().empty())) {
socket_json["defaultValue"] = *value;
}
}
destination.push_back(std::move(socket_json));
return true;
};
for (const bNodeTreeInterfaceSocket *socket : tree.interface_inputs()) {
if (socket != nullptr && !append_interface_socket(*socket, "INPUT", interface_inputs)) {
return false;
}
}
for (const bNodeTreeInterfaceSocket *socket : tree.interface_outputs()) {
if (socket != nullptr && !append_interface_socket(*socket, "OUTPUT", interface_outputs)) {
return false;
}
}
std::vector<const bNode *> source_nodes;
source_nodes.reserve(node_count);
for (const bNode &node : tree.nodes) source_nodes.push_back(&node);
std::sort(source_nodes.begin(), source_nodes.end(), [](const bNode *a, const bNode *b) {
return a->identifier < b->identifier;
});
json nodes = json::array();
std::unordered_set<int32_t> node_identifiers;
std::unordered_set<std::string> group_references;
size_t socket_count = 0;
for (const bNode *node : source_nodes) {
if (node == nullptr || node->identifier <= 0 ||
!node_identifiers.insert(node->identifier).second || node->idname[0] == '\0')
{
error = "GN_INVALID_GRAPH: Geometry Node has a missing or duplicate stable identifier";
return false;
}
json sockets = json::array();
std::unordered_set<std::string> socket_ids;
auto append_node_sockets = [&](const ListBaseT<bNodeSocket> &source,
const eNodeSocketInOut direction) -> bool {
for (const bNodeSocket &socket : source) {
if (socket.identifier[0] == '\0' || strcmp(socket.identifier, "__extend__") == 0) {
continue;
}
const std::string socket_id = geometry_socket_id(socket, direction);
if (!socket_ids.insert(socket_id).second) {
error = "GN_INVALID_GRAPH: Geometry Node contains duplicate stable socket identifiers";
return false;
}
socket_count++;
if (socket_count > max_sockets_per_graph) {
error = "GN_GRAPH_BUDGET_EXCEEDED: Geometry Node socket count exceeds 65536";
return false;
}
sockets.push_back(geometry_socket_json(socket, direction));
}
return true;
};
if (!append_node_sockets(node->inputs, SOCK_IN) ||
!append_node_sockets(node->outputs, SOCK_OUT))
{
return false;
}
const std::string node_type = geometry_node_type(*node);
json node_json = {{"id", geometry_node_id(*node)},
{"type", node_type},
{"name", node->name[0] != '\0' ? node->name : node_type},
{"sockets", std::move(sockets)}};
if (node->id != nullptr && std::string(node->id->name, 2) == "NT") {
const std::string reference = geometry_id_reference(node->id);
node_json["groupTreeId"] = reference;
group_references.insert(reference);
}
nodes.push_back(std::move(node_json));
}
std::vector<json> source_links;
source_links.reserve(link_count);
for (const bNodeLink &link : tree.links) {
if (link.fromnode == nullptr || link.tonode == nullptr || link.fromsock == nullptr ||
link.tosock == nullptr || strcmp(link.fromsock->identifier, "__extend__") == 0 ||
strcmp(link.tosock->identifier, "__extend__") == 0)
{
continue;
}
if (!node_identifiers.contains(link.fromnode->identifier) ||
!node_identifiers.contains(link.tonode->identifier))
{
error = "GN_INVALID_GRAPH: Geometry Node link references a node outside its graph";
return false;
}
source_links.push_back({{"fromNodeId", geometry_node_id(*link.fromnode)},
{"fromSocketId", geometry_socket_id(*link.fromsock, SOCK_OUT)},
{"toNodeId", geometry_node_id(*link.tonode)},
{"toSocketId", geometry_socket_id(*link.tosock, SOCK_IN)}});
}
std::sort(source_links.begin(), source_links.end(), [](const json &a, const json &b) {
return std::tie(a.at("fromNodeId"), a.at("fromSocketId"), a.at("toNodeId"),
a.at("toSocketId")) <
std::tie(b.at("fromNodeId"), b.at("fromSocketId"), b.at("toNodeId"),
b.at("toSocketId"));
});
json links = json::array();
for (json &link : source_links) links.push_back(std::move(link));
std::vector<std::string> references(group_references.begin(), group_references.end());
std::sort(references.begin(), references.end());
json graph = {{"schemaVersion", 1},
{"id", graph_id},
{"name", id_name(tree.id)},
{"interfaceInputs", std::move(interface_inputs)},
{"interfaceOutputs", std::move(interface_outputs)},
{"nodes", std::move(nodes)},
{"links", std::move(links)},
{"groupReferences", std::move(references)}};
graph["graphHash"] = web_engine_sha256_hex(graph.dump());
graphs.push_back(std::move(graph));
}
geometry_node_graphs_json = graphs.dump();
return true;
}
catch (const std::exception &exception) {
error = std::string("GN_INVALID_GRAPH: Geometry Node Main reader failed: ") + exception.what();
return false;
}
}
bool web_engine_blend_main_physics_simulation_json(WebBlendMainState *state,
std::string &physics_json,
std::string &error)
@@ -3884,6 +4345,50 @@ bool web_engine_blend_main_move_grease_pencil_layer(WebBlendMainState *state,
return true;
}
bool web_engine_blend_main_move_grease_pencil_frame(WebBlendMainState *state,
const char *data_id,
const char *layer_id,
const int frame,
const int target_frame,
const char *drawing_id,
std::string &error)
{
GreasePencil *grease_pencil = find_grease_pencil(state != nullptr ? state->main : nullptr,
data_id);
bke::greasepencil::Layer *layer = find_grease_pencil_layer(grease_pencil, layer_id);
if (grease_pencil == nullptr || layer == nullptr || drawing_id == nullptr ||
!ensure_single_user_data(state != nullptr ? state->main : nullptr,
grease_pencil != nullptr ? &grease_pencil->id : nullptr,
error))
{
if (error.empty()) error = "GREASE_PENCIL_SCHEMA_INVALID: Grease Pencil frame target was not found";
return false;
}
if (frame < -1000000 || frame > 1000000 || target_frame < -1000000 ||
target_frame > 1000000 || frame == target_frame || layer->frames().contains(target_frame))
{
error = "GREASE_PENCIL_SCHEMA_INVALID: frame move is outside the bounded range or target frame already exists";
return false;
}
const GreasePencilFrame *source = layer->frames().lookup_ptr(frame);
if (source == nullptr || source->is_end()) {
error = "GREASE_PENCIL_SCHEMA_INVALID: source Grease Pencil frame was not found";
return false;
}
const std::string expected_drawing_id = "grease-pencil-drawing:" +
id_name(grease_pencil->id) + ":" +
std::to_string(source->drawing_index);
if (expected_drawing_id != drawing_id) {
error = "GREASE_PENCIL_SCHEMA_INVALID: source drawing identity does not match the frame";
return false;
}
blender::Map<int, int> destinations;
destinations.add(frame, target_frame);
grease_pencil->move_frames(*layer, destinations);
grease_pencil->id.recalc |= ID_RECALC_GEOMETRY;
return true;
}
bool web_engine_blend_main_insert_grease_pencil_frame(WebBlendMainState *state,
const char *data_id,
const char *layer_id,
@@ -4039,7 +4544,10 @@ bool web_engine_blend_main_set_vertex_weights(WebBlendMainState *state,
const std::vector<uint32_t> &indices,
const std::vector<float> &values,
const bool normalize,
const uint32_t limit,
const bool mirror,
const int mirror_axis,
const float mirror_tolerance,
std::string &error)
{
Object *object = find_object(state != nullptr ? state->main : nullptr, object_id);
@@ -4050,10 +4558,6 @@ bool web_engine_blend_main_set_vertex_weights(WebBlendMainState *state,
if (error.empty()) error = "PAINT_SCHEMA_INVALID: Mesh object was not found";
return false;
}
if (mirror) {
error = "CAPABILITY_MISSING: topology mirror requires a verified mesh symmetry map";
return false;
}
if (strlen(vertex_group) == 0 || strlen(vertex_group) > 63 || indices.empty() ||
indices.size() > 1000000 || indices.size() != values.size() ||
std::any_of(indices.begin(), indices.end(), [&](const uint32_t index) { return index >= uint32_t(mesh->verts_num); }) ||
@@ -4061,6 +4565,88 @@ bool web_engine_blend_main_set_vertex_weights(WebBlendMainState *state,
error = "PAINT_SCHEMA_INVALID: vertex weight patch is outside the bounded domain";
return false;
}
if (limit > 32 || (mirror && (mirror_axis < 0 || mirror_axis > 2 || !std::isfinite(mirror_tolerance) ||
mirror_tolerance <= 0.0f || mirror_tolerance > 1.0f))) {
error = "PAINT_SCHEMA_INVALID: vertex weight limit or mirror options are outside the bounded domain";
return false;
}
std::unordered_set<uint32_t> seen_indices;
for (const uint32_t index : indices) {
if (!seen_indices.insert(index).second) {
error = "PAINT_SCHEMA_INVALID: vertex weight patch contains duplicate vertex indices";
return false;
}
}
std::unordered_map<uint32_t, float> updates;
updates.reserve(indices.size() * (mirror ? 2 : 1));
for (size_t index = 0; index < indices.size(); index++) updates.emplace(indices[index], values[index]);
if (mirror) {
/* Build a reciprocal local-coordinate symmetry map before mutating Main. */
const Span<float3> positions = mesh->vert_positions();
if (positions.size() > 100000) {
error = "PAINT_BUDGET_EXCEEDED: verified weight mirror is limited to 100000 vertices";
return false;
}
auto cell_key = [](const float3 &position, const float cell_size, const int dx = 0,
const int dy = 0, const int dz = 0) {
const int64_t x = int64_t(std::llround(position.x / cell_size)) + dx;
const int64_t y = int64_t(std::llround(position.y / cell_size)) + dy;
const int64_t z = int64_t(std::llround(position.z / cell_size)) + dz;
return std::to_string(x) + ":" + std::to_string(y) + ":" + std::to_string(z);
};
std::unordered_map<std::string, std::vector<int32_t>> buckets;
buckets.reserve(positions.size());
for (int64_t index = 0; index < int64_t(positions.size()); index++) {
buckets[cell_key(positions[index], mirror_tolerance)].push_back(int32_t(index));
}
std::vector<int32_t> mirror_vertices(positions.size(), -1);
for (int64_t source = 0; source < int64_t(positions.size()); source++) {
const float3 reflected = [&]() {
float3 value = positions[source];
value[mirror_axis] = -value[mirror_axis];
return value;
}();
float best_distance = std::numeric_limits<float>::max();
int32_t best = -1;
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
for (int dz = -1; dz <= 1; dz++) {
const auto bucket = buckets.find(cell_key(reflected, mirror_tolerance, dx, dy, dz));
if (bucket == buckets.end()) continue;
for (const int32_t candidate : bucket->second) {
const float3 delta = positions[candidate] - reflected;
const float distance = std::sqrt(delta.x * delta.x + delta.y * delta.y + delta.z * delta.z);
if (distance < best_distance || (distance == best_distance && candidate < best)) {
best_distance = distance;
best = candidate;
}
}
}
}
}
if (best < 0 || best_distance > mirror_tolerance) {
error = "CAPABILITY_MISSING: WEIGHT_MIRROR_SYMMETRY_UNVERIFIED";
return false;
}
mirror_vertices[source] = best;
}
for (size_t source = 0; source < mirror_vertices.size(); source++) {
const int32_t target = mirror_vertices[source];
if (target < 0 || mirror_vertices[size_t(target)] != int32_t(source)) {
error = "CAPABILITY_MISSING: WEIGHT_MIRROR_SYMMETRY_UNVERIFIED";
return false;
}
}
for (const auto &[source, value] : std::vector<std::pair<uint32_t, float>>(updates.begin(), updates.end())) {
const uint32_t target = uint32_t(mirror_vertices[source]);
const auto existing = updates.find(target);
if (existing != updates.end() && std::abs(existing->second - value) > 1e-6f) {
error = "PAINT_SCHEMA_INVALID: mirrored weight patch contains conflicting values";
return false;
}
updates[target] = value;
}
}
int group_index = BKE_object_defgroup_name_index(object, vertex_group);
if (group_index < 0) {
if (BKE_object_defgroup_add_name(object, vertex_group) == nullptr) {
@@ -4070,15 +4656,35 @@ bool web_engine_blend_main_set_vertex_weights(WebBlendMainState *state,
group_index = BKE_object_defgroup_name_index(object, vertex_group);
}
MutableSpan<MDeformVert> deform_verts = mesh->deform_verts_for_write();
for (size_t index = 0; index < indices.size(); index++) {
MDeformVert &deform_vert = deform_verts[indices[index]];
if (values[index] == 0.0f) {
std::vector<uint32_t> touched_vertices;
touched_vertices.reserve(updates.size());
for (const auto &[vertex_index, value] : updates) {
MDeformVert &deform_vert = deform_verts[vertex_index];
if (value == 0.0f) {
if (MDeformWeight *weight = BKE_defvert_find_index(&deform_vert, group_index)) {
BKE_defvert_remove_group(&deform_vert, weight);
}
}
else {
BKE_defvert_ensure_index(&deform_vert, group_index)->weight = values[index];
BKE_defvert_ensure_index(&deform_vert, group_index)->weight = value;
}
touched_vertices.push_back(vertex_index);
}
for (const uint32_t vertex_index : touched_vertices) {
MDeformVert &deform_vert = deform_verts[vertex_index];
if (limit > 0) {
while (deform_vert.totweight > int(limit)) {
int remove_index = 0;
for (int index = 1; index < deform_vert.totweight; index++) {
const MDeformWeight &candidate = deform_vert.dw[index];
const MDeformWeight &current = deform_vert.dw[remove_index];
if (candidate.weight < current.weight ||
(candidate.weight == current.weight && candidate.def_nr > current.def_nr)) {
remove_index = index;
}
}
BKE_defvert_remove_group(&deform_vert, &deform_vert.dw[remove_index]);
}
}
if (normalize) BKE_defvert_normalize(deform_vert);
}

View File

@@ -346,6 +346,12 @@ bool web_engine_blend_main_set_font_advanced(WebBlendMainState *state,
const std::vector<WebFontTextBoxEdit> &text_boxes,
int active_text_box,
std::string &error);
bool web_engine_blend_main_import_vfont(WebBlendMainState *state,
const char *name,
const char *source_path,
const char *base64,
std::string &font_id,
std::string &error);
bool web_engine_blend_main_set_font_links(WebBlendMainState *state,
const char *data_id,
const std::array<std::string, 4> &font_ids,
@@ -361,6 +367,9 @@ bool web_engine_blend_main_set_metaball_elements(WebBlendMainState *state,
bool web_engine_blend_main_grease_pencils_json(WebBlendMainState *state,
std::string &grease_pencils_json,
std::string &error);
bool web_engine_blend_main_geometry_node_graphs_json(WebBlendMainState *state,
std::string &geometry_node_graphs_json,
std::string &error);
bool web_engine_blend_main_physics_simulation_json(WebBlendMainState *state,
std::string &physics_json,
std::string &error);
@@ -377,6 +386,13 @@ bool web_engine_blend_main_move_grease_pencil_layer(WebBlendMainState *state,
const char *layer_id,
const char *direction,
std::string &error);
bool web_engine_blend_main_move_grease_pencil_frame(WebBlendMainState *state,
const char *data_id,
const char *layer_id,
int frame,
int target_frame,
const char *drawing_id,
std::string &error);
bool web_engine_blend_main_insert_grease_pencil_frame(WebBlendMainState *state,
const char *data_id,
const char *layer_id,
@@ -407,7 +423,10 @@ bool web_engine_blend_main_set_vertex_weights(WebBlendMainState *state,
const std::vector<uint32_t> &indices,
const std::vector<float> &values,
bool normalize,
uint32_t limit,
bool mirror,
int mirror_axis,
float mirror_tolerance,
std::string &error);
bool web_engine_blend_main_set_render_properties(WebBlendMainState *state,
const char *target_id,

View File

@@ -1,6 +1,6 @@
# Web Blender V1 当前执行计划
更新时间2026-08-15
更新时间2026-08-17
## 1. 交付目标
@@ -124,12 +124,13 @@ Linux archive SHA-256 为 `96f6c181a30f4950607839dc84d42a354b250d8a0231b098b59b7
### 4.4 当前阻断与决策顺序
1. `P0`V1 可部署 RC 已完成 M6 71/71M7 已完成 7/18下一领取点`M7-08` Worker 崩溃恢复。
1. `P0`V1 可部署 RC 已完成 M6 71/71M7 核心体验硬化已完成 18/18后续领取点
`后续工作.txt` 的机器队列记录为准。
2. `P1`binary/source archive 已在独立目录复验quick/Chromium/release 三条
CI lane 与 RC manifest 已联合校验;发布/限制/恢复和运维合同均已进入离线包。
3. `P1`pthread/single-thread、真实 HTTP、缓存升级、归档离线、OPFS 断网和空目录
deploy/upgrade/rollback 均已有可复验证据M7 开始核心项目体验硬化。
4. `P2`:完整 Blender 功能域继续受 VDB demand paging、PBVH、Geometry Nodes evaluator、
deploy/upgrade/rollback 均已有可复验证据M7 项目体验硬化已完成并进入持续回归
4. `P2`:完整 Blender 功能域继续受 VDB depth composition/完整 Volume IO、PBVH、Geometry Nodes evaluator、
通用 Shader、完整编辑器/渲染/媒体和安全脚本边界阻断。
### 4.5 真实发布缺口
@@ -151,18 +152,19 @@ loss、恶意 blend、zip bomb、离线包和 V1 用户闭环均已在最终 M5
| M4 OOM | 四类确定性内存故障与恢复证据 | 已完成,持续回归 | `faults.oom=true` |
| M5 V1 RC | 可复现离线候选包、SBOM、校验和 | 已完成,持续回归 | release gate `READY`、49/49 acceptance |
| M6 可部署 RC | 双 WASM 运行时选择、真实 HTTP、离线包、CI、运维合同 | 已完成71/71 | single/pthread 可解释,真实部署与归档 P0 通过 |
| M7 核心体验硬化 | 项目状态机、恢复、存储、输入和可访问性 | 进行中7/18 | 30 分钟编辑/保存/重开 soak 通过 |
| M8 VDB 自动分页 | GPU 缺页反馈、range/OPFS、LRU、双视口恢复 | 未开始 | 64 MiB sparse bundle 与三视角 golden 通过 |
| M9 非 Mesh/GP/Paint | 字体、Curve、Grease Pencil、Paint 增量闭环 | 未开始 | 每个新增 writer 独立通过 Main/undo/save/golden |
| M10 GN/Shader/NLA/Simulation | 白名单求值、cache、编译与阻断 | 未开始 | 四域分别通过 desktop/WASM/fault 门 |
| M11 Render/Compositor/Media | 灯光、渲染、合成、媒体执行边界 | 未开始 | 本地白名单与 server 边界均可审计 |
| M7 核心体验硬化 | 项目状态机、恢复、存储、输入和可访问性 | 已完成18/18 | 30 分钟编辑/保存/重开 soak 通过 |
| M8 VDB 自动分页 | GPU 缺页反馈、range/OPFS、LRU、双视口恢复 | 已完成20/20 | 联合重开与 desktop/main/Offscreen 三轴 golden 通过 |
| M9 非 Mesh/GP/Paint | 字体、Curve、Grease Pencil、Paint 增量闭环 | 已完成14/14 | 每个新增 writer 独立通过 Main/undo/save/golden;三域故障恢复闭环通过 |
| M10 GN/Shader/NLA/Simulation | 白名单求值、cache、编译与阻断 | 已完成15/15 | 四域分别通过 desktop/WASM/fault 门 |
| M11 Render/Compositor/Media | 灯光、渲染、合成、媒体执行边界 | 进行中13/14 | 本地白名单与 server 边界均可审计 |
| M12 Asset/IO/Editors | 资产、格式、编辑器和上下文工作流 | 未开始 | 每个格式/editor 有独立 round-trip 或稳定阻断 |
| M13 Scripting/Security | 脚本默认拒绝、服务端隔离、CSP、供应链 | 未开始 | 恶意输入矩阵和 release 安全门通过 |
| M14 跨浏览器/设备 | Firefox、WebKit、触控、笔、HiDPI、IME | 未开始 | 新浏览器进入 quick/P0/full CI 后才声明支持 |
| M15 全域审计 | Blender 5.2 全域差距和下一发布 | 未开始 | 逐 family 审计,不由聚合 release gate 反推完成 |
M0 至 M5 已完成并转入持续回归;当前严格领取 M6。里程碑内部允许先写纯校验任务但不得在
前一小里程碑失败时把后一小里程碑标为完成M7 至 M15 不得绕过 M6 发布合同扩功能。
M0 至 M7 已完成并转入持续回归;后续严格读取机器队列最新 `nextTask`。里程碑内部允许先写
纯校验任务,但不得在前一小里程碑失败时把后一小里程碑标为完成M8 至 M15 不得绕过 M6/M7
发布与体验合同扩功能。
## 6. M0 范围、状态和 P0 闭环
@@ -555,7 +557,8 @@ SBOM、状态和 evidence SHA-256 相互一致。
M5 最终 archive SHA-256binary
`f2903a75026a0a28615aeb5f4834a3859af83022c82bc50e28437dcbed68e549`source
`9a9c1b505c68a618de842be7c248f628b104b11b80e8a40d111f62540fcba327`当前下一任务:`M7-08`
`9a9c1b505c68a618de842be7c248f628b104b11b80e8a40d111f62540fcba327`后续领取点只读取机器队列
最新 `nextTask`,本段不缓存任务名称。
## 12. V1 后原子里程碑
@@ -716,91 +719,107 @@ CI 可从 lockfile 和对应源码重现当前 binary/source hash。
- [x] `M7-05` save 中断时旧 revision 和旧 hash 保持不变。
- [x] `M7-06` dirty state 只由成功 Main transaction 改变。
- [x] `M7-07` undo 回到保存 revision 时 dirty state 清除。
- [ ] `M7-08` Worker 崩溃时展示可恢复错误,不清空当前项目列表。
- [ ] `M7-09` 重启 Worker 后恢复 selection、frame、workspace 和项目 revision。
- [ ] `M7-10` recent projects 对缺失/损坏 OPFS 条目执行隔离和修复提示。
- [ ] `M7-11` 存储预算面板报告项目、快照、LOD、媒体、VDB 分项字节。
- [ ] `M7-12` 清理单个项目时只删除该项目 content-addressed 引用的孤儿资源。
- [ ] `M7-13` 键盘焦点、菜单、modal、Escape 取消形成一致上下文规则。
- [ ] `M7-14` 1440x900、1280x720、移动窄屏下无控制重叠和文本溢出。
- [ ] `M7-15` 主线程与 Offscreen 视口的选择、相机和 gizmo 行为一致。
- [ ] `M7-16` P0 用户流程增加无鼠标键盘路径和基础可访问性检查。
- [ ] `M7-17` 用户可见错误只显示稳定短消息,详细诊断进入可导出报告。
- [ ] `M7-18` 完成 30 分钟持续编辑、自动保存、重开 soak 测试。
- [x] `M7-08` Worker 崩溃时展示可恢复错误,不清空当前项目列表。
- [x] `M7-09` 重启 Worker 后恢复 selection、frame、workspace 和项目 revision。
- [x] `M7-10` recent projects 对缺失/损坏 OPFS 条目执行隔离和修复提示。
- [x] `M7-11` 存储预算面板报告项目、快照、LOD、媒体、VDB 分项字节。
- [x] `M7-12` 清理单个项目时只删除该项目 content-addressed 引用的孤儿资源。
- [x] `M7-13` 键盘焦点、菜单、modal、Escape 取消形成一致上下文规则。
- [x] `M7-14` 1440x900、1280x720、移动窄屏下无控制重叠和文本溢出。
- [x] `M7-15` 主线程与 Offscreen 视口的选择、相机和 gizmo 行为一致。
- [x] `M7-16` P0 用户流程增加无鼠标键盘路径和基础可访问性检查。
- [x] `M7-17` 用户可见错误只显示稳定短消息,详细诊断进入可导出报告。
- [x] `M7-18` 完成 30 分钟持续编辑、自动保存、重开 soak 测试。
### M8 VDB 自动分页最小闭环
- [ ] `M8-01` 定义 GPU page-fault feedback buffer schema、容量和 overflow code。
- [ ] `M8-02` shader 对未驻留 leaf 记录唯一 page ID不越界写 feedback。
- [ ] `M8-03` CPU 读取 feedback 后排序、去重并绑定 render revision。
- [ ] `M8-04` stale frame feedback 不触发 I/O。
- [ ] `M8-05` page request 使用现有 manifest range/hash不建立第二套地址模型。
- [ ] `M8-06` 同页并发请求合并,取消最后一个订阅者时中止 range。
- [ ] `M8-07` page hash 错误不写 resident cache并返回稳定错误。
- [ ] `M8-08` LRU 淘汰跳过当前 frame pin 的 page。
- [ ] `M8-09` page upload 成功后只安排一次渐进重绘。
- [ ] `M8-10` 重绘上限防止坏数据造成无限 render loop。
- [ ] `M8-11` 主线程 WebGPU 验证缺页、加载、重绘到确定性像素。
- [ ] `M8-12` Offscreen Worker 验证相同页序列和最终像素。
- [ ] `M8-13` page fetch 中 network interruption 可从精确 byte offset 续传。
- [ ] `M8-14` Worker restart 从 OPFS 恢复 manifest但不信任未复验 resident 状态。
- [ ] `M8-15` device loss 重建 page table并按可见集合有界回放。
- [ ] `M8-16` OOM 释放 page table/resident/feedback buffer 各一次。
- [ ] `M8-17` 64 MiB sparse bundle 性能与取消门。
- [ ] `M8-18` Volume Main、asset binding、双视口联合保存重开。
- [ ] `M8-19` desktop/主线程/Offscreen 三视角 golden 和误差阈值。
- [ ] `M8-20` 只有 M8 全部通过后更新 N-015 对应 sliceN-015 全域仍不自动 COMPLETE。
- [x] `M8-01` 定义 GPU page-fault feedback buffer schema、容量和 overflow code。
- [x] `M8-02` shader 对未驻留 leaf 记录唯一 page ID不越界写 feedback。
- [x] `M8-03` CPU 读取 feedback 后排序、去重并绑定 render revision。
- [x] `M8-04` stale frame feedback 不触发 I/O。
- [x] `M8-05` page request 使用现有 manifest range/hash不建立第二套地址模型。
- [x] `M8-06` 同页并发请求合并,取消最后一个订阅者时中止 range。
- [x] `M8-07` page hash 错误不写 resident cache并返回稳定错误。
- [x] `M8-08` LRU 淘汰跳过当前 frame pin 的 page。
- [x] `M8-09` page upload 成功后只安排一次渐进重绘。
- [x] `M8-10` 重绘上限防止坏数据造成无限 render loop。
- [x] `M8-11` 主线程 WebGPU 验证缺页、加载、重绘到确定性像素。
- [x] `M8-12` Offscreen Worker 验证相同页序列和最终像素。
- [x] `M8-13` page fetch 中 network interruption 可从精确 byte offset 续传。
- [x] `M8-14` Worker restart 从 OPFS 恢复 manifest但不信任未复验 resident 状态。
- [x] `M8-15` device loss 重建 page table并按可见集合有界回放。
- [x] `M8-16` OOM 释放 page table/resident/feedback buffer 各一次。
- [x] `M8-17` 64 MiB sparse bundle 性能与取消门。
- [x] `M8-18` Volume Main、asset binding、双视口联合保存重开。
- [x] `M8-19` desktop/主线程/Offscreen 三视角 golden 和误差阈值。
- [x] `M8-20` 只有 M8 全部通过后更新 N-015 对应 sliceN-015 全域仍不自动 COMPLETE。
### M9 非 Mesh、Grease Pencil 与 Paint 增量
- [ ] `M9-01` 外部字体导入先做路径、类型、大小和 hash 校验。
- [ ] `M9-02` 字体进入 OPFS content-addressed asset 后才写 Main VFont。
- [ ] `M9-03` 字体替换/撤销/保存/重开/缺失资产形成闭环。
- [ ] `M9-04` Curve 完整 topology editor 先冻结 operator 白名单和预算。
- [ ] `M9-05` 每个新增 Curve operator 单独完成 Main/undo/save/golden 后再开放 UI。
- [ ] `M9-06` Grease Pencil marquee 只操作当前 drawing 的稳定 point/stroke ID。
- [ ] `M9-07` Grease Pencil 2D canvas 与 3D viewport 使用同一 selection revision。
- [ ] `M9-08` Grease Pencil layer/frame reorder 完成 undo/save/reopen。
- [ ] `M9-09` Paint 建立真实深度可见性采样,不用 CPU proxy 冒充。
- [ ] `M9-10` Paint stroke 分块提交且一次 pointer session 只产生一个 undo step。
- [ ] `M9-11` Texture paint dirty tile 原子写入 packed/UDIM 资产。
- [ ] `M9-12` Weight paint normalize/limit/mirror 与 Blender desktop golden 对比
- [ ] `M9-13` PBVH 若不进入 WASM则相关笔刷保持明确 capability block
- [ ] `M9-14` 三个编辑域分别通过 Worker restart、OOM、GPU release 和小场景恢复
- [x] `M9-01` 外部字体导入先做路径、类型、大小和 hash 校验。
- [x] `M9-02` 字体进入 OPFS content-addressed asset 后才写 Main VFont。
- [x] `M9-03` 字体替换/撤销/保存/重开/缺失资产形成闭环。
- [x] `M9-04` Curve 完整 topology editor 先冻结 operator 白名单和预算。
- [x] `M9-05` 每个新增 Curve operator 单独完成 Main/undo/save/golden 后再开放 UI。
- [x] `M9-06` Grease Pencil marquee 只操作当前 drawing 的稳定 point/stroke ID。
- [x] `M9-07` Grease Pencil 2D canvas 与 3D viewport 使用同一 selection revision。
- [x] `M9-08` Grease Pencil layer/frame reorder 完成 undo/save/reopen。
- [x] `M9-09` Paint 建立真实深度可见性采样,不用 CPU proxy 冒充。
- [x] `M9-10` Paint stroke 分块提交且一次 pointer session 只产生一个 undo step。
- [x] `M9-11` Texture paint dirty tile 原子写入 packed/UDIM 资产。
- [x] `M9-12` Weight paint normalize/limit/mirror 与 Blender 5.2 desktop golden 对比;镜像仅对
复核后的局部坐标对称映射放行,限权后按 Blender 语义归一化
- [x] `M9-13` PBVH 若不进入 WASM则相关笔刷保持明确 capability block
- [x] `M9-14` 三个编辑域分别通过 Worker restart、OOM、GPU release 和小场景恢复。
### M10 Geometry Nodes、Shader、NLA 与 Simulation
### M10 Geometry Nodes、Shader、NLA 与 Simulation15/15
- [ ] `M10-01` 从 Blender Main 读取图拓扑、socket 默认值、link 和稳定 node ID。
- [ ] `M10-02` 为 Geometry Nodes 建立 allowlist不支持节点保留原数据并阻断求值。
- [ ] `M10-03` 每个 allowlist 节点增加 desktop fixture、WASM 输出和误差阈值。
- [ ] `M10-04` field/domain 转换显式预算,禁止按无限域展开 JSON。
- [ ] `M10-05` Simulation Zone cache 绑定 graph/source/revision hash。
- [ ] `M10-06` cache 取消、LRU、重启和损坏隔离通过后才允许播放。
- [ ] `M10-07` Shader 图只编译声明的 Principled/Image/Normal/Math 子集。
- [ ] `M10-08` shader compile key 包含 graph、texture、color space 和 renderer backend。
- [ ] `M10-09` compile failure 不替换上一份可用 material pipeline。
- [ ] `M10-10` 任意未支持 Shader 节点返回 stable capability block不静默降级。
- [ ] `M10-11` NLA track/strip/action/time mapping 先完成只读精确求值。
- [ ] `M10-12` NLA 单个编辑 operator 分别完成 Main transaction、undo 和保存重开。
- [ ] `M10-13` Physics 本地 solver 按 family 探测;不支持项只消费 desktop/server bake。
- [ ] `M10-14` 每类 cache 验证 source hash、frame range、字节预算和版本。
- [ ] `M10-15` GN/Shader/NLA/Simulation 分别建立浏览器性能、OOM 和恶意图输入门。
- [x] `M10-01` 从 Blender Main 读取图拓扑、socket 默认值、link 和稳定 node ID。
- [x] `M10-02` 为 Geometry Nodes 建立 allowlist不支持节点保留原数据并阻断求值。
- [x] `M10-03` 每个 allowlist 节点增加 desktop fixture、WASM 输出和误差阈值。
- [x] `M10-04` field/domain 转换显式预算,禁止按无限域展开 JSON。
- [x] `M10-05` Simulation Zone cache 绑定 graph/source/revision hash。
- [x] `M10-06` cache 取消、LRU、重启和损坏隔离通过后才允许播放。
- [x] `M10-07` Shader 图只编译声明的 Principled/Image/Normal/Math 子集。
- [x] `M10-08` shader compile key 包含 graph、texture、color space 和 renderer backend。
- [x] `M10-09` compile failure 不替换上一份可用 material pipeline。
- [x] `M10-10` 任意未支持 Shader 节点返回 stable capability block不静默降级。
- [x] `M10-11` NLA track/strip/action/time mapping 先完成只读精确求值。
- [x] `M10-12` NLA 单个编辑 operator 分别完成 Main transaction、undo 和保存重开。
- [x] `M10-13` Physics 本地 solver 按 family 探测;不支持项只消费 desktop/server bake。
- [x] `M10-14` 每类 cache 验证 source hash、frame range、字节预算和版本。
- [x] `M10-15` GN/Shader/NLA/Simulation 分别建立浏览器性能、OOM 和恶意图输入门。
### M11 Lighting、Render、Compositor 与 Sequencer
### M11 Lighting、Render、Compositor 与 Sequencer13/14
- [ ] `M11-01` Camera/Light/World/Scene color management 建立字段级 parity 表。
- [ ] `M11-02` 每个支持字段通过 Main edit、undo、save/reopen 和 viewport 映射。
- [ ] `M11-03` Three/WebGPU 灯光数量、shadow map 和纹理预算显式化。
- [ ] `M11-04` Web 实时渲染结果与 Blender reference 采用可解释图像误差指标。
- [ ] `M11-05` Cycles/复杂 Eevee/硬件后端固定为 server job不在浏览器伪造等价。
- [ ] `M11-06` server render job 绑定 source hash、Blender build、设置和输出 hash
- [ ] `M11-07` Compositor allowlist 每新增一个 node 单独增加 CPU/WebGPU golden
- [ ] `M11-08` unsupported compositor graph 保留原图并阻断执行。
- [ ] `M11-09` Sequencer IMAGE/SOUND/MOVIE codec 由运行时 probe 决定,不按扩展名猜测
- [ ] `M11-10` long media proxy/cache 绑定源 hash 和 decode capability。
- [ ] `M11-11` seek/scrub/decode 迟到结果全部受 revision gate 控制。
- [ ] `M11-12` 最终视频编码固定 server/export capability不误报本地完成
- [ ] `M11-13` audio context suspend/resume、设备缺失和静音恢复有专项测试。
- [x] `M11-01` Camera/Light/World/Scene color management 建立字段级 parity 表。
- [x] `M11-02` 每个支持字段通过 Main edit、undo、save/reopen 和 viewport 映射。
- [x] `M11-03` Three/WebGPU 灯光数量、shadow map 和纹理预算显式化。
- [x] `M11-04` Web 实时渲染结果与 Blender reference 采用可解释图像误差指标;固定 SRGB8/STRAIGHT RGBA、MAE/RMS/P95/坏像素/前景 IoU/alpha 覆盖率,主线程与 Offscreen 共享同一 256×256 Blender 5.2 Eevee fixture golden
- [x] `M11-05` Cycles/复杂 Eevee/硬件后端固定为 server job不在浏览器伪造等价schema 1 将 bounded Eevee/WebGL2 与 server target 分离endpoint 未配置时稳定 fail-closed
- [x] `M11-06` server render job 绑定 source `.blend` hash、Blender 5.2 build hash、规范化设置 hash 和实际输出 hash本地 loopback server 由 Blender 5.2 headless 执行 fixture rendersource/settings/build/output 篡改均 fail-closed
- [x] `M11-07` Compositor allowlist 每新增一个 node 单独增加 CPU/WebGPU golden;当前仅冻结
Constant Color、Exposure、Invert、Composite 的线性 sRGB Float32 有界链,真实 Blender 5.2
Main fixture 在 CPU 与 Chromium WebGPU compute 上逐节点 hash 等价,其余节点仍阻断
- [x] `M11-08` unsupported compositor graph 保留原图并阻断执行CPU 与 cached 入口都在
求值/缓存命中前扫描完整图,未连接到输出的 Unsupported 节点也稳定阻断,真实 Main graph、
Blender type metadata 和 revision 保持不变
- [x] `M11-09` Sequencer IMAGE/SOUND/MOVIE codec 由运行时 probe 决定,不按扩展名猜测;
receipt 绑定 strip family、MIME、字节长度和 source SHA-256Chromium 分别以 ImageBitmap、
固定采样率 Web Audio 和 HTMLMedia 实际解码 PNG/WAV/H.264 MP4。
- [x] `M11-10` long media proxy/cache 绑定源 hash 和 decode capabilityschema 1 将 source
family/MIME/bytes/SHA-256、M11-09 READY receipt、RGBA8 profile 和 source frame 共同绑定为
cache identitypayload 独立验 hashChromium 实际生成首帧 proxy 并通过 LRU/Storage Worker
重开门。
- [x] `M11-11` seek/scrub/decode 迟到结果全部受 revision gate 控制;三类请求共享单调
request revision结果必须回显 timeline/request identitytimeline 换代、旧请求和伪造结果
均在 publish/cache callback 前返回 `STALE/REVISION_CONFLICT`
- [x] `M11-12` 最终视频编码固定 server/export capability不误报本地完成schema 1 绑定
timeline/source revision、`.blend` SHA-256、帧率/范围、分辨率和 codec settings hash
endpoint 缺失时 fail-closed即使检测到 `VideoEncoder` 也保持 `localEncoding=BLOCKED`
- [x] `M11-13` audio context suspend/resume、设备缺失和静音恢复有专项测试schema 1
区分 context/output 状态,真实 Chromium `AudioContext` 通过用户手势、挂起、静音恢复、
二次挂起/恢复和 close缺设备与 resume failure 保持结构化静音阻断。
- [ ] `M11-14` render/compositor/media 全部覆盖取消、重启、预算释放和恢复。
### M12 Asset、IO、Editor 与工作流

View File

@@ -1,6 +1,6 @@
# Web Blender 项目现状与后续连续任务
更新时间2026-08-15
更新时间2026-08-17
当前短周期任务、领取顺序和阶段退出条件统一维护在
`docs/CURRENT_EXECUTION_PLAN.md`。本文件保留实现事实、长期能力台账和完整验收命令,不再作为
@@ -29,14 +29,19 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM
| --- | --- | --- | --- |
| V1 release family | 12/12 `READY` | 0 | V1 已声明功能闭环完成 |
| Blender 5.2 全域 parity | 0/12 complete | 12/12 `BLOCKED` | 不是完整 Blender Web 移植 |
| slice 台账 | 177 completed | 60 blocked | blocked 项均留在 M8-M15 长期路线 |
| slice 台账 | 191 completed | 58 blocked | blocked 项均留在 M9-M15 长期路线 |
| acceptance | 50 declarations / 49 unique passed | 0 failed | 本地 V1 RC 证据完整 |
| M6 可部署 RC | 71/71 原子任务 | 0 | 可部署 RC 已冻结 |
| M7 核心体验硬化 | 7/18 原子任务 | 11 | 当前严格领取 M7 项目体验工作 |
| M7 核心体验硬化 | 18/18 原子任务 | 0 | 已完成并进入持续回归 |
| M8 VDB 自动分页 | 20/20 原子任务 | 0 | 联合重开与 desktop/main/Offscreen 三轴 golden 通过 |
| M9 非 Mesh/GP/Paint | 14/14 原子任务 | 0 | `TOGGLE_CYCLIC` 已通过 Main/undo/save/reopen 与 Blender 5.2 goldenGP current-drawing marquee、2D/3D 共享 selection revision 及 layer/frame reorder 已通过 Main/undo/save/reopen 门Paint 主线程/Offscreen 真实 GPU depth、单 undo 分块 pointer session、normalize/limit/mirror 权重与 Blender 5.2 golden、packed/UDIM dirty tile 原子资产绑定,以及 46 项 PBVH brush 的 WASM 入口显式阻断已通过Curve、Grease Pencil、Paint 三域的 Worker restart、OOM、GPU release、小场景恢复已通过PBVH/桌面 brush 求值仍 BLOCKED |
| M10 GN/Shader/NLA/Simulation | 15/15 原子任务 | 0 | GN/Simulation cache、Shader、NLA 和 Physics 有界闭环全部完成M10-15 以四个隔离 Chromium Worker 分别通过性能、超预算/OOM-prevention、恶意输入和同会话小输入恢复门 |
| M11 Lighting/Render/Compositor/Media | 13/14 原子任务 | 1 | M11-01/02 已冻结字段 parity 并完成支持字段闭环M11-03 已让双 viewport 共用资源预算M11-04 已完成 Blender Eevee reference 图像指标M11-05/06 已完成最终渲染路由/provenanceM11-07/08 已完成有限 Compositor golden 与 Unsupported 全图阻断M11-09/10/11/12 已完成 codec/proxy/revision/export gateM11-13 已完成实时 AudioContext 恢复门 |
当前优先级不是扩 Blender 全域功能。single/pthread、真实 HTTP、缓存升级、离线闭环、
独立归档复验、运维 runbook、RC 文档和最终三条 CI lane 均已通过;现在进入 M7 核心项目
体验硬化。`M7-01``M7-07` 已通过,唯一下一任务为 `M7-08`
独立归档复验、运维 runbook、RC 文档和最终三条 CI lane 均已通过M7 核心项目体验硬化
18/18 已完成并进入持续回归M9 已完成 14/14M10 已完成 15/15 并进入持续回归M11 当前
13/14后续领取点只读取机器队列最新 `nextTask`
## 2. 已完成并有测试覆盖的能力
@@ -53,17 +58,22 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM
| Packed image | PackedFile、双 tile UDIM、generated、linked library、损坏签名、路径沙箱和 OPFS SHA-256 去重/重发现 | 已完成当前资源矩阵 |
| GLB | 本地导出、严格解析,以及 Blender 5.2 回导比较网格/PBR/纹理/morph/skin/animation | 已完成当前 SceneIR 子集 |
| Modifier/undo | modifier enable 状态、命令 revision、undo/redo 和专项 smoke | 已完成基础命令链路 |
| Geometry Nodes | Main 图 reader、16 节点 schema 1 allowlist、11 个 Blender 5.2 desktop fixture、full-Main WASM/Chromium 保存重开求值 golden以及 7 域元素/字节/批次 materialization 预算 | 已完成 M10-04 有界闭包;超过 65,536 scalar 的 JSON field 明确阻断并要求 binary任意 field/domain 实际求值、图写回和 Simulation Zone 仍阻断 |
| Mesh Edit | 点/边/面选择Merge/Dissolve/Extrude/Inset/Bevel/Loop CutMain 保存重开 | 已完成当前操作集 |
| 材质与 UV | 材质槽/面分配、UV Map、planar/cube unwrap、限定 Principled/Image/Normal 图、图片打包 | 已完成当前节点集 |
| 动画编辑 | TRS 关键帧、Timeline/Dope Sheet、插值、Action 选择和约束开关/影响 | 已完成当前操作集 |
| 对象层级 | Parent/Unparent、Collection、rename、Join/Separate、Apply Transform、Origin | 已完成当前操作集 |
| SceneIR 扩展 | tangent/split normal/seam/sharp/vertex group/attribute/UVnative WBG1、资源 Delta 和 mesh byte-range patch | 已完成当前数据契约 |
| Sculpt 属性 | Mask/Face Set 的 SceneIR、WBG1 header v2、Delta、Main、undo 和保存重开 | 已完成属性子集;有界四笔刷 Main stroke 已开放PBVH/完整求值未开放 |
| Simulation cache | Blender 5.2/source blend/hash/frame manifest、OPFS 内容寻址、schema 6、重启逐帧复验 | 已完成缓存安全层Simulation evaluator 未开放 |
| Simulation cache | Blender 5.2/source blend/hash/frame manifest、manifest schema 2 的 graph/source/revisionHash binding、OPFS 内容寻址、storage schema 7、Worker 生命周期播放准入、取消、确定性 LRU、重启全量复验和损坏 quarantine | 已完成缓存 identity/播放生命周期安全层Simulation Zone evaluator、GN modifier seek 接入仍未开放 |
| 发布性能/故障门 | Chromium E2E、1M/10M geometry、1M 帧长媒体、600 帧 OPFS cache、4K/8K texture、运行中断网、主线程 WebGL context loss、WASM/OPFS/GPU/NanoVDB 四类确定性 OOM 恢复 | V1 evidence gate 已齐Offscreen device loss 仍属后续完整故障矩阵 |
| Shader Main | RGB/Value/Principled/Image Texture/Normal Map/Output 整图事务、资源门和保存重开 | 已完成六节点写回子集;任意图/编译仍阻断 |
| NLA Main | 单对象 Action Clip 整栈写回、SceneIR 重开和 native frame 求值 | 已完成有限 Action Clip 子集 |
| 非 Mesh 数据块 | Curve/Surface/Font/Metaball Main 权威写回与保存重开、真实 evaluated mesh、PointCloud/Curves/Hair 属性、WNM chunks、双 viewport、handle-local 连续 preview/单次 Main commit、desktop golden 与 7 对象 GLB/USD round-trip | N-015 整体 BLOCKEDVDB 的 desktop/server converter、HTTP/OPFS、Float32 WebGPU 双生产视口、有限 grid 材质、显式 GPU resident LRU、确定性 resident OOM 和 Main 属性重开已完成,自动 demand paging、联合重开、大 bundle 和发布 golden 未完成 |
| 实时渲染 reference | Blender 5.2 Eevee 固定相机/黑体/World fixtureSRGB8 MAE、RMS、P95、坏像素比例、前景 IoU、alpha 覆盖率;主线程/Offscreen 同帧 | M11-04 有界 golden 已完成;完整 AgX/高级灯光、Cycles/复杂 Eevee、Volume 深度合成仍阻断 |
| 最终渲染路由 | bounded Eevee WebGL2 可本地WebGPU 需 browser+bundle 双门Cycles、复杂 Eevee、Workbench 和桌面硬件后端只返回 `SERVER_JOB` | M11-05 路由合同已完成M11-06 server job 已绑定 source/settings/build/output hash真实远程队列、进度、取消和 denoise 仍属后续任务 |
| Compositor CPU/WebGPU golden | Constant Color、Exposure、Invert、Composite 的单链由真实 Blender 5.2 Main fixture 驱动CPU 与 Chromium WebGPU compute 输出 Float32 hash 等价 | M11-07 有界 allowlist 已完成M11-08 已让 CPU/cached 在完整图预检时阻断并保留 Unsupported Main graph资源输入、分支、其他节点、完整色彩/HDR 与生产调度仍阻断 |
| Sequencer codec/proxy/revision/export/audio gate | IMAGE/SOUND/MOVIE receipt 绑定 family、MIME、bytes 和 source hashmovie RGBA8 proxy identity 再绑定 READY receipt、profile 和 source frameSEEK/SCRUB/DECODE 共享 revision gate最终导出只路由 server实时音频显式报告 context/output/mute 状态 | M11-09..13 已完成有界解码、首帧 proxy、迟到结果、最终编码路由和真实 Chromium AudioContext suspend/resume/mute/device 门;帧精确 seek、多帧生成、waveform、A/V sync、实际混音和真正 server encode job 仍阻断 |
| Shader Main/Web compiler | RGB/Value/Math/Principled/Image Texture/Normal Map/Output 整图事务、资源门、保存重开、有界 WebGL2 Three physical compile report、身份 compile key 和失败回滚 | 已完成 M10-09 当前切片;完整色彩管理/sampler、WebGPU 和任意图仍阻断 |
| NLA Main | 单对象 Action Clip 整栈写回、SceneIR 重开和 native frame 求值M10-11 对 scale/reverse/repeat 做 12 帧 goldenM10-12 `moveNLAStrip` 通过 Main/history/save/reopen | 已完成只读精确求值与单个移动 operator 子集create/remove/resize、多轨 blend、Transition/Meta/Sound 仍阻断 |
| 非 Mesh 数据块 | Curve/Surface/Font/Metaball Main 权威写回与保存重开、真实 evaluated mesh、PointCloud/Curves/Hair 属性、WNM chunks、双 viewport、handle-local 连续 preview/单次 Main commit、desktop golden 与 7 对象 GLB/USD round-trip | N-015 整体 BLOCKEDVDB 自动 demand paging、联合重开、64 MiB sparse 门和 desktop/main/Offscreen 三轴 density golden 已完成,深度合成与完整 Volume GLB/USD/材质仍阻断 |
## 3. 部分完成或仍有边界的能力
@@ -75,8 +85,8 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM
| GLB round-trip | Web 严格解析与 Blender 5.2 importer 双门已覆盖网格、变换、PBR、PNG/sRGB、sampler、morph、双骨骼 skin 和动画帧 | 新增相机/灯光、插值和材质扩展时继续扩大独立摘要 |
| `.blend` 保存 | 场景状态、modifier、Mesh Edit、材质/UV、动画/约束和对象层级命令均写 Main历史、原子保存、快照和日志重放可恢复 | skin/shape-key decimate 保持结构化阻断 |
| Blender UI 对标 | 默认区域、当前 Mesh Edit、raycast、多选、拖拽 gizmo、层级/材质/动画面板和核心快捷键已闭环 | Sculpt、Geometry Nodes 编辑器等未声明能力继续结构化阻断 |
| 未声明能力扩展 | 四项协议/安全门均已细分Sculpt 属性/有界 Main stroke、Simulation cache、节点 Shader Main 和 Action Clip NLA Main 已有正例 | PBVH、GN lazy-function/Simulation 求值、其余 Shader Node、Web compiler、完整 NLA/UI/GLB 图映射仍 planned/结构化阻断 |
| 非 Mesh 数据块 | 1M 点 reader budget、ID_MB/PT/VO/VF 注册、Main control-point/topology/body/element/Font style link commands、一维多 spline 与批量 handle/cyclic、二维 Surface topology transaction、undo/save/reopen、真实 evaluated mesh、WNM SHA chunks、handle raycast/handle-local 连续 preview/单次 commit、7 对象 GLB/USD、Chromium quota、VDB desktop/server converter、HTTP/OPFS、有界 WebGPU 双视口、有限 grid 材质、显式 resident LRU 和 resident OOM 恢复 | 新外部字体导入,以及 VDB 自动 demand paging、联合保存重开、大 bundle 和发布 golden 仍 planned/阻断 |
| 未声明能力扩展 | 四项协议/安全门均已细分Sculpt 属性/有界 Main stroke、Simulation cache、节点 Shader Main/有界 Web compiler、compile key/rollback、未知节点稳定 capability block、Action Clip NLA Main/只读精确求值和 `moveNLAStrip` Main/history/save/reopen 已有正例 | PBVH、GN lazy-function/Simulation 求值、其余 Shader Node、完整色彩/sampler/WebGPU、完整 NLA/UI/GLB 图映射仍 planned/结构化阻断 |
| 非 Mesh 数据块 | 1M 点 reader budget、ID_MB/PT/VO/VF 注册、Main control-point/topology/body/element/Font style link commands、一维多 spline 与批量 handle/cyclic、二维 Surface topology transaction、undo/save/reopen、真实 evaluated mesh、WNM SHA chunks、handle raycast/handle-local 连续 preview/单次 commit、7 对象 GLB/USD、Chromium quota、VDB desktop/server converter、自动 demand paging、联合重开和三轴 golden | 新外部字体导入,以及 VDB 深度合成、完整 Principled Volume/GLB/USD 映射和更大真实 bundle 联合故障矩阵仍 planned/阻断 |
| 大场景 | Worker 二进制解码、per-mesh/range transferable、linked Mesh 实例化、LOD/OPFS cache、frustum culling、能力门 OffscreenCanvas Worker、100k/1M 内存门 | 超出当前 WBG1 的网络式渐进流送仍属于后续性能扩展 |
| Decimate 全对标 | Collapse 的当前网格集合已覆盖,错误路径结构化 | Un-Subdivide、Dissolve、所有 delimiter/权重/对称组合及大模型性能矩阵未完成 |
@@ -195,8 +205,8 @@ Sculpt、Geometry Nodes/Simulation、Shader Node 图和 NLA 的详细任务分
`docs/UNDECLARED_CAPABILITIES_EXECUTION_PLAN.md`。当前四项均进入
`in_progress`:四项都已超过纯协议阶段,但只开放经过 native/存储闭环验证的有限切片:
Sculpt Mask/Face Set 与有界 Main stroke、GN 外部资源门与 deterministic cache 存储、七节点
Shader Main 写回(含有限 Math常量 PBR 映射、单对象 Action Clip NLA Main/Depsgraph reverse/repeat 求值。详细完成项和 planned 项见专项计划;
这些正例不扩大到 PBVH Sculpt、任意 GN/Simulation、任意 Shader Node/Web compiler 或完整 NLA。
Shader Main 写回(含有限 Math常量 PBR 映射、M10-07 有界 Web compiler、M10-08 compile key、M10-09 pipeline rollback、单对象 Action Clip NLA Main/Depsgraph reverse/repeat 求值。详细完成项和 planned 项见专项计划;
这些正例不扩大到 PBVH Sculpt、任意 GN/Simulation、任意 Shader Node、完整色彩/sampler/WebGPU 或完整 NLA。
Blender 5.2 全域功能矩阵、浏览器/服务端边界和 N-015 至 N-026 后续顺序见
`docs/BLENDER_5_2_WEB_FEATURE_PARITY.md`
@@ -208,10 +218,15 @@ save/reopen、真实 legacy evaluated mesh、PointCloud/Curves 属性和 WNM 二
`UsdGeomMesh``UsdGeomPoints``UsdGeomBasisCurves` 的机器可读映射/损失。
VDB 已改为 desktop/server OpenVDB -> NanoVDB、浏览器分块读取 + WebGPU。真实资源 catalog、
desktop/server converter、HTTP 断点续传、OPFS 原子绑定、Float32 WebGPU 双生产视口和 Main
Volume 属性重开与 resident/page-table OOM 恢复已有真实证据GPU demand paging、联合保存重开、完整材质、大 bundle 和
desktop/Chromium 发布 golden 仍阻断。handle-local 专用 gizmo 已接通,新字体导入等剩余项按 `docs/status/N-015.md`
Volume 属性、asset binding、双视口联合重开、自动 GPU page feedback、64 MiB sparse 门与
desktop/Chromium 发布 goldenmain/Offscreen 三轴 density已有真实证据深度合成、完整材质与 Volume GLB/USD 仍阻断。handle-local 专用 gizmo 已接通,新字体导入等剩余项按 `docs/status/N-015.md`
`docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md` 推进。
M9-14 已新增 `docs/status/M9-14.md``tests/golden/M9-14/manifest.json` 和统一
`editing-domain-recovery` runner。三个真实 fixture 均完成 Main writer 后保存/Worker 重开、
token-isolated OOM 清理、WebGL2 资源释放/重建与小场景像素恢复;该闭环不扩大 N-015/N-016/N-017
对完整 Blender topology、2D Grease Pencil 或 PBVH brush 的 parity 声明。
### PBR-001 至 PBR-012 模型物理渲染(核心切片 done_current_scope资产/高级渲染安全门 in_progress
`physical-v1` 已完成扩展 Principled 参数的 Main 写回/保存重开、双后端
@@ -228,8 +243,8 @@ HDR/EXR、多 tile 采样、Offscreen context-loss、真实 WebGPU renderer、
`docs/status/release-evidence.json` 已生成 17 条成功 command/output/artifact-hash 记录;其中
VDB 专项记录覆盖真实 converter、server job、HTTP/OPFS 和有界 WebGPU 当前切片,独立 OOM
记录覆盖 WASM/Main、OPFS staging、GPU 资源组和 NanoVDB resident/page-table这些记录不能替代
demand paging、大 bundle 和发布 golden
记录覆盖 WASM/Main、OPFS staging、GPU 资源组和 NanoVDB resident/page-tableM8 后续专项已补齐
自动分页、64 MiB sparse、联合重开和 density 三轴 golden但不改写已冻结 V1 release record
发布账本现已拆分 `parityStatus``releaseStatus`N-015 至 N-026 的全域对标仍全部
`BLOCKED`,但 N-015 至 N-026 的 V1 有界切片均为 `releaseStatus=READY`,不会再因发布后能力
阻塞 V1。P0 用户闭环、10M geometry、long media 和 OOM 均有独立 command/output/artifact-hash
@@ -262,8 +277,8 @@ MIME、4 个 HEAD、WASM/BLEND/NVDB range、6 类 `416`、强 ETag、If-Range
`ENGINE_VARIANT_INTEGRITY_FAILED`,不请求 single、不 fallback、不 open。`M6-08A/B` 已用实际
production build 验证 HTML/manifest/stable engine `no-cache`、5 个 hashed JS/CSS/Worker
`immutable`,以及未换代 `304`、换代后旧 ETag 得到完整 `200` 的重验证行为。M6 最终
quick 7/7、Chromium 9/9、release 25/25 与 RC manifest 联合通过M7 已完成 7/18
下一领取点`M7-08`
quick 7/7、Chromium 9/9、release 25/25 与 RC manifest 联合通过M7 已完成 18/18
后续领取点只读取机器队列最新 `nextTask`,本文不缓存任务名称
## 6. 当前验收命令
@@ -294,6 +309,8 @@ npm --prefix web run test:capability-gates
npm --prefix web run test:simulation-cache
npm --prefix web run test:release-performance
npm --prefix web run test:simulation-cache-performance
npm --prefix web run test:render-reference
npm --prefix web run test:render-routing
npm --prefix web run test:network-interruption
npm --prefix web run test:device-loss
npm --prefix web run test:texture-4k-performance

View File

@@ -236,21 +236,22 @@ N-011 -> N-012 -> N-013 -> N-014 执行。任一任务的正例、阻断例、
| N-012-A1 | GraphIR schema、节点白名单、typed socket/link/domain 校验 | 已完成 | `geometry-nodes.ts`Worker cycle gate 已有 E2E |
| N-012-A2 | 跨 group 的递归闭包、外部 object/collection/image ID 沙箱 | 已完成 | stable ID 类型、owner cycle、linked/missing/corrupt 资源门和 E2E 已覆盖 |
| N-012-A3a | Simulation manifest/schema、Blender 版本、frame byte range | 已完成 | `simulation-cache.ts`;完整帧、连续范围、总/逐帧 SHA-256 |
| N-012-A3b | committed blend 绑定、OPFS 内容寻址、schema 6 索引和 Worker 重启 | 已完成 | `simulation_manifest` + asset SHA-256`test:simulation-cache` |
| N-012-A3b | committed blend 绑定、OPFS 内容寻址、schema 7 索引和 Worker 重启 | 已完成 | `simulation_manifest` + manifest schema 2 graph/source/revisionHash + asset SHA-256`test:simulation-cache-identity``test:simulation-cache` |
| N-012-A3b2 | cache 取消、播放准入、active playback 保护的确定性 LRU 和损坏隔离 | 已完成 | `simulation_quarantine`、Worker 生命周期 full verification、AbortSignal/cancelRequest、`test:simulation-cache-lifecycle`;不扩大 Simulation Zone evaluator |
| N-012-A3c | Blender desktop bake 生成、版本迁移、GN frame seek 消费 | planned | Simulation Zone 仍返回 `GEOMETRY_NODES_SIMULATION_UNAVAILABLE` |
| N-012-B1 | 无时间 GN lazy-function/field Main evaluator | planned | 当前合法 GraphIR 仍返回 `CAPABILITY_MISSING` |
| N-012-B1 | 无时间 GN lazy-function/field Main evaluator | 已完成有限切片 | M10-03 的自有有界 evaluator 覆盖 schema-1 allowlist完整 Blender lazy-function runtime 和任意 field/domain 仍阻断 |
| N-013-A1 | ShaderIR schema、单输出、typed socket/隐式转换、无环 | 已完成 | `shader-graph.ts`;未进入 writer 白名单的节点返回 `SHADER_NODE_UNSUPPORTED` |
| N-013-A2 | material/image stable ID 与 linked/missing/corrupt 资源门 | 已完成当前子集 | packed image 可引用linked/missing/corrupt image 在 Worker 阻断 |
| N-013-B1 | RGB/Value/Principled/Image Texture/Normal Map/Output 整图 Main transaction | 已完成当前子集 | native create/remove/link/default、history、save/reopen 回归 |
| N-013-B2a | Math 六运算 Main `custom1`、原生 socket identifier 和 operation reader | 已完成有限切片 | ADD/SUBTRACT/MULTIPLY/DIVIDE/MINIMUM/MAXIMUM其他运算阻断 |
| N-013-B2 | Mix/Mapping/TexCoord/Bump storage/property 写回 | planned | 未声明节点和 properties 仍 `SHADER_NODE_UNSUPPORTED` |
| N-013-C1a | RGB/Value 常量到 glTF PBR factor 的严格映射 | 已完成有限切片 | 缺值、越界、重复输入和未知链接返回 `SHADER_GRAPH_UNMAPPABLE`mapping E2E 覆盖 |
| N-013-C1 | 受限 Web material compiler、graph hash、色彩空间/sampler | planned | C1a 不代表 Web shader compiler;任意图仍未启用 |
| N-013-C1 | 受限 Web material compiler、graph hash、色彩空间/sampler | 已完成有限编译切片 | M10-07 发布 schema 1 compile report、M10-08 绑定 graph/texture/color-space/backend 的 compile key、M10-09 失败回滚、WebGL2 Three physical 共同消费和 fail-closed 预算;完整 sampler/WebGPU 仍 planned |
| N-014-A1 | Track/Strip schema、Action/range/blend/time-warp 校验 | 已完成 | `nla.ts`;缺失 Action 返回 `NLA_ACTION_MISSING` |
| N-014-A2 | strip 排序/重叠、owner/path compatibility、循环引用校验 | 已完成当前子集 | 同轨重叠、duration mapping、blend range 和 unsupported strip type 均阻断 |
| N-014-B1 | Action Clip 整栈替换 Main transaction、reader、保存重开 | 已完成当前子集 | `setNLAStack`Track/Strip/Action 摘要重开一致 |
| N-014-B2 | 细粒度 create/remove/move/resize/active 命令 | planned | UI 前先扩命令和 revision 回归 |
| N-014-C1 | 正 scale Action Clip native frame 求值 | 已完成当前子集 | authoritative Main bytes 进入 Blender depsgraphframe 5 矩阵回归通过 |
| N-014-B2 | 细粒度 create/remove/move/resize/active 命令 | 已完成有限切片 | M10-12 `moveNLAStrip` 通过 revision gate、单次 Main `setNLAStack` transaction、undo/redo 和 save/reopencreate/remove/resize/active 仍 planned |
| N-014-C1 | Action Clip track/action/time mapping 只读精确求值 | 已完成当前子集 | M10-11 desktop fixture 的 scale/reverse/repeat 2 个 Clip、12 帧 Main/Chromium 矩阵最大误差 0求值不改 NLA/Action identity |
| N-014-C2a | Action Clip reverse Main 标志、reader、Depsgraph、保存重开 | 已完成有限切片 | `NLASTRIP_FLAG_REVERSE`frame 5 反向矩阵回归 |
| N-014-C2b1 | Action Clip repeat 与跨周期 frame seek | 已完成有限切片 | `repeat=2`frame 5/14 native 矩阵一致 |
| N-014-C2 | blend overlap、骨骼、约束顺序、transition/meta | planned | 未声明族继续 `NLA_*` 结构化阻断 |
@@ -274,10 +275,10 @@ N-011 -> N-012 -> N-013 -> N-014 执行。任一任务的正例、阻断例、
| GN/Simulation 切片 | 交付与验收 | 状态 |
| --- | --- | --- |
| N-012-B0 | wasm32 lazy-function/field runtime feature probe、超时和内存上限 | planned/阻断 |
| N-012-B1a | Group Input/Output + Transform Geometry 最小闭包,输出 topology/attributes/bounds | planned依赖 B0 |
| N-012-B1b | Set Position、Math/Compare/Selection field 及 point/edge/face/corner 域转换 | planned,依赖 B1a |
| N-012-B1c | Join/Separate/Realize Instances 和 instance stable ID/transform | planned,依赖 B1b |
| N-012-B1d | Store/Named Attribute 生命周期、anonymous attribute 泄漏和 deterministic graph hash | planned,依赖 B1c |
| N-012-B1a | Group Input/Output + Transform Geometry 最小闭包,输出 topology/attributes/bounds | 已完成有限切片 | M10-03 desktop/full-Main WASM golden不是完整 lazy-function runtime |
| N-012-B1b | Set Position、Math/Compare/Selection field 及 point/edge/face/corner 域转换 | 部分完成 | Set Position/Math/Compare 常量闭包和 M10-04 七域预算已完成Selection field 与真实跨域转换仍 planned |
| N-012-B1c | Join/Separate/Realize Instances 和 instance stable ID/transform | 已完成有限切片 | Join/Separate/Collection Realize golden 已通过;任意 instance 属性/ID 传播仍 planned |
| N-012-B1d | Store/Named Attribute 生命周期、anonymous attribute 泄漏和 deterministic graph hash | 部分完成 | constant point Float Store Named Attribute 和有界回执已通过Named/anonymous attribute 完整生命周期仍 planned |
| N-012-D1 | desktop bake 生成器、Blender 版本迁移和逐帧 cache 消费 | planned依赖现有 A3b |
| N-012-D2 | Simulation Zone wasm frame step、状态所有权和 frame seek feature probe | planned/阻断,依赖 B1d/D1 |
| N-012-D3 | 浏览器 bake start/cancel/commit 原子事务;中断时不发布半成品 manifest | planned依赖 D2 |
@@ -285,11 +286,11 @@ N-011 -> N-012 -> N-013 -> N-014 执行。任一任务的正例、阻断例、
| Shader 切片 | 交付与验收 | 状态 |
| --- | --- | --- |
| N-013-B2a | Math 白名单逐运算 Main 写回和 operation readerGLB/Web 编译仍阻断 | 已完成有限切片 |
| N-013-B2a | Math 白名单逐运算 Main 写回和 operation reader完整 GLB/Web 编译仍阻断 | 已完成有限切片M10-07 已消费六项常量 Math任意 field/动态输入仍阻断 |
| N-013-B2b | Mix 的 data type、factor clamp 和颜色/向量 socket 变体 Main 写回 | planned依赖 B2a |
| N-013-B2c | Texture Coordinate -> Mapping -> Image TextureUV 名称/sampler/色彩空间闭环 | planned依赖 B2b |
| N-013-B2d | Normal Map/Bump 串联、强度/距离、切线前置条件和 desktop golden | planned依赖 B2c |
| N-013-C1b | 受限 Web compiler IR、graph hash cache、未知节点 fail-closed | planned依赖 B2d |
| N-013-C1b | 受限 Web compiler IR、graph hash binding、未知节点 fail-closed | 已完成有限切片 | M10-07 只覆盖 RGB/Value/Math/Image/Normal/Principled/Output 与 WebGL2 Three physicalM10-08/M10-09 完成 graph/asset compile key 与失败回滚M10-10 完成任意未知节点稳定 capability block |
| N-013-C1c | sRGB/Non-Color、alpha、sampler、normal/tangent 和 WebGL/WebGPU 编译报告 | planned依赖 C1b |
| N-013-D1 | 可证明 PBR 闭包的纹理/常量完整映射;其余保持 `SHADER_GRAPH_UNMAPPABLE` | plannedC1a 已覆盖常量子集 |
| N-013-D2 | 桌面 Blender GLB 再导入,比较 factor、节点摘要、图片、sampler 与渲染摘要 | planned依赖 D1 |
@@ -315,4 +316,5 @@ npm --prefix web run typecheck
WEB_TEST_PORT=5200 npm --prefix web run test:capability-gates
npm --prefix web run test:simulation-cache
npm --prefix web run test:authoring-roundtrip
npm --prefix web run test:nla-evaluation-golden
```

View File

@@ -199,9 +199,9 @@ transmittance termination 和 NaN 防护。shader 不能通过越界 buffer read
- `VDB-041`density/temperature/color/emission/anisotropy 的 Main writer、undo/redo 和原子事务。
状态:`in_progress`Volume source/display/interpolation/step/velocity Main 写回、undo/redo、保存重开
已完成Principled Volume 完整节点事务仍阻断。
- `VDB-042`:保存 `.blend` 与 Web asset binding清空 Worker 后重开并重新流送。`BLOCKED`
状态:`in_progress`Main Volume 保存重开与 OPFS asset binding/Worker 重开分别通过,尚待生产视口
联合重开闭环
- `VDB-042`:保存 `.blend` 与 Web asset binding清空 Worker 后重开并重新流送。
状态:`done_current_scope`Main VolumeOPFS asset bindingWorker 重建与主线程/Offscreen
两个生产视口已在同一 revision/hash 闭环联合重开。
- `VDB-043`GLB 明确报告 Volume 无核心映射USD 仅在 desktop USD/OpenVDB 路径真实可用时
写入 field asset不把 bounds proxy 当体积导出。`BLOCKED`
@@ -209,8 +209,11 @@ transmittance termination 和 NaN 防护。shader 不能通过越界 buffer read
- `VDB-050`desktop OpenVDB -> NanoVDB grid count/name/type/transform/bounds/value sample golden。
- `VDB-051`Blender 5.2 与 Chromium WebGPU 的至少 3 个视角像素 golden分别比较 alpha、
transmittance、color记录容差与色彩空间。
transmittance、color记录容差与色彩空间。状态:`done_current_scope`OpenVDB 13 native
density reference 与主线程/Offscreen WebGPU 的 X/Y/Z 三轴 RGBA8 逐通道比较均为零误差。
- `VDB-052`64 MiB/512 MiB/1 GiB sparse bundle 的首帧、渐进清晰、峰值 CPU/GPU 和取消门。
状态:`in_progress`64 MiB 实际 range、4 MiB 峰值工作集与中途取消已通过512 MiB/1 GiB
仍按设备和 CI 预算保持阻断。
- `VDB-053`:损坏 magic/version/tree offset/hash、zip bomb 等价超预算、NaN transform、设备丢失、
网络中断和 OOM 门。状态:`in_progress`resident/page-table 确定性 OOM、唯一释放和同设备
小 resident grid 恢复已完成,其余大 bundle 与联合故障矩阵仍阻断。

51
docs/status/M10-01.md Normal file
View File

@@ -0,0 +1,51 @@
# M10-01 Status
status: done
task: 从 Blender Main 读取 Geometry Nodes 图拓扑、socket 默认值、link 和稳定 node ID
updated: 2026-08-16 America/New_York
## Scope
The WebEngine authoritative Main reader publishes a bounded `GeometryNodeGraphIR`
snapshot for every `GeometryNodeTree`. It preserves unsupported nodes as metadata,
uses Blender node identifiers and socket identifiers rather than display names, and
does not evaluate Geometry Nodes. The existing `web/protocol/schema-version` remains
`1`: `geometryNodeGraphs` is an additive optional SceneIR field and no existing field
meaning or persisted storage schema changed.
## Evidence
- Fixture: `tests/files/web/modifier_geometry_nodes_scene.blend`.
- Fixture SHA-256: `f3820511f791769837d75be092934130c397cf46ee0acf79db28efca9a7948c9`.
- Desktop reference: `tests/golden/W-075/modifier_geometry_nodes_scene.json`.
- Desktop reference SHA-256: `93c21e5158daefce10f2d4df674e636bf3f6ed3dd43e1576c45a02a8592f5886`.
- Golden manifest: `tests/golden/M10-01/geometry-node-main-reader.json`.
- Protocol/unit check: `node --test web/tests/unit/geometry-nodes.test.mjs` (3/3).
- Native/WASM reader check: `node tools/web/check-geometry-node-main-reader.mjs`;
3 graphs, 10 nodes, 7 links, 9 defaults, stable IDs, graph hashes, desktop
comparison, save/reopen and unsupported Simulation-node preservation all passed.
- Chromium Worker check: `WEB_TEST_PORT=5440 npm --prefix web run test:geometry-node-main-reader`
(unit 3/3, reader 1/1, Chromium 1/1).
- `source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8`:
passed (`ninja: no work to do`).
- `npm --prefix web run typecheck`, `npm --prefix web run lint`,
`npm --prefix web run build`, and `git diff --check`: passed.
## Contract
- Per-graph budgets are fixed at 4,096 graphs, 4,096 nodes, 16,384 links,
65,536 node sockets, and 4,096 interface sockets.
- Graphs, nodes, links, and socket arrays are deterministically ordered before the
graph SHA-256 is emitted.
- Socket data types and finite defaults are read from Blender's native `bNodeSocket`
storage; ID-valued defaults are serialized as stable typed IDs.
- `__extend__` sockets are omitted because they are UI extension sockets, not graph
data. Missing/duplicate identifiers, invalid links, and budget overflow fail closed.
- Unsupported node types remain in the graph and are available to the later M10-02
allowlist gate; this task does not claim Geometry Nodes evaluation.
## Rollback
Remove the M10-01 reader, protocol field, golden, unit/E2E checks, package script and
this status file together. Existing SceneIR consumers continue to accept snapshots
without the optional `geometryNodeGraphs` field.

56
docs/status/M10-02.md Normal file
View File

@@ -0,0 +1,56 @@
# M10-02 Status
status: done
task: Geometry Nodes allowlist 与未支持节点无损阻断
updated: 2026-08-16 America/New_York
## Scope
`GEOMETRY_NODE_ALLOWLIST_SCHEMA` and `GEOMETRY_NODE_ALLOWLIST` freeze the only node
types that may proceed past the Geometry Nodes protocol gate. The gate validates the
complete graph before any Main writer call. A node outside the list returns
`GN_NODE_UNSUPPORTED`; a structurally valid allowlisted graph still returns
`CAPABILITY_MISSING` from the production Worker until its evaluator and desktop/WASM
golden are completed by M10-03. Neither failure mutates Main or replaces the graph
read by M10-01.
## Allowlist
Schema 1 contains 16 node types: Group Input/Output, Transform Geometry, Set
Position, Join/Separate Geometry, Realize Instances, Store Named Attribute, integer/
vector input, Value, Compare, Math, and Object/Collection/Image Info. External resource
nodes remain subject to stable-ID, missing-resource, linked-resource and owner-cycle
checks.
## Evidence
- Golden: `tests/golden/M10-02/geometry-node-allowlist.json`.
- Fixture: `tests/files/web/modifier_geometry_nodes_scene.blend`, SHA-256
`f3820511f791769837d75be092934130c397cf46ee0acf79db28efca9a7948c9`.
- `WEB_TEST_PORT=5441 npm --prefix web run test:geometry-node-allowlist`: unit 4/4
and Chromium 1/1 passed.
- The real Main graph `WebGeometryNodesSimulation` preserved
`GeometryNodeSimulationInput` and `GeometryNodeSimulationOutput`; an attempted
graph transaction returned `GN_NODE_UNSUPPORTED` and retained the graph JSON,
graph SHA-256 and Main revision.
- The allowlisted `WebGeometryNodes` graph returned `CAPABILITY_MISSING` while the
evaluator is still closed; it also retained graph identity and Main revision.
- M10-01 regression: `WEB_TEST_PORT=5442 npm --prefix web run
test:geometry-node-main-reader` passed (unit 4/4, reader 1/1, Chromium 1/1).
- Full unit suite: 131/131 passed.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`,
`npm --prefix web run build` (73 modules), and `git diff --check`: passed.
## Implementation Hashes
- Protocol: `23c2289ec2acbdce0beba47c0b6539bc3f14f5a71ce29927df86cf6c705d6ec3`.
- Unit: `e60933ed0f946bdaa96f5322ba8d80b26c8425c623dd6e87da33016a1592c299`.
- Chromium: `ff2c838b5c16d86eff13699586a0cd1121ee1a86d5ea52d7b89c01d35632bb77`.
- Golden: `471fec9c5382ab7522b6c18ab580a6926eaa59eb5a8b72d23968670b9b9ab37a`.
- Production Worker: `4a65547e6b1fea16a120b4e72eb37d4d9f7c7ced4342f535796535262ff65fcc`.
## Rollback
Remove the exported allowlist schema/list, M10-02 unit/E2E assertions, golden,
package script and this status file together. The older private allowlist behavior is
otherwise unchanged; no persisted schema migration is required.

73
docs/status/M10-03.md Normal file
View File

@@ -0,0 +1,73 @@
# M10-03 Status
status: done
task: Geometry Nodes allowlist 逐节点 Blender 5.2 desktop/WASM golden
updated: 2026-08-16 America/New_York
## Scope
The headless Web modifier evaluates the saved Blender Main graph only through the
16-node schema-1 allowlist. Eleven minimal Blender 5.2 fixtures make every allowlisted
node affect a measured output. The same fixture is evaluated by the full-Main WASM in
Node and by the production Chromium Worker before and after save/reopen.
This is a bounded constant-scalar, topology, attribute, and same-file resource closure.
It does not claim arbitrary Blender field/domain conversion, complete lazy-function
semantics, Geometry Nodes graph writing, linked resources, or Simulation Zones.
## Coverage
- Graph boundary: Group Input and Group Output.
- Geometry: Transform, Set Position, Join/Separate Geometry, Realize Instances, and
Store Named Attribute with a constant Float point-domain value.
- Scalar/vector: Integer, Vector, Value, Compare, and Math nodes on the bounded
operations accepted by the native evaluator.
- Same-file resources: Object Info, Collection Info with separate children, and Image
Info dimensions. Missing, recursive, linked, or otherwise unverified resources stay
behind the M10-02 resource gate.
## Evidence
- Fixture: `tests/files/web/geometry_node_allowlist_evaluator.blend`.
- Golden: `tests/golden/M10-03/geometry-node-evaluator.json`.
- Desktop generator: `tools/web/generate-geometry-node-evaluator-golden.py`; Blender
5.2.0 LTS emitted 11 cases, and a clean temporary regeneration matched every semantic
golden field after excluding output-path-dependent fixture path/hash fields.
- Node full-Main WASM: `node tools/web/check-geometry-node-evaluator-golden.mjs`;
16 nodes, 11 cases, maximum position error 0, RMS position error 0, and attributes
passed.
- Chromium: `WEB_TEST_PORT=5443 npm --prefix web run
test:geometry-node-evaluator-golden`; unit 4/4, Node golden 1/1, and Worker
open/evaluate/save/reopen 1/1 passed.
- Tolerances: maximum position `1e-5`, RMS position `1e-6`, Float point attribute
`1e-6`, and bounds `1e-5`.
- M10-01 regression on port 5444 and M10-02 regression on port 5445 passed.
- Full unit suite 131/131, typecheck, lint, production build (73 modules), local
dependency checks, and `git diff --check` passed.
## Implementation Hashes
- Native evaluator: `b896c5c52a611e7a309f80ca4bf8323ccded458584f65ab8f6841df24d70cc42`.
- Desktop generator: `f42ba309e4935a4534eabcb8ab1978abc08d863e2d18d80a70607f31dd3a3066`.
- WASM checker: `76a02d4aae70a597f0c26c12de3104d56e3f1cc9fded1d4824a05d7ddc59c1b5`.
- Fixture: `0cb3e33df570b436e32d783eef0a6c2daad04ca5bb4ccb4f0286b3d723ff7978`.
- Golden: `363ae47cfb50b8c95d0a55116e8663e41d27c7aa332cffe9b702bbe9b8cbfe1c`.
- Chromium spec: `83b1c96518eb64bef2c8830c778a84eb13d8da0ea62c2488471ed5fefea975c8`.
- Full-Main WASM: `432c5c9efb03b23a506c1b8f57f0d06ee683274bbdfe415f894f0d3c29db0b77`.
## Contract
- Evaluation is demand-driven from the active Group Output and is capped at 4,096
node-output evaluations. Unsupported nodes, sockets, operations, cycles, or resource
states return `GEOMETRY_NODES_EVALUATOR_UNSUPPORTED` without claiming evaluation.
- The evaluator uses Blender `GeometrySet`, geometry join/realize/transform helpers,
evaluated dependency graph resources, and Blender mesh attributes. Three.js does not
execute or approximate Geometry Nodes semantics.
- The fixture and golden inventory must cover every schema-1 allowlist entry before the
check starts; missing coverage fails before numeric comparison.
## Rollback
Remove the bounded evaluator expansion, M10-03 generator/fixture/golden/checker,
Chromium spec, package script, and this status file together. Revert the M10 count to
2/15 and restore the previous Transform/Set Position-only modifier evaluator.

75
docs/status/M10-04.md Normal file
View File

@@ -0,0 +1,75 @@
# M10-04 Status
status: done
task: Geometry Nodes field/domain materialization budgets
updated: 2026-08-16 America/New_York
## Scope
Field materialization schema 1 binds every request to a graph ID/hash, Main revision,
source and target domain, data type, transport, and an exact seven-domain cardinality
snapshot. The parser derives element, scalar, and byte counts rather than accepting
caller-provided counts. JSON never carries field values in this contract; the current
native Depsgraph path only publishes a small verified point-Float payload.
This task establishes budgets and transport boundaries. It does not claim actual
evaluation for arbitrary POINT/EDGE/FACE/CORNER/CURVE/INSTANCE/LAYER conversions,
complete Blender lazy-function semantics, graph writing, or Simulation Zones.
## Budgets
- Domain maxima: POINT 1,000,000; EDGE/FACE 2,000,000 each; CORNER 4,000,000;
CURVE/INSTANCE 100,000 each; LAYER 4,096.
- Batch maxima: 64 fields, 32 real domain conversions, 4,000,000 target elements,
and 64 MiB of materialized data.
- Per-field JSON maximum: 65,536 scalar values. Larger fields must stay binary or
return `GN_FIELD_JSON_BUDGET_EXCEEDED`.
- Supported bounded layouts are Boolean, Int, Float, Vector, and Color. Element,
component, and byte multiplication is checked before a receipt is returned.
## Native Contract
The full-Main Depsgraph reports exact POINT/EDGE/FACE/CORNER cardinality from the
evaluated Blender Mesh and zeroes the non-Mesh domains for the current fixture. The
observable `m10_value` point-Float attribute carries schema version, element/scalar
counts, materialized byte length, and transport. The TypeScript parser cross-checks
the receipt against mesh vertex count, domain cardinality, the Float32 byte layout,
and the actual attribute payload. Unknown receipt fields and hidden JSON arrays are
rejected.
## Evidence
- `WEB_TEST_PORT=5448 npm --prefix web run test:geometry-node-field-budget`:
unit 6/6 and real Chromium Worker 1/1 passed.
- The real `M10GN_StoreAttribute` evaluated mesh reported cardinality
POINT/EDGE/FACE/CORNER `8/12/6/24`, schema 1, eight Float scalars, 32 bytes, and
eight values equal to `0.375`.
- Chromium negative checks rejected POINT cardinality drift, byte-length drift, and
a hidden `values` array in the receipt.
- M10-01/02/03 regressions passed on ports 5449/5450/5451. M10-03 still covers all
16 allowlisted nodes across 11 desktop/full-Main WASM/Chromium cases with zero
maximum and RMS position error.
- Full unit suite 133/133, typecheck, lint, production build (73 modules), local
dependency checks, `git diff --check`, and the three-fixture 100-iteration
Depsgraph regression passed.
The first Chromium rerun on port 5447 was not accepted as evidence: the negative
check used a browser-relative dynamic import that resolved to a nonexistent
`/protocol/depsgraph.ts`. The check was moved to Playwright's Node-side static import,
then the complete task command was rerun on port 5448.
## Implementation Hashes
- Geometry Nodes protocol: `8d04291c29e9ce30e3f2f6d4141bc4101d49529bf7f7edf109d67d059efa58b1`.
- Depsgraph protocol: `df7e0e29422f5b605f20fd5ab939d170d35048020968d2f6aea86603d0633cd6`.
- Native Depsgraph: `5bcc17416fd4d45985bc1529288127b9eeed4a30b47836e687dc5321182c0b11`.
- Unit test: `1aa4df021731d7ba4c4191d56f88c663b223443f78c9fdc1b88c2a43423239c7`.
- Chromium spec: `95d8380d6a17a5b85df695a3cb850dffa8705f574ae0b9c97422bb802899eb8e`.
- Full-Main WASM: `1bb4a947da4332471f3d7bab8f3f628c83d1703244093a554a188668ddafafd7`.
## Rollback
Remove the field materialization schema/parser, native Depsgraph cardinality and
receipt fields, M10-04 unit/Chromium checks, package script, and this status file
together. Revert the M10 count to 3/15; do not leave the native attribute JSON path
without its receipt and cardinality validation.

68
docs/status/M10-05.md Normal file
View File

@@ -0,0 +1,68 @@
# M10-05 Status
status: done
task: Simulation cache graph/source/revision identity binding
updated: 2026-08-16 America/New_York
## Scope
Simulation cache manifest schema 2 binds every cache to the Geometry Nodes graph ID/hash,
the committed source `.blend` hash and revision, input hash, Blender 5.2 build, and the
declared frame range. A deterministic `revisionHash` is calculated from those fields and
the storage key is `sim2-` plus the complete 64-character digest. The payload hash and
per-frame hashes remain separate integrity checks.
This task closes identity and stale-source safety. It does not implement Simulation Zone
evaluation, browser bake, cancel, LRU, corruption quarantine, or playback capability.
## Protocol
- Schema 1 manifests and undeclared manifest/frame fields are rejected.
- `sourceRevision` is a bounded committed revision; frame values are bounded to +/-1,000,000.
- `revisionHash` uses a JSON-array canonical encoding with an explicit v2 domain marker,
avoiding ambiguous delimiter collisions.
- Graph/source/input/revision/frame-range drift returns
`SIMULATION_CACHE_REVISION_MISMATCH`; payload or source bytes still use their dedicated
hash mismatch codes.
## Storage Contract
`putSimulationCache`, full read, frame read, and list run through the per-project transaction
lock. Put checks revision and source hash before hashing/storing the payload. Full/frame reads
re-verify the manifest identity and current project; listing validates all manifests and filters
valid but stale revisions out of the current project view. Old rows are not silently reused.
## Evidence
- `WEB_TEST_PORT=5455 npm --prefix web run test:simulation-cache-identity`: unit 3/3 and
Chromium 1/1 passed. Revision 7 cache recovered after Worker restart; after save to revision
8, the old key was rejected and omitted from the list, while a new key was accepted.
- Negative checks covered forged graph hash, forged source hash, stale read/put, changed input,
changed source revision, changed frame range, legacy schema, undeclared fields, and frame
payload drift.
- `WEB_TEST_PORT=5456 npm --prefix web run test:simulation-cache`: identity and existing
Worker-restart smoke 2/2 passed.
- `WEB_TEST_PORT=5457 npm --prefix web run test:simulation-cache-performance`: 600/600 frames,
OPFS backend, playback completed, 600 published frames, 0 pending requests after terminate,
5,072 ms total, below the 30,000 ms gate.
- Full unit suite 136/136; typecheck, lint, production build (73 modules), local dependency
check, and `git diff --check` passed. The first full static pass had one unused import lint
failure after the read-path refactor; removing it and rerunning produced the results above.
## Implementation Hashes
- Simulation protocol: `974b006f1ad4cd1d3d294122fc777c89c3a7da04fdaa7aebedea12b545bc65e5`.
- Storage Worker: `a7b31eeb601e9e71c74eaceda95704f1d120653e9ade6bfbee6d262a42edaa74`.
- Unit test: `591582a4ce77b699a4462e30d587eb89034f29ec0a22a94c81f8ab7306f2524f`.
- Identity Chromium spec: `2446060a9c3955ef466adb479966bf424fd9e9c351b65cd8525e90a1a1032e4d`.
- Existing smoke: `0c8c001290c8c6b3482eeebc105b2f217c55b2eded0c62b4fee4595ec277a11d`.
- Performance spec: `f6c465b7afa012030dd8516d676e2663f4dd8298aa156defb905b3fa0229a84b`.
- Package: `f9e28fb23af28720f56f97b249cb636599ebd92955ab2ff6d234b821e6d5ea44`.
- Error registry: `4c6045b1cab8348f0acf180a1e618a0d691ddc5092822ca288f974904e5e13a5`.
## Rollback
Remove schema 2 identity fields and hash helper, restore the prefix-truncated key, revert
Storage Worker project-lock checks and stale filtering, remove the identity unit/E2E/package
entries and this status file, then restore the M10 count to 4/15. Do not retain schema 2
manifests with schema 1 readers.

84
docs/status/M10-06.md Normal file
View File

@@ -0,0 +1,84 @@
# M10-06 Status
status: done
task: Simulation cache cancellation, playback gate, LRU, restart and corruption isolation
updated: 2026-08-16 America/New_York
## Scope
Simulation cache playback is now a lifecycle-gated operation. A cache may be read by
frame only after the current Storage Worker has completed a full manifest, payload and
per-frame hash verification. This closes cache lifecycle safety; it does not implement
Simulation Zone evaluation, browser bake generation, or GN modifier seek semantics.
## Protocol and Client
- `SIMULATION_CACHE_NOT_READY`, `SIMULATION_CACHE_CANCELLED` and
`SIMULATION_CACHE_BUDGET_EXCEEDED` are stable error codes.
- `verifySimulationCacheCancellable` checks cancellation between total and per-frame
SHA-256 work. `StorageClient` accepts `AbortSignal`, removes an aborted request from
`pending`, and sends a `cancelRequest` to the Worker so late responses cannot publish.
- `planSimulationCacheLRU` sorts removable entries by `lastAccessAt`, `createdAt`, and
`cacheKey`; active playback keys are protected. If protected entries alone exceed the
requested budget, the result reports `budgetSatisfied: false` instead of evicting them.
## Storage Contract
- IndexedDB schema 7 adds `simulation_quarantine`; the M10-06 Chromium test creates a
temporary schema 6 database and verifies the real v6 to v7 migration and store.
- `prepareSimulationCachePlayback` performs full verification in the current Worker and
activates the key. `readSimulationCacheFrame` rejects unprepared keys, while `release`
removes readiness and LRU protection. A successful full read also establishes verified
readiness for compatibility with existing callers.
- `pruneSimulationCaches` removes deterministic LRU manifests and deletes their
unreferenced simulation assets. Active playback is automatically protected.
- Invalid manifests and missing, truncated, or hash-mismatched payloads are moved from
`simulation_manifest` to `simulation_quarantine`. Quarantine preserves the bad row for
diagnostics while valid cache listing and playback continue.
## Evidence
- `WEB_TEST_PORT=5464 npm --prefix web run test:simulation-cache-lifecycle`: unit 5/5 and
real Chromium 1/1. It covers v6 to v7 migration, post-restart `NOT_READY`, full
verification, StorageClient cancellation, BrowserTransform playback cancellation with
zero published frames and zero pending requests, active-cache LRU protection, release,
cancelled write cleanup, OPFS byte tamper, and quarantine listing.
- `WEB_TEST_PORT=5460 npm --prefix web run test:simulation-cache-identity`: unit 5/5 and
Chromium 1/1. Existing graph/source/revision stale filtering remains green.
- `WEB_TEST_PORT=5461 npm --prefix web run test:simulation-cache`: identity and existing
Worker-restart smoke 2/2.
- `WEB_TEST_PORT=5462 npm --prefix web run test:simulation-cache-performance`: 600/600
frames, OPFS backend, 6,639 ms total, 0 pending requests after terminate, and restart
recovery passed the 30,000 ms gate.
- Targeted schema/snapshot smoke 2/2 and recent-project recovery 4/4 passed after the
schema version update. Full unit suite passed 138/138; typecheck, lint, production
build (73 modules), local-dependency check, and `git diff --check` passed.
The first lifecycle run was not accepted as evidence because the test helper returned the
numeric legacy `DOMException.code` value `20` instead of the `AbortError` name. The helper
was corrected and the complete command was rerun on port 5459, then rerun again on port
5464 after adding playback cancellation and migration assertions.
## Implementation Hashes
- Simulation protocol: `3025db84bd4e0151b8894a457c51da630d628afb1760e2f0caeec003f0efce3f`.
- Storage protocol: `2711dbab0c52a566679b83d5e4d793ec0ffd3f991e744dce765b6bdc54d25c13`.
- Error registry: `7ebff554dd6346b539215a6545c67806bca2ead33953700b79c0910fd0cfb408`.
- Storage Worker: `e31d7dcf93c5dcefb7e6c4cfd7874c748f972849ee7d93adb6294f82aafcf236`.
- Storage client: `aaa3a03e3b91452e90a661566407c261c7a16b1671ed55ec948de7626d49bfa4`.
- Storage migration: `8de1fcbbda9d7a4e7496995306c4e9fc83224211a516cf7241496b6a145953b5`.
- OPFS helper: `2e8ef9d72194bf803a7e823fb2b7de30b9f10ae6dd4b031b81b97ff82eee9cc6`.
- Unit test: `4ece0f5051667b04fab78c81a847def7a0c77d9267bd9e4fa2bbe4b9308125bf`.
- Lifecycle Chromium spec: `861d370f72896987cef65d2e4d78ab3edcc1d4828d4a0fa0b5301b72a4a2932a`.
- Identity regression: `aa305871e8651018342b123112beab10a43dab3313e2c9765e450dc91c08b6e1`.
- Performance regression: `d7083c24789fc5877a7841bf611aee0ff471cc191621de1b30720d637635b44c`.
- Smoke/recovery version updates: `2d15e5bf54a1dcdec1686ee937a510a7521ad44b152c895289a152ea711fadf4` /
`a4d6279f05b6215f2e34cd5f1d76ebb6d5ed2cc765859c2ae0af975623f93b2c`.
- Package: `fe90c0bd741a7840f6184726737a1a93c606841b8408dd7fd6dec823a8352cf6`.
## Rollback
Remove the schema 7 migration and `simulation_quarantine` store, restore frame reads to
the schema 6 identity-only contract, remove cancellation/LRU/playback commands and the
M10-06 tests/status entry, then restore the M10 count to 5/15. Do not retain schema 7
databases with a schema 6 reader.

59
docs/status/M10-07.md Normal file
View File

@@ -0,0 +1,59 @@
# M10-07 Status
status: done
task: Bounded WebGL2/Three physical compiler for the declared Shader node subset
updated: 2026-08-16 America/New_York
## Scope
The browser material path now compiles only the declared RGB, Value, Math,
Image Texture, Normal Map, Principled, and Material Output closure. The compiler
produces a schema-versioned `WEBGL2_THREE_PHYSICAL` report before a Shader Main
transaction is sent to Blender. Unknown nodes, cycles, duplicate inputs, missing
or blocked images, oversized graphs, and invalid constants fail closed without
mutating the source graph.
This task does not claim arbitrary Shader Node support, WebGPU compilation,
complete color management/sampler parity, compile-key invalidation, or pipeline
rollback; those remain later M10 tasks.
## Protocol and Runtime
- `web/protocol/shader-compiler.ts` defines the M10-07 allowlist, fixed node/link/
depth/texture/identifier budgets, deterministic graph fingerprint fallback,
scalar Math evaluation, and bounded `ShaderCompileReport`.
- The Engine Worker compiles `setShaderGraph` before the native Main transaction,
rejects a blocked report, and returns the report with the committed delta.
- Main-thread and Offscreen viewport material creation share `createPBRMaterial`;
compiled constants and declared base-color/normal texture bindings are stored in
material `userData` for the texture adapter.
## Evidence
- `WEB_TEST_PORT=5521 npm --prefix web run test:shader-compile`: unit 5/5 and
Chromium 3/3 passed. The suite covers the real Main graph transaction, Math
result and topological order, Image/Normal bindings through the shared viewport
path, missing resources, cycles, duplicate inputs, oversized graphs, forged
fingerprints, and source graph immutability on blocked input.
- `tests/golden/M10-07/shader-compile.json` fixes the fixture digest, backend,
allowlist, six Math operations, and compiler budgets.
- The first attempt on the default port was rejected before browser startup because
an existing process owned `127.0.0.1:5173`; it is not counted as a feature failure.
## Implementation Hashes
- Compiler: `50b29c99d73c886a41bb8e10ccd2309068a1c09b23196655659b2f530d31f856`.
- Engine Worker: `376efe09de20150fc32803745da7e7e55bb41d178302e45b1fd90a2fe7778b2e`.
- PBR adapter: `f9c2e5ede635d11673f971c985288821af24ed81ecaadc95efefd958637672fd`.
- Texture adapter: `d5e379e25e0ab8294523690c4d4c79203b6f1610b4d6a608b33a7118f51efa57`.
- Unit test: `1e5d44b4f73f4ee4e4c9329f73628980df9337632dc48a3d50689f051a89b6e7`.
- Chromium spec: `c59b9f349bd6ce329ffc89f64eba3fec3e1e02a528978bdc1c3793d921faabc3`.
- Golden: `6bb8c07f014a0f2c1fd0e9ec16b08b99a524d8d3161bc556767acaf510c6c961`.
- Package: `ea0fef34497d51448d5478f4a761bef3c4ca816c408fd8a0807eaa0b093d0df8`.
## Rollback
Remove the compiler, Worker gate, shared material/texture adapter integration,
M10-07 tests, golden and this status file together. Restore the M10 count to
6/15 and leave Shader Main's existing capability gate and writeback contract in
place; do not retain a compiler report that is not enforced before Main mutation.

59
docs/status/M10-08.md Normal file
View File

@@ -0,0 +1,59 @@
# M10-08 Status
status: done
task: Shader compile key binding for graph, textures, color space and backend
updated: 2026-08-16 America/New_York
## Scope
Every bounded Shader compile report now carries a deterministic 64-character
`compileKey`. The key is SHA-256 over the task-local report schema, graph hash,
renderer backend, and sorted texture identities: image ID, usage, asset ID,
asset SHA-256, and resolved SRGB/LINEAR/NON_COLOR color space.
Texture identities are optional for legacy SceneIR rows; missing metadata is
represented explicitly as `null`, while usage-based color-space defaults remain
deterministic. An unknown renderer backend or malformed texture digest blocks
compilation. The global `web/protocol/schema-version` remains 1 because this is
an additive field in the task-local Shader report, whose schema is still 1.
## Runtime
- `ImageIR.colorSpace` is optional and validated; render asset requests use it
when present and otherwise retain the existing usage-derived color space.
- The Engine Worker passes snapshot image asset/hash/color-space identity into the
compiler. The PBR adapter accepts the same context for main-thread and Offscreen
material creation.
## Evidence
- `WEB_TEST_PORT=5523 npm --prefix web run test:shader-compile-key`: unit 7/7 and
Chromium 1/1 passed. The browser test proves key changes for texture SHA-256 and
color-space changes through the application PBR entry point.
- `WEB_TEST_PORT=5524 npm --prefix web run test:shader-compile`: M10-07 regression
unit 7/7 and Chromium 3/3 passed.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`,
`npm --prefix web run build`, `npm --prefix web run test:status-consistency`,
and `git diff --check` passed.
The first browser attempt on port 5522 used a repository-root dynamic import that
Vite does not expose and was rejected before exercising the compiler. The test was
moved through the application PBR entry point and rerun in full on port 5523.
## Implementation Hashes
- Compiler: `acd5f8deb2912f6823533df12baba8049c5e64d440c17a6dc3f90741f71774b2`.
- SceneIR: `a65ef3b4be5303b1b86b3ee6829c795870dcc2490ba58d3a66b8bed8172d6442`.
- Render assets: `840c09a6c2ec56cc75a9ca72bcbdde6957612a7c61b19ab83be47c571eca8b67`.
- Engine Worker: `668728f7e5b6edf9e1cee3e6152238d16ac19f43f030e94e77bfe24e7ff50489`.
- PBR adapter: `8a9cb52c8e7b1481096017cc659d93541c21724c1203d22755e37e1e3c365250`.
- Unit test: `9ae9bb4c778d9e31a607d0c7d678ecf3448ed4e5b57c88ab4ba767a2f0850cc7`.
- Chromium spec: `a09ebfa451e76c33f421bc8402fac4c77faedee88020431a505602f317b4eb65`.
- Golden: `187666d9dfb9d9cdd5bd6195cc2f7ee57bb78fa0d05d6020762c06a4ab015984`.
## Rollback
Remove `compileKey`, texture identity context, ImageIR color-space metadata,
M10-08 tests/golden and the Worker/adapter wiring together. Restore the M10
count to 7/15 and retain graphHash-only reports with the original usage-derived
render asset color-space behavior.

40
docs/status/M10-09.md Normal file
View File

@@ -0,0 +1,40 @@
# M10-09 Status
status: done
task: Preserve the previous usable material pipeline on compile failure
updated: 2026-08-16 America/New_York
## Scope
`PBRMaterialPipeline` makes material replacement transactional. A successfully
compiled candidate becomes current only after compilation; the previous material
is disposed exactly once after replacement. A blocked candidate is disposed and
the current material remains usable, with the failed report stored as
`shaderCompileFailure` for diagnostics. This does not claim arbitrary Shader Node
support or a WebGPU pipeline.
The Engine Worker independently rejects blocked `setShaderGraph` requests before
the Blender Main transaction, so a failed graph cannot publish a new SceneIR
snapshot in addition to the viewport-side rollback.
## Evidence
- `WEB_TEST_PORT=5525 npm --prefix web run test:shader-pipeline`: Chromium 1/1.
The test confirms the failed candidate reports `SHADER_NODE_UNSUPPORTED`, keeps
object identity and the previous compiled report, and that a later successful
compile replaces the material.
- `npm --prefix web run typecheck` and `npm --prefix web run lint` passed after the
pipeline helper was added.
- `tests/golden/M10-09/shader-pipeline.json` fixes the rollback invariants.
## Implementation Hashes
- PBR adapter: `8a9cb52c8e7b1481096017cc659d93541c21724c1203d22755e37e1e3c365250`.
- Chromium spec: `e6771f672e885250db199cbf97a0a3866084ab9bb3f2ad25d17f5c53edaa99af`.
- Golden: `5dbb916e512935a76dacde37242b123707897f54ab10d7b99e246f57afbc94ac`.
## Rollback
Remove `PBRMaterialPipeline`, its Chromium test/golden and this status file. Keep
the M10-08 compile key and Engine Worker pre-transaction gate, and restore the M10
count to 8/15.

39
docs/status/M10-10.md Normal file
View File

@@ -0,0 +1,39 @@
# M10-10 Status
status: done
task: Stable capability block for unsupported Shader nodes
updated: 2026-08-16 America/New_York
## Scope
The Shader capability query now accepts arbitrary node type strings and returns a
stable `PBR-012/ARBITRARY_SHADER` block for every type outside the declared bounded
compiler set. Unsupported node names are de-duplicated and sorted, so the same
graph receives the same error regardless of input order. The block uses
`SHADER_NODE_UNSUPPORTED`, remains recoverable, and does not silently report READY.
The existing ShaderGraph validator and Engine Worker gate continue to preserve the
source graph and reject blocked `setShaderGraph` commands before any Main mutation.
This task does not expand the compiler allowlist.
## Evidence
- `WEB_TEST_PORT=5526 npm --prefix web run test:shader-capability`: Chromium 1/1.
`VORONOI` and `CUSTOM_OSL` both return the expected stable block; reversed input
order produces the identical task/capability/status/code/message result.
- `npm --prefix web run typecheck` and `npm --prefix web run lint` passed.
- `tests/golden/M10-10/shader-capability-block.json` fixes the status, error code,
recoverability and graph-preservation policy.
## Implementation Hashes
- Render capability gate: `322929e9c7b627a9e79b8a6f11d5e9b0c7c05a5754f82d41c45c1134bbaaf385`.
- Chromium spec: `6337801f10b9115ba7b53a43152ae69a324b5baa61c8e455cc61505437228cbf`.
- Golden: `b7182bb57e10d0b302b4c7dc9bc43160721a2c9414b056c86173c3b5c2501739`.
- Package: `d6d4dcaf4f5c852f884ba2064da96a8c30519c661bf4a16c59e115742aa37e8a`.
## Rollback
Restore the enum-only `ARBITRARY_SHADER` request, remove deterministic sorting,
the M10-10 Chromium test/golden and this status file, and restore the M10 count to
9/15. Keep the existing M10-07 compiler fail-closed behavior.

44
docs/status/M10-11.md Normal file
View File

@@ -0,0 +1,44 @@
# M10-11 Status
status: done
task: NLA track/strip/action/time mapping read-only exact evaluation
updated: 2026-08-16 America/New_York
## Scope
A Blender 5.2 desktop-authored fixture contains one object, one NLA Track and two
Action Clip strips. The first maps Action frames 1..11 to scene frames 20..40 with
scale 2. The second maps the same Action range to frames 45..65 with reverse playback
and repeat 2. Both use `NOTHING` extrapolation so samples outside their ranges expose
boundary behavior rather than hold behavior.
The Main reader must preserve Track, Strip, Action and time-mapping fields. Blender's
native Depsgraph evaluates 12 boundary/interior/cycle frames; Node WASM and the
production Chromium Worker compare every world matrix with the desktop golden. The
test asserts that evaluation does not change NLA or Action JSON. It does not call
`setNLAStack` and does not claim the M10-12 operator transaction.
## Evidence
- Fixture: `tests/files/web/nla_time_mapping_scene.blend`, SHA-256
`b32f783debe213a1345ec4528e5ed27f0f4de00d7896b626df39756289119855`.
- Golden: `tests/golden/M10-11/nla-evaluation.json`; Blender 5.2.0 LTS, 1 Track,
2 Clip strips and 12 frames.
- `WEB_TEST_PORT=5527 npm --prefix web run test:nla-evaluation-golden`: Node/WASM
checker and Chromium 1/1 passed; maximum matrix error 0 and read-only identity passed.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`, and
`npm --prefix web run check:local-deps`: passed.
## Implementation Hashes
- Fixture generator: `a941ecb850ddd165ede117f6411615c6daed4d6bdaffaeac8228a308b7fbe745`.
- Golden generator: `941a63cc9edbaa0d8dd25025872e3fc44bf64df54d5a9232ad4128e6ac0cd5c5`.
- Node/WASM checker: `2ce9db0fcbceaf30e398e8feb5c002a60395ac34e61382d9414e980e51e6fdd5`.
- Golden: `2d148ac0cbc2ac9281d430da20dba8373ad1bb0de6ec5fa578adf36296455fbe`.
- Chromium spec: `c12f105d206a03dae5b73b076cc3b0a758d597cea7532b6720f4653568df0245`.
## Rollback
Remove the fixture/golden generators, fixture, M10-11 golden/checker/Chromium spec,
package command and this status file together. Restore the M10 count to 10/15. Keep
the existing N-014 bounded Main writer and reverse/repeat regression unchanged.

45
docs/status/M10-12.md Normal file
View File

@@ -0,0 +1,45 @@
# M10-12 Status
status: done
task: One NLA edit operator with Main transaction, undo/redo and save/reopen
updated: 2026-08-16 America/New_York
## Scope
The bounded `moveNLAStrip` command moves one existing Action Clip while preserving
its duration and time mapping. It requires the current `baseRevision`; stale commands
return `REVISION_CONFLICT` before Main mutation. The Worker derives a candidate stack
through the pure protocol helper, validates Action/path/range/overlap gates, and commits
it through one authoritative Main `setNLAStack` transaction. The native history entry
therefore covers the exact pre-edit `.blend` bytes.
This task intentionally opens only the move operator. Create/remove/resize/active,
multi-track blending, Transition/Meta/Sound, NLA UI and export remain blocked or planned.
## Evidence
- Pure contract: `web/protocol/nla.ts` and `web/tests/unit/nla.test.mjs`, 2/2 tests.
The source stack is not mutated; overlap, unknown identity and frame budget errors
are deterministic.
- `WEB_TEST_PORT=5528 npm --prefix web run test:nla-operator`: unit 2/2 and Chromium
1/1 passed. The browser path proves stale revision rejection, Main move 20..40 ->
5..25, monotonic revision/delta, undo to 20..40, redo to 5..25, save/reopen at
5..25, and native frame-10 evaluation at X=2.5.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`, and `git diff --check`:
passed.
## Rollback
Remove `moveNLAStrip` from `web/protocol/nla.ts` and `web/protocol/web-engine.ts`,
remove the Worker conversion, unit/E2E tests and package command, and delete this
status file. Restore the M10 count to 11/15. Keep M10-11 read-only evaluation and
the pre-existing whole-stack `setNLAStack` authoring path.
## Implementation Hashes
- `web/protocol/nla.ts`: `03d5abce5cb2f0a3a38e42eedbced7f4de6e2052d18e53a3dcde3b3ca1e6020d`
- `web/protocol/web-engine.ts`: `48f2290ead27aeda1f17b279f01ae278776b50b4cb4de065d94b0e1d44717dbb`
- `web/app/src/workers/web-engine.worker.ts`: `4ccad87c1619db8e39d2855ebca9918883484ce3e5dfc8a325ccee9a8220397c`
- `web/tests/unit/nla.test.mjs`: `d8bac2dd3e4ec1a1342de7c7401a51beb9879ea5ba2b34df7d615ae0449081a7`
- `web/tests/e2e/nla-operator.spec.ts`: `1658d52eb0b8cf329f73be552e9b2649ed6dc96b21edb48ade9dd4a725a37ae0`
- `web/package.json`: `e9d5c6e4f7132921faac8ecbee40215098497fe374d56309f2c76fab152c33d6`

48
docs/status/M10-13.md Normal file
View File

@@ -0,0 +1,48 @@
# M10-13 Status
status: done
task: Probe local Physics solvers per family and keep unsupported families bake-only
updated: 2026-08-16 America/New_York
## Scope
The Physics protocol now probes all seven declared families independently. A local
solver becomes `READY` only after its runtime export exists, initialization succeeds,
its thread requirement is met and its required memory fits the active WASM limit.
Missing exports, initialization errors, insufficient threads or memory, and malformed
probe results remain `BLOCKED` and route to `DESKTOP_SERVER_BAKE`.
The production inventory has no installed solver runtime adapter, so Rigid Body,
Soft Body, Cloth, Fluid, Dynamic Paint, Particle and Hair all remain bake-only.
The synthetic positive probe verifies the gate and does not declare an actual solver.
Desktop family decoding, cache playback, browser bake and server job submission remain
blocked for later tasks.
## Evidence
- `node --test web/tests/unit/physics-solver-probe.test.mjs`: 3/3. It covers the
default seven-family fallback, family isolation across all four gates, and invalid
environment/result fail-closed behavior.
- `WEB_TEST_PORT=5530 npm --prefix web run test:physics-solver-probe`: unit 3/3 and
Chromium Worker 1/1. The browser result matches the M10-13 golden and keeps every
failed family on `DESKTOP_SERVER_BAKE`.
- `WEB_TEST_PORT=5531 npm --prefix web run test:e2e -- --grep "N-018 physics"` and
`npm --prefix web run test:physics-main-reader`: passed without changing existing
metadata/cache gates or claiming a solver.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`, production build
(74 modules), and `git diff --check`: passed.
## Implementation Hashes
- `web/protocol/physics-simulation.ts`: `adc57a12c7f57c6b1d5f99590d3c36cefa83636aca86c780c2520f3f41148981`
- `web/tests/unit/physics-solver-probe.test.mjs`: `e499d55770d2024c75803ce1acc1c234814c027279f2b40f42195fd480977653`
- `web/app/src/workers/physics-solver-probe-test.worker.ts`: `fbad383de186a659440da42dcad988310ca2e483db27e30d74342c8f57a0f49a`
- `web/tests/e2e/physics-solver-probe.spec.ts`: `1419b98d68da9b0ab0bf41b37843442741593655e2693159d84e069f1dec3f09`
- `tests/golden/M10-13/physics-solver-probe.json`: `bcc8f9094e2b0d8f41ef01fda0daa2a29693d188e602447d6d06358aaa751df4`
- `web/package.json`: `875162709315e2a8116c5d28023576a4b95fb076b79453de8b21acdbff2df9dc`
## Rollback
Remove the solver probe types/functions, unit/Worker/E2E/golden/package command and
this status file, then restore the M10 count to 12/15. Keep the existing N-018
metadata, Main reader, Simulation cache and BTF1 playback primitive unchanged.

47
docs/status/M10-14.md Normal file
View File

@@ -0,0 +1,47 @@
# M10-14 Status
status: done
task: Validate source hash, frame range, byte budget and version for every Physics cache family
updated: 2026-08-16 America/New_York
## Scope
Every Physics family cache now uses cache schema 1 and binds its family, desktop or
server bake origin, Blender 5.2 version, source blend/settings/input/cache SHA-256,
frame range, total byte length and per-frame byte offset/length/SHA-256. `COMPLETE`
caches require every frame; `PARTIAL` caches may omit frames but remain strictly
ordered and byte-contiguous. Per-frame and total cache budgets match the existing
bounded Simulation cache policy.
`verifyPhysicsCachePayload` hashes the actual current blend, full cache payload and
every frame before consumption. Family/source/version/range/budget drift fails closed.
This task validates cache identity and bytes; it does not implement a Blender family
decoder or change the existing cache playback capability block.
## Evidence
- `node --test web/tests/unit/physics-cache-family.test.mjs`: 3/3. All seven families
and both desktop/server sources pass; source/payload drift and family/schema/version/
range/budget/undeclared-field failures return stable codes.
- `WEB_TEST_PORT=5533 npm --prefix web run test:physics-cache-family`: unit 3/3 and
Chromium Worker 1/1, matching the M10-14 golden.
- M10-13 probe Chromium 1/1, Physics Main reader, M10-05 Simulation cache identity
unit 5/5 + Chromium 1/1, typecheck, lint, production build (74 modules), and
`git diff --check`: passed.
## Implementation Hashes
- `web/protocol/error.ts`: `6972a144bdc61c2a4270c88df2d86c75640bb5f24b5ac3b7f9a02d69d3c500fd`
- `web/protocol/physics-simulation.ts`: `b02b820503d79cd26b0323a4d2033e79d0eeb3032befd26fafe5eab55634584c`
- `web/app/src/workers/physics-simulation-test.worker.ts`: `453c6e9e683b9da0ef7ce4bd0e4e430b97102d80ddb9b14cc9d075319b37d818`
- `web/tests/unit/physics-cache-family.test.mjs`: `e1c6f07ec58ce4c4c987bd8a36f270497ae3a0f521d6ed27d7f199debeeaccb2`
- `web/app/src/workers/physics-cache-family-test.worker.ts`: `8af676506cc0ff1501816431d92d82ca15316b40095d617ecb664b9608e209e0`
- `web/tests/e2e/physics-cache-family.spec.ts`: `6a2788587f6692bf5bf19d76400810fa277a6985d1a6ecc1b8d1adb85c609249`
- `tests/golden/M10-14/physics-cache-family.json`: `b220d1fd73f307213078ce3cbd6212544f97ade84a45d54847f107cef69d302d`
- `web/package.json`: `9baeb319d8c740f67f3e7615942c78f920e7a56b0d654a5a8f47d68de7243eb4`
## Rollback
Restore the former Physics cache binding, remove the two new cache hash error codes,
unit/Worker/E2E/golden/package command and this status file, then restore M10 to
13/15. Keep M10-13 solver probing and the existing GN Simulation cache schema 2.

51
docs/status/M10-15.md Normal file
View File

@@ -0,0 +1,51 @@
# M10-15 Status
status: done
task: Browser performance, OOM-prevention and malicious-input gates for GN, Shader, NLA and Simulation
updated: 2026-08-16 America/New_York
## Scope
GN, Shader, NLA and Simulation now run in four isolated Chromium Worker sessions.
Each session executes a representative bounded workload, rejects an over-budget input
before evaluator/payload allocation, rejects a domain-specific malicious graph or
manifest, and then proves that a small valid input still succeeds in the same Worker.
NLA gains explicit track/strip/string budgets and exact declared-field checks.
Simulation cache total/frame/range overages now return the dedicated
`SIMULATION_CACHE_BUDGET_EXCEEDED` code. This is a deterministic OOM-prevention gate;
it does not claim that arbitrary browser heap exhaustion is recoverable inside a
domain evaluator.
## Evidence
- `WEB_TEST_PORT=5536 npm --prefix web run test:m10-domain-gates`: Node 23/23 and
Chromium 1/1. Measured Worker times were GN 54 ms (10 x 512 nodes), Shader 17 ms
(100 compiles), NLA 62 ms (20 x 256 strips), and Simulation 2 ms (64 frame hashes),
all below their 5/5/5/10 second gates.
- Stable OOM/malicious codes: GN `GN_GRAPH_BUDGET_EXCEEDED`/
`GN_DEPENDENCY_CYCLE`; Shader `SHADER_NODE_UNSUPPORTED`/`SHADER_GRAPH_CYCLE`;
NLA `NLA_BUDGET_EXCEEDED`/`NLA_INVALID_STACK`; Simulation
`SIMULATION_CACHE_BUDGET_EXCEEDED`/`SIMULATION_CACHE_INVALID`.
- Existing GN field budget (unit 6/6 + Chromium 1/1), Shader compiler (7/7 + 3/3),
NLA operator (4/4 + 1/1), and Simulation lifecycle (6/6 + 1/1) all passed.
- Typecheck, lint, production build (74 modules), status consistency and
`git diff --check`: passed.
## Implementation Hashes
- `web/protocol/error.ts`: `88119782307cb9a61e131f2d5e2e25127256c1e68a333fc7fc3206158b8a1019`
- `web/protocol/nla.ts`: `96ae7cf9ca3fadbaf98ca15a38937329f203b0bc4a9c39ef685987e7f55353dd`
- `web/protocol/simulation-cache.ts`: `a1302e42129420a96416e363cbc857d082d4646655685520c9e81f877ce3d9b8`
- `web/tests/unit/nla.test.mjs`: `e11aa1f395970613cee5d37a9eb21c3fac3422f0080c75c78eb8777effb77adc`
- `web/tests/unit/simulation-cache.test.mjs`: `2be82cdda991b47fbf8955cfbf2f92cb1fdc7b7c0debb0c8102d4f35e8fce8e3`
- `web/app/src/workers/m10-domain-gate-test.worker.ts`: `bc508e1c81ca6efefd67b5c572f93c27dd71bd602319ae887c50e27f8a309a31`
- `web/tests/e2e/m10-domain-browser-gates.spec.ts`: `2973a596df8613a9cfdb0bd4d300d95b71a8e818e615278a39fb777db6c5cf03`
- `tests/golden/M10-15/domain-browser-gates.json`: `580051434ec6673251bac755cc189939de1f887aad088497e16a7657f3a83963`
- `web/package.json`: `092931cebf9cb14814951703af26e2d0e4c5e0295c1ba4cb748bfe4ee49448c2`
## Rollback
Remove the NLA stack budgets/exact-field checks, restore the former Simulation cache
budget error mapping, remove the domain Worker/E2E/golden/package command and this
status file, then restore M10 to 14/15. Keep all M10-01 through M10-14 behavior.

42
docs/status/M11-01.md Normal file
View File

@@ -0,0 +1,42 @@
# M11-01 Status
status: done
task: Camera, Light, World and Scene color-management field parity table
updated: 2026-08-16 America/New_York
## Scope
`tests/golden/M11-01/lighting-field-parity.json` is the machine-readable field table
for all CameraIR, LightIR and WorldIR leaves plus Scene render engine and color
management. Each field independently records reader, writer, main viewport and
Offscreen viewport status as VERIFIED, PARTIAL, METADATA_ONLY, BLOCKED or
NOT_APPLICABLE, with an aggregate parity status.
The table contains 60 fields: 24 COMPLETE, 17 PARTIAL and 19 BLOCKED. It explicitly
keeps true Orthographic/Panoramic/Custom projection, camera DOF rendering, light
radius/area spread/sun angle, World environment rotation and visual Mist, display/
view/look/gamma/white-balance rendering and Scene writer behavior outside COMPLETE.
## Evidence
- `npm --prefix web run test:lighting-field-parity`: AST table checker and the real
Main lighting roundtrip passed. The checker proves exact type coverage, unique fields,
valid states, existing evidence paths and required blocked claims.
- The Main roundtrip covers Camera/DOF, Light exposure/temperature/shadow, World/Mist,
validation rejection, undo/redo, save/reopen and the white-balance integrity gate.
- `WEB_TEST_PORT=5541 npm --prefix web run test:e2e -- --grep "N-019"`: Chromium 2/2,
covering Scene-over-World exposure, shadow metadata and renderer-bound World/Scene
delta preservation.
- Typecheck, lint, production build (74 modules), status consistency and
`git diff --check`: passed.
## Implementation Hashes
- Parity table: `b7fee80b4f2469cf119902c939f9091e640a6f0e1307e97ba29f0afa8942c4ed`
- AST checker: `cf910a087f9af72e0782d62e67341f90fd4af1c6c8eddf54169ee6275e24bd9c`
- Package: `cd7174b04b19e61a280e03e6c61e2ffd20fd507c0716c820b8faa31ea2438ebf`
## Rollback
Remove the M11-01 table/checker/package command and this status file, restore M11 to
0/14, and keep the existing N-019 reader/writer/viewport implementation unchanged.

48
docs/status/M11-02.md Normal file
View File

@@ -0,0 +1,48 @@
# M11-02 Status
status: done
task: Supported Camera, Light and World fields through Main, history, persistence and viewport mapping
updated: 2026-08-16 America/New_York
## Scope
The bounded `setCameraProperties`, `setLightProperties` and `setWorldProperties`
commands now have one task-level acceptance path through Blender Main, monotonically
revisioned undo/redo, in-memory `.blend` save, a fresh Worker reopen and the shared
main-thread/Offscreen PBR mapping functions.
The golden covers Camera lens, both sensor dimensions and fit modes, shift, clip,
ortho scale and DOF fields; Light color, energy/exposure, temperature, shadow, spot
and area fields; and World color/exposure/Mist fields. Viewport assertions cover both
horizontal and vertical sensor fit, clip and film offset, temperature-adjusted linear
RGB, intensity, shadow, spot angle/blend, area dimensions and World background color.
Fields kept PARTIAL or BLOCKED by M11-01 remain outside visual parity claims.
## Evidence
- `WEB_TEST_PORT=5545 npm --prefix web run test:lighting-field-roundtrip`: native
Main roundtrip passed and Chromium 1/1 passed.
- Camera, Light and World edits each advanced revision; task-level Chromium coverage
undid and redid every domain, saved, opened the bytes in a fresh Worker and compared
the reopened values with float32-aware `1e-5` tolerance.
- The native rejection path kept an invalid `near >= far` edit out of Main. Existing
white-balance integrity protection remained blocked after Blender serialization
rather than publishing invalid Scene color-management values.
- `npm --prefix web test`: 156/156 passed. Typecheck, lint, production build
(74 modules) and `git diff --check` passed.
## Implementation Hashes
- Golden: `ac6d84c0099b12cf6c96df2dbbb4281e3308c36f0bda49c92d7548e58ccc286e`
- Chromium spec: `9f75c7b88c70ab42c6c418b0056dcffcab3b8851003258891902db9615fcfe67`
- Native roundtrip checker: `023e3e43bc37fb38c3063d33c20e59043308013874fce20bc3a49d508b850e6c`
- Web command protocol: `48f2290ead27aeda1f17b279f01ae278776b50b4cb4de065d94b0e1d44717dbb`
- Blender Main writer: `510b4017c540fc4d1ac135c1ce7251cc5d7143f31d8604e1d545de36716da9a2`
- Shared PBR adapter: `78352e46c62d0c8d9bf25408ea1d725a843a42068ea3eb304bb3d78f8d612e0b`
- Package: `8074d80265a1378fb3f284206c343ff1e388a5ea43e57946a1d4092076d72ea8`
## Rollback
Remove the M11-02 golden/spec/package command and this status file, restore M11 to
1/14, and keep the underlying N-019 bounded writers and viewport mappings at their
previous independently tested status.

55
docs/status/M11-03.md Normal file
View File

@@ -0,0 +1,55 @@
# M11-03 Status
status: done
task: Explicit Three WebGL2 and WebGPU light, shadow-map and texture budgets
updated: 2026-08-16 America/New_York
## Scope
Schema 1 `PBRRenderBudget` freezes separate `THREE_WEBGL2` and `THREE_WEBGPU`
product ceilings. WebGL2 reserves two built-in lights and one built-in shadow map
inside totals of 16 lights and four 1024-square shadow maps. The WebGPU contract uses
64 lights and eight 2048-square maps. Runtime device limits may only lower these
ceilings.
The shared planner deterministically retains light nodes in SceneIR order, retains a
bounded shadow-capable prefix and reports every dropped identity with stable
`GPU_LIGHT_BUDGET_EXCEEDED`/`GPU_SHADOW_BUDGET_EXCEEDED` issues. Main-thread and
Offscreen production viewports publish the same report on the canvas.
Texture planning covers asset count, maximum dimension, aggregate compressed payload
and decoded RGBA GPU bytes before hash/decode/allocation. An over-budget batch leaves
the previous texture set alive. This task defines the WebGPU budget contract but does
not claim that the still-unbundled Three WebGPU renderer is available.
## Evidence
- `WEB_TEST_PORT=5548 npm --prefix web run test:render-resource-budget`: unit 3/3 and
Chromium 3/3. Both production viewports requested 20 lights, rendered 14, dropped
six, rendered three of 14 requested scene shadow maps and blocked 11; with reserved
resources, the main Three scene contained exactly 16 lights and four shadow maps.
- A 257-asset batch was rejected before decode in both viewports. A separate browser
case first decoded a valid PNG, rejected a 257-asset follow-up (258 including the
retained asset), and proved the original texture object/hash remained installed.
- M11-02 field roundtrip remained 1/1. Real 4K (64 MiB decoded) and 8K (256 MiB
decoded) texture performance cases passed; packed raster main/Offscreen passed 2/2.
- Full unit was 159/159. Typecheck, lint, production build (75 modules), local runtime
dependency checks and `git diff --check` passed.
## Implementation Hashes
- Budget protocol: `d7784690358e38b71c6ad3ccc5baf82869d897714d5257f219bbd00dc7e7b0f7`
- PBR adapter: `934874434afa6778eaf783c78650ad221a428352741f3ff7173cb3c17a0c3c14`
- Texture store: `0f18f3bcf04c5c99175d4cac8bbf1bc028fc54d635701fb6a92791e06c335fb6`
- Main viewport: `dcb502ca892fd5431cbf1a13823aec5d83a2aa0a1b40487cb66fa630c6e461b3`
- Offscreen Worker: `c95bce10f0bd73591f002c21415bfbc6f709ecbe61e97a3b5b05bcec2b0e0bef`
- Unit: `4bde90788bbf9a94317d83bd398acd321bbcf4e53e26bf40165a889bacff03a2`
- Chromium spec: `90834dfff09e61a2d32732d3a8f0db4b9c71414ac4991412d93d322119ef2de7`
- Golden: `c312bf5ed8415ad51e5cccb3a2653f65cd9fee46488be2f860e6a5a8d5a7901d`
- Package: `c718983c3f3a552f4c68d9390376777e45a3f23e6999afd5cb4ddc786a12a278`
## Rollback
Remove the render-budget protocol, planner integration, canvas reports, unit/E2E/
golden/package command and this status file; restore the former fixed 1024 shadow
configuration and per-asset-only texture checks, then restore M11 to 2/14.

56
docs/status/M11-04.md Normal file
View File

@@ -0,0 +1,56 @@
# M11-04 Status
status: done
task: Explainable image-error metrics between the Web realtime viewport and a Blender 5.2 reference
updated: 2026-08-17 America/New_York
## Scope
Schema 1 `RenderImageComparisonIR` compares equal-size display-referred `SRGB8`/
`STRAIGHT` RGBA frames. The report records mean absolute error, root mean squared
error, P95 channel error, maximum channel error, bad-pixel ratio, reference-background
foreground intersection-over-union and alpha coverage delta. Each release check keeps
its measured value, threshold, direction and pass state; a mismatch returns the stable
`RENDER_REFERENCE_MISMATCH` code. Dimensions, byte lengths, threshold ranges and a
4,194,304-pixel bound are validated before comparison.
The reference is generated by Blender 5.2 Eevee from
`tests/files/web/m11_render_reference.blend` with a fixed camera, black rough material,
World color, one-sample TAA and zero dither. The source fixture, generator and PNG are
SHA-256 bound in `tests/golden/M11-04/manifest.json`. Re-render verification compares
decoded Blender pixels exactly because PNG container bytes may differ while pixels do
not.
## Evidence
- `WEB_TEST_PORT=5552 npm --prefix web run test:render-reference`: unit 3/3,
Blender reference re-render verification passed, Chromium 3/3. Main-thread and
Offscreen reports were identical: MAE `2.0433349609375`, RMS `5.448994264342599`,
P95 `4`, maximum channel error `110`, bad-pixel ratio `0.0047760009765625`,
foreground IoU `0.9853400565736072`, alpha coverage delta `0`.
- Thresholds are MAE `4`, RMS `12`, P95 `12`, bad-pixel ratio `0.02`, foreground
IoU `0.97` and alpha coverage delta `0`. A non-empty red block with the wrong
composition is rejected as `RENDER_REFERENCE_MISMATCH`.
- `npm --prefix web run typecheck`, `npm --prefix web run lint`, production build
(75 modules), `npm --prefix web run test:status-consistency` and `git diff --check`
passed.
## Implementation Hashes
- `web/protocol/render-image-comparison.ts`: `4345cc0d02b4482201de34292cbf5198009bf615e69c80cc00bc3e76116efec2`
- `web/app/src/three-adapter/render-image-comparison.ts`: `ad97fd594d0d964e05d4da2bffc84a64ef737fef700045c252b5222250f1ffc7`
- `web/protocol/error.ts`: `e8ea483466b16debaf803ff1640ade07a858a802781e3040d3547028ecb5d19f`
- `tools/web/generate-m11-render-reference.py`: `118c98c77d98922e72660898bfb65842e11743fc77f4124c9f5088a76cb6a94a`
- `tools/web/check-render-reference.mjs`: `ead3500f054e98c2db4cd6596c983586a622e929bc8a587e9fbd38d94a7df7d1`
- `web/tests/unit/render-image-comparison.test.mjs`: `f9800745760b85700e80abc5e389f44d7f7ea3b9e41e1002f4e5449bcb620f67`
- `web/tests/e2e/render-reference.spec.ts`: `349c7da0dfd60baec80139cced52079312bd5e892a42f000ba0b4f55c6a4a61d`
- `tests/files/web/m11_render_reference.blend`: `d2bea55fe4de0b00e73a9241b4d1b0c802c2eb0e6159c0cd48e007b97b9d3963`
- `tests/golden/M11-04/blender-eevee-reference.png`: `f5c22636a232bcbb0ed0f772418cb12fced18ff72f4999804197e42caa61480a`
- `tests/golden/M11-04/manifest.json`: `e9551f86f0b9704d7be35c03f07321bec5d2481ffee4208ae766156d9c9a0135`
- `web/package.json`: `38ba6a722a2e0bc2c91d6edbd2b7df5e3609342e3b7ef2ec9646b9c6ce0d25a6`
## Rollback
Remove the M11-04 protocol, reference fixture/golden, generator/checker, unit/E2E
specs and package command; restore M11 to 3/14 and remove the bounded reference slice
from N-019. Keep M11-01 through M11-03 resource and lighting behavior unchanged.

47
docs/status/M11-05.md Normal file
View File

@@ -0,0 +1,47 @@
# M11-05 Status
status: done
task: Server-only routing for Cycles, complex Eevee and hardware render backends
updated: 2026-08-17 America/New_York
## Scope
Schema 1 `RenderRoutingRequestIR` separates execution target from endpoint
availability. Only bounded Eevee on WebGL2, or WebGPU after both browser and bundled
renderer capability checks, may return `WEB_LOCAL_BOUNDED/READY`. Cycles, complex
Eevee, Workbench final rendering and CUDA/OptiX/HIP/Metal/oneAPI requests always
target `SERVER_JOB`; the default unconfigured endpoint returns
`SERVER_JOB_UNAVAILABLE` without a local approximation. Unknown, syntactically valid
SceneIR engine identities fail closed as `PLATFORM_CAPABILITY_UNAVAILABLE`.
Setting `serverRenderAvailable:true` can make a server route `READY`, but only proves
the routing decision. It does not submit a job or claim the source/build/settings/
output hash contract reserved for M11-06.
## Evidence
- `WEB_TEST_PORT=5556 npm --prefix web run test:render-routing`: unit 3/3 and
Chromium 1/1. The browser reads `BLENDER_EEVEE` from the real M11 reference `.blend`
through the production WebEngine Worker before checking the local/server matrix.
- Cycles, complex Eevee and OptiX all remained `SERVER_JOB/BLOCKED` with
`SERVER_JOB_UNAVAILABLE` under the production default. A synthetic configured
server context returned `SERVER_JOB/READY`, never a Web target.
- Unbundled WebGPU remained `WEB_LOCAL_BOUNDED/BLOCKED` with
`WEBGPU_RENDERER_UNAVAILABLE`; an unknown engine remained server-targeted and
blocked with `PLATFORM_CAPABILITY_UNAVAILABLE`.
- Typecheck, lint, production build (75 modules) and `git diff --check` passed.
## Implementation Hashes
- `web/protocol/render-routing.ts`: `2d41d48609635c810572aaae95979df2fe9e49bbd130c60d70f64042c5d0f097`
- `web/app/src/three-adapter/render-routing.ts`: `f360c05fa62aa830a486c5be4747488fcbb5ba9b68d82b4f7187e1267da46fa6`
- `web/tests/unit/render-routing.test.mjs`: `4ac2e7a6c2265f948d971f72effb4ff9ad1c51839ab6c06d496fc2b0768c4fa8`
- `web/tests/e2e/render-routing.spec.ts`: `e5572d9ad9426f1dcc1856fe426261910093fa2f5460117e4ffdcfc7b23b10a1`
- `tests/golden/M11-05/render-routing.json`: `53c5d3a2e51f251d14953594567081597be4250be5b1236832370e74805bd35d`
- `web/package.json`: `716a5331990618892bca97f48c7eef589a5af6c5131b8cf3ae52a1b9d923644c`
## Rollback
Remove the M11-05 routing protocol/wrapper, unit/E2E/golden/package command, restore
M11 to 4/14 and remove the N-019 server-routing completed slice. Keep M11-04 realtime
reference metrics and all earlier lighting/resource behavior unchanged.

48
docs/status/M11-06.md Normal file
View File

@@ -0,0 +1,48 @@
# M11-06 Status
status: done
task: server render job provenance binding
updated: 2026-08-17 America/New_York
## Scope
`web/protocol/server-render-job.ts` defines schema 1 for a server render request and
result. A request is accepted only when the uploaded `.blend` bytes match its declared
source SHA-256 and byte length, the Blender build is a declared 5.2 build with a
content hash, and canonical bounded render settings match `settingsSha256`. The
request itself is bound by `requestSha256`.
A successful result carries the same source/revision/build/settings identity and adds
output MIME, byte length, and output SHA-256, plus `resultSha256`. Consumers recalculate
the output hash before accepting pixels. The contract does not claim a remote queue,
progress, cancellation, denoise, or Freestyle implementation; those remain later
M11 tasks.
## Evidence
- `npm --prefix web run test:server-render-job`: unit 3/3 and a real loopback server
check passed.
- The check used Blender 5.2.0 headless to open
`tests/files/web/m11_render_reference.blend` and render a PNG from the submitted
source bytes. It verified source/build/settings/output hashes and rejected source,
build, settings, and output tampering.
- Result: `server-render-job-ok blender=5.2.0 source=d2bea55fe4de0b00e73a9241b4d1b0c802c2eb0e6159c0cd48e007b97b9d3963 settings=97bb832618bd27e8763722afa0d250ef74383fb355f0ff38763930f57ba1590b output=564b95cd501da2636085aeada88814c4a3b7af7b1f0188543a1863fe34dd091e bytes=3915 source-hash=1 build-hash=1 settings-hash=1 output-hash=1 tamper=4`.
- Full regression passed: typecheck, lint, Node `168/168`, status consistency
(`12` parity blocked, `0` release blocked), release evidence (`17` records,
`0` missing), production build (`75` modules), and `git diff --check`.
## Implementation Hashes
- `web/protocol/server-render-job.ts`: `e2b5b95032b0dbcb346aa35d1a10a3b55d6c006603f16b9e72c3de45763906c5`
- `web/protocol/error.ts`: `747ea52fd96b3ec7ede9ce72c15f56fdc454836ba5bf924cc60198096d4986de`
- `web/tests/unit/server-render-job.test.mjs`: `c84f86365006360f146601839d7e1394f3f7bc7fa71dcbf62d8d4e4321cdd16d`
- `tools/web/check-server-render-job.mjs`: `6dd49fbd1be202a88fa714b2fb81305018da6df5f9d73c95e048e3697742764e`
- `tools/web/render-server-job.py`: `48950b0884a52a40ae234f8bbe590a0cb89e58c4d2a2de40ea638f491d111aff`
- `tests/golden/M11-06/server-render-job.json`: `36f083f8da7585f850e748e6ac78fc57cd6e8d59bce03d77eee3acd2fa042ae7`
- `web/package.json`: `628191a76c7614393a74969bd000faa8a6302d887265d13a1ad49dcfc401c2fc`
## Rollback
Remove the server-render-job protocol, error codes, unit/check commands, and this status
record; restore M11 to 5/14 and remove the M11-06 completed slice. Keep M11-05 routing,
M11-04 reference metrics, and all earlier lighting/resource behavior unchanged.

36
docs/status/M11-07.md Normal file
View File

@@ -0,0 +1,36 @@
# M11-07 Status
status: done
task: compositor allowlist CPU/WebGPU node goldens
updated: 2026-08-17 America/New_York
## Scope
The schema 1 WebGPU compositor allowlist contains only `CONSTANT_COLOR`, `EXPOSURE`,
`INVERT`, and `COMPOSITE`. A Blender 5.2 fixture supplies an independent constant,
exposure, invert, and integrated-chain scene through the production Main/WASM reader.
The production CPU executor and a real Chromium WebGPU compute pipeline must produce
identical `LINEAR_SRGB` Float32 RGBA bytes for every case.
Resource inputs, branching graphs, Viewer, Transform, Alpha Over, Blur, Mix, and
undeclared Blender nodes remain outside the allowlist and fail before shader
compilation. This task does not claim the M11-08 graph-preservation pipeline, HDR/color
management parity, a production compositor scheduler, or server compositor execution.
## Evidence
- `WEB_TEST_PORT=5562 npm --prefix web run test:compositor-node-golden`: Node 3/3,
Blender 5.2 Main/WASM fixture check, and Chromium WebGPU 1/1 passed.
- The four CPU and WebGPU Float32 SHA-256 values match the committed golden exactly;
maximum absolute error is `0`.
- The first run without an isolated port passed Node/native checks but did not start
Chromium because port 5173 was already occupied. It was not counted as evidence;
the complete command was rerun on port 5562.
- Full regression passed: typecheck, lint, Node 171/171, production build, status
consistency, release evidence validation, and `git diff --check`.
## Rollback
Remove the WebGPU compositor plan/executor, M11-07 fixture/goldens/tests, and this
status record; restore M11 to 6/14 and remove the N-020 WebGPU golden completed slice.
Keep the existing bounded CPU compositor and Main graph reader unchanged.

37
docs/status/M11-08.md Normal file
View File

@@ -0,0 +1,37 @@
# M11-08 Status
status: done
task: preserve unsupported compositor graphs and block execution
updated: 2026-08-17 America/New_York
## Scope
The CPU compositor and cached execution entry points now preflight the complete parsed
graph before evaluation or cache lookup. Any `UNSUPPORTED` node blocks with
`COMPOSITOR_NODE_UNSUPPORTED`, including a disconnected node that does not contribute
to the Composite output. Unsupported Blender type names are sorted and deduplicated so
the failure is deterministic.
The gate is pure: it does not remove nodes, rewrite links, advance Main revision, run a
cancellation callback, allocate an output, or accept a previously cached result. The
existing Blender 5.2 fixture preserves its Glare node name, `UNSUPPORTED` IR type, and
`CompositorNodeGlare` source type before and after the failed attempt.
## Evidence
- `WEB_TEST_PORT=5565 npm --prefix web run test:compositor-unsupported-gate`: unit 2/2,
native Main reader, and Chromium 1/1 passed.
- `WEB_TEST_PORT=5566 npm --prefix web run test:e2e -- --grep "N-020"`: 2/2 passed;
the supported synthetic CPU graph still executes, while the real graph containing a
disconnected Glare node now fails closed.
- The first new unit run was 172/173 because an async rejection used `assert.throws`.
It was corrected to `await assert.rejects`; no implementation failure was counted as
evidence. The complete Node suite then passed 173/173.
- Full regression passed: typecheck, lint, production build, status consistency,
release evidence validation, and `git diff --check`.
## Rollback
Remove the complete-graph preflight from CPU/cached execution and the M11-08 tests and
golden, restore the old N-020 smoke expectation, restore M11 to 7/14, and remove the
N-020 completed slice. Keep M11-07 CPU/WebGPU goldens unchanged.

43
docs/status/M11-09.md Normal file
View File

@@ -0,0 +1,43 @@
# M11-09 Status
status: done
task: runtime codec probes for IMAGE, SOUND, and MOVIE strips
updated: 2026-08-17 America/New_York
## Scope
Schema 1 codec probe requests and receipts bind the Sequencer strip family, canonical
MIME type, source byte length, and source SHA-256. File names and source paths are not
accepted by the probe contract, so extensions cannot grant capability. A READY receipt
must also use the family-specific backend and carry validated decoded dimensions or
audio frame metadata.
The production browser probe hashes the supplied bytes and performs a real decode:
`createImageBitmap` for IMAGE, a fixed 48 kHz `OfflineAudioContext` for SOUND, and a
muted `HTMLMediaElement` load/seek/canvas readback for MOVIE. The committed movie is a
deterministic 16x16, two-frame H.264 MP4 generated locally by FFmpeg. This task does not
claim frame-accurate WebCodecs seeking, waveform/proxy generation, A/V synchronization,
arbitrary codec support, or final encoding.
## Evidence
- `WEB_TEST_PORT=5570 npm --prefix web run test:sequencer-codec-probe`: unit 3/3 and
Chromium 1/1 passed. PNG, WAV, and H.264 MP4 returned READY with their expected
runtime backend and decoded metadata.
- Deliberately wrong file extensions had no effect. Corrupt bytes, source identity
drift, PNG bytes declared as MOVIE, a forged backend, and a forged receipt all stayed
BLOCKED with `SEQUENCER_CODEC_UNSUPPORTED` at the gate.
- The first Chromium attempt failed before business execution because `/protocol` is
not exposed by Vite; the test now imports the existing app adapter. The second decoded
all media but exposed device-rate Web Audio resampling; the production probe now uses
a fixed 48 kHz OfflineAudioContext. The complete command was then rerun successfully.
- Sequencer Main reader, N-021 browser regression, and the one-million-frame long media
performance/restart test passed. Full regression passed: typecheck, lint, Node suite,
production build, status consistency, release evidence validation, and
`git diff --check`.
## Rollback
Remove the codec probe protocol/runtime/tests/movie fixture, restore the MIME-set gate,
restore M11 to 8/14, and remove the new N-021 completed slice. Keep the V1 long-media
IMAGE/SOUND index/cache behavior and all M11-08 compositor behavior unchanged.

57
docs/status/M11-10.md Normal file
View File

@@ -0,0 +1,57 @@
# M11-10 Status
status: done
task: bind long-media proxy/cache entries to source hash and runtime decode capability
updated: 2026-08-17 America/New_York
## Scope
Schema 1 proxy-cache manifests bind the complete movie source identity, the source-bound
M11-09 READY decode receipt, an explicit SRGB8/STRAIGHT RGBA8 profile, and the source
frame into one deterministic SHA-256 cache identity. The proxy payload has an independent
byte length and SHA-256. Source drift, decode-capability drift, identity tampering, and
payload tampering therefore fail with separate stable codes.
The production browser adapter verifies the movie bytes before decode, captures only the
initial decoded HTMLMedia frame, and stores verified copies in a 64 MiB-bounded LRU. The
Chromium gate persists the RGBA8 payload and its manifest through the content-addressed
Storage Worker path, creates a new Storage Worker, and verifies the reopened bytes against
the current runtime receipt. This task does not claim frame-accurate proxy generation,
seek/scrub publication, waveform generation, A/V synchronization, or final encoding.
## Evidence
- `WEB_TEST_PORT=5573 npm --prefix web run test:sequencer-media-cache`: unit 3/3 and
Chromium 1/1 passed. The real 16x16 H.264 fixture produced one 8x8/256-byte RGBA8
proxy; its cache identity is
`68a3af14865841e81f69bd75f2605461de4819fe025d158ac3723fa0cdf31525`.
- A one-frame cache budget admitted frame 0, returned a copied hit, then evicted it when
frame 1 was inserted. `clear()` released exactly 256 bytes and left zero entries.
- Source hash drift, READY receipt metadata drift, payload mutation, and bad source bytes
returned `SEQUENCER_CACHE_SOURCE_MISMATCH`,
`SEQUENCER_CACHE_CAPABILITY_MISMATCH`, or `SEQUENCER_CACHE_HASH_MISMATCH` as
appropriate. Blocked receipts, undeclared profile fields, oversized RGBA8 profiles, and
upscaled profiles were rejected before cache insertion.
- The first unit run was 2/3 because the test incorrectly treated exactly 64 MiB as over
budget; the corrected 64 MiB plus one-row case passed. The first Chromium run completed
all business operations but sampled `statsBeforeClear` after `clear()`; the sampling
order was fixed and the full command rerun on ports 5572 and 5573.
- Regression passed: M11-09 unit 3/3 plus Chromium 1/1, one-million-frame long-media 1/1,
Sequencer Main reader, full Node 179/179, typecheck, lint, and production build (75
modules).
## Artifact Hashes
- protocol: `1dd5ee8cade2642723bac6f0c8e34d09cf178d000beba6eb63626284ee35fd5e`
- browser adapter: `57070b1a42129afdfb8121ec11a48b0792b3de9d84f7e8a78017c7d6e12343aa`
- app protocol exports: `8ca82257c758580f5fe91a3a5cd21f8ae9e9ce10f9fa71fcd7cf1f30e0b6367b`
- unit: `f4cc1171dd6875bf079e242beed846409a1d1f53b9497882aa475bd2773b43c7`
- Chromium spec: `6d9d26e3639a74b173f77ec5d67e53e5ec42b88dd6e34bda54c8168b8251423e`
- golden: `39a71f8d97f0045da6ec913b6a0231ac3363f67a420eea5ab3fabaa8f1d47c33`
- package: `2c7fb9f8657155088e4a080443b8a0ba9c13aae9f46f1807ff3b8820640c44c4`
## Rollback
Remove the proxy-cache protocol/runtime/tests/golden and package command, restore M11 to
9/14, and remove the new N-021 completed slice. Keep M11-09 runtime codec probes and the
V1 long-media index/cache evidence unchanged.

52
docs/status/M11-11.md Normal file
View File

@@ -0,0 +1,52 @@
# M11-11 Status
status: done
task: revision-gate late seek, scrub, and decode results before publication
updated: 2026-08-17 America/New_York
## Scope
Schema 1 uses one request identity for SEEK, SCRUB, and DECODE. Every request binds a
timeline ID, timeline revision, monotonically increasing request revision, operation,
and frame. A completed result must echo that identity and add a bounded source frame and
payload SHA-256. Publication is allowed only when both the timeline revision and latest
request revision still match the controller state.
The production controller advances one shared request revision across all three operation
types. Replacing a timeline invalidates every pending request before a new request begins,
and same-timeline revisions must advance monotonically. The publish callback is invoked
only after the pure gate returns PUBLISH, so stale results cannot update the visible frame
or write a decoded payload into cache. This task does not claim frame-accurate WebCodecs
seeking or the M11-14 cancellation/restart resource lifecycle.
## Evidence
- `WEB_TEST_PORT=5576 npm --prefix web run test:sequencer-media-revision`: unit 4/4
and Chromium 1/1 passed on the first complete run.
- Chromium scheduled SEEK revision 1 behind SCRUB revision 2. SCRUB published first and
the late SEEK returned `STALE/REVISION_CONFLICT`. A real H.264 runtime decode started at
timeline revision 7, completed after replacement with revision 8, and was rejected
before publish/cache. The revision-8 DECODE published; a forged result identity stayed
stale.
- The machine sequence is frozen in
`tests/golden/M11-11/sequencer-media-revision.json`: only `SCRUB@2` and `DECODE@5`
publish, and only `DECODE@5` reaches the cache-write callback.
- Regression passed: M11-10 unit 3/3 plus Chromium 1/1, M11-09 unit 3/3 plus Chromium
1/1, one-million-frame long-media 1/1, full Node 183/183, typecheck, lint, and production
build (75 modules).
## Artifact Hashes
- protocol: `326f1525f50af3b2c4132ff58508ed6174ba3aa4a5075b2a6660e549c280826a`
- production controller: `29a44404bb28e46d67439eec0622a69ce4cd7264ce45da77a3f320c8687b7006`
- app protocol exports: `be2eca8482a60e437878b99f69d3178f47fee61618886d141760320e3a3ed1e1`
- unit: `2099f738120897dfbb8662741831774b5f9150d51924dfa7832907177de5e7`
- Chromium spec: `e47f105360ce0e2d4d561d3cf790ceec9f41dd6bac7361965914ada307db843c`
- golden: `672b4fde6e27f8b15cd51d839cacb5efb128c0d0f4c7f6aa4e184c713f9b45e5`
- package: `5d17b17d67ee587191552c3dfe226cd8f2e5a872791224d8e6c22aafa1177ebe`
## Rollback
Remove the media revision protocol/controller/tests/golden and package command, restore
M11 to 10/14, and remove the N-021 revision-gate slice. Keep M11-09 runtime probes,
M11-10 proxy-cache identity, and the V1 long-media generation behavior unchanged.

58
docs/status/M11-12.md Normal file
View File

@@ -0,0 +1,58 @@
# M11-12 Status
status: done
task: keep final video encoding on an explicit server-export capability route
updated: 2026-08-17 America/New_York
## Scope
Schema 1 final-export requests bind the Main timeline ID/revision, source `.blend`
SHA-256, bounded frame range and rational frame rate, output dimensions, and one declared
container/video/audio codec combination. The canonical encoding settings and the complete
source identity receive separate SHA-256 hashes, so source revision drift and settings
drift produce different request identities.
The only route is `SERVER_EXPORT`. A missing endpoint fails closed with
`SEQUENCER_EXPORT_SERVER_UNAVAILABLE`; a configured endpoint returns
`SERVER_EXPORT_REQUIRED`, not local success. Browser `VideoEncoder` availability is
recorded for diagnostics only and cannot change the route or the invariant
`localEncoding=BLOCKED`. This task does not claim a server encoder implementation,
upload/progress/cancellation, audio mixing, or encoded-output verification.
## Evidence
- `WEB_TEST_PORT=5579 npm --prefix web run test:sequencer-final-export`: unit 4/4 and
Chromium 1/1 passed on the first complete run.
- Chromium opened the real Blender 5.2 `sequencer_scene.blend` through WebEngine Main and
bound timeline `sequencer:scene:SequencerScene`, revision 1, frames 1..250, 24000/1001
FPS, and source SHA-256
`5f5212487bb6b5df62b5ca915c75133f1ca45678614712ea4c7d902d823cd90c`.
- The fixed settings hash is
`01e39d1fdd88c75aecad7bc48a2b756bbcd645929963528b42ceb180b51e5566`;
the source/timeline/settings request hash is
`20d3a9e333804e014a993a9892a557842293fc44902229dd6eb65661f0691cfc`.
- No-server, server-without-encoder, server-with-injected-encoder, and actual Chromium
runtime cases all retained the server route and blocked local encoding. Undeclared
fields, invalid codec combinations, over-budget ranges, and environment drift returned
`SEQUENCER_EXPORT_REQUEST_INVALID`.
- Regression passed: M11-09 unit 3/3 plus Chromium 1/1, M11-10 unit 3/3 plus Chromium
1/1, M11-11 unit 4/4 plus Chromium 1/1, one-million-frame long-media 1/1, Sequencer Main
reader, full Node 187/187, typecheck, lint, and production build (75 modules).
## Artifact Hashes
- protocol: `559516163ce9a9c60917033455c71a1ae6be2f34666371f12723a056ddfdb3dd`
- browser adapter: `f9f193eb71afa48a23b4eb618cd98b5c0064c59943b3652fb6a601b545ac175c`
- app protocol exports: `d30783528fabaaefe8835998e11d75da357d265ee06db67713e57e4b85b58d7d`
- error contract: `747894b216db59c753b24ce85eb946925e64340f63684311e5d9628c9a6a9c8e`
- unit: `6f5030852e89160fd898b2aff4dc9911963342bd6c2301a2c0e8a3d518ddbcbd`
- Chromium spec: `d8cba79c9a330c1e5c777c8f0ed08362b29f50702c31d8229fd65f4304bce363`
- golden: `c0061f82ef156e53ad4dfc6aedf7c6a8f53e616d8d9fa1435fd9491ba3d07e01`
- package: `83ee5bfa50a0e5b742048d4563ae815171232a121cf3df4a1e813f21313a6509`
## Rollback
Remove the final-export protocol/browser adapter/tests/golden and package command, remove
the two export error codes and SequencerTimeline exports, restore M11 to 11/14, and remove
the N-021 final-export completed slice. Keep M11-09/10/11 decode, proxy, and revision
gates and the existing `sequencerRuntimeCapabilities().localEncoding=BLOCKED` contract.

58
docs/status/M11-13.md Normal file
View File

@@ -0,0 +1,58 @@
# M11-13 Status
status: done
task: verify AudioContext suspend, resume, missing-device, and mute recovery
updated: 2026-08-17 America/New_York
## Scope
Schema 1 audio-session reports separate context state
(`UNAVAILABLE/SUSPENDED/RUNNING/CLOSED`) from output state
(`BLOCKED/SILENT/ENABLED`). Every initialize, resume, suspend, mute, device-recovery, and
close operation advances a monotonic revision. Reports bind the mute flag, effective gain,
and a stable issue code, and reject contradictory states such as enabled output from a
suspended or muted context.
The production `SequencerAudioSession` owns one `AudioContext` and master `GainNode`.
Mute schedules gain zero; unmute restores the configured nominal gain. Suspend and resume
must reach the corresponding real context state before output can be enabled. Missing API,
constructor failure, and resume/suspend failure remain silent with structured errors.
Closing first zeros and disconnects the gain node, closes the context, drops all references,
and reports `CLOSED/SILENT`. This task does not claim waveform generation, A/V sync,
multi-strip mixing, output-device selection, or encoded audio export.
## Evidence
- `WEB_TEST_PORT=5586 npm --prefix web run test:sequencer-audio-recovery`: unit 3/3 and
Chromium 1/1 passed on the first complete run.
- The Chromium case creates a real `AudioContext` inside a trusted click handler, then
verifies suspend, mute, resume-while-muted, unmute, second suspend/resume, and close.
Muted output remains `RUNNING/SILENT` at gain 0; unmute restores
`RUNNING/ENABLED` at gain 0.75; close reports `CLOSED/SILENT` at gain 0.
- A scope without `AudioContext` returns
`UNAVAILABLE/BLOCKED/SEQUENCER_AUDIO_DEVICE_UNAVAILABLE`. The deterministic unit path
then makes a constructor available and verifies `recoverDevice()` plus resume. Constructor
and resume failures stay silent; malformed state reports return
`SEQUENCER_AUDIO_CONTEXT_INVALID`.
- Regression passed: M11-09 unit 3/3 plus Chromium 1/1, M11-10 unit 3/3 plus Chromium
1/1, M11-11 unit 4/4 plus Chromium 1/1, M11-12 unit 4/4 plus Chromium 1/1,
one-million-frame long-media 1/1, Sequencer Main reader, full Node 190/190, typecheck,
lint, and production build (75 modules).
## Artifact Hashes
- protocol: `0b1b75c7630b599723914bbced9ee0a107bd8c0ac4382ced1f1c5114dac8fd01`
- production controller: `c09914d2581ee454e042ed6a9ef5b8c0f6f7b0688eadb1deff45163263742109`
- app protocol exports: `e630f647ddd3680a04e07ce7388cf03f54268d4e2d839aa5991cb9ceb6a53bda`
- error contract: `6589f712058a39a2177cb174cc355c01777a549d254c4b9eac2301a344c619f2`
- unit: `1a888d1b19a3d12091e6edd05287a5bcc19912415cf7f2ed73a028d12846a298`
- Chromium spec: `1b94ea07ab5fafb0b06fbd73742c77d6b69d745a546fc60def85857844afc719`
- golden: `584aea46687fd5cec8d89e8fac51314acc9a8d05af2a8d762e8d6a74483e0940`
- package: `8d87390f9b1b7382bf9c0ba189f7a9d95daa3ee15d1d422a8d856fa4eb6d3cca`
## Rollback
Remove the audio-session protocol/controller/tests/golden and package command, remove the
four audio error codes and SequencerTimeline exports, restore M11 to 12/14, and remove the
N-021 audio-recovery completed slice. Keep M11-09 offline audio decoding and M11-12 final
export routing unchanged.

57
docs/status/M9-14.md Normal file
View File

@@ -0,0 +1,57 @@
# M9-14 Status
status: done
task: Curve、Grease Pencil、Paint 三编辑域故障恢复闭环
updated: 2026-08-16 America/New_York
## Scope
`web/protocol/editing-domain-recovery.ts` freezes one schema-v1 evidence shape for
the three domains. Each report must prove Main identity, Worker generation, OOM
fault mapping/cleanup, WebGL2 resource release/reinitialization, and a visible
small-scene recovery. Save/reopen may reset the logical Main revision; the
content identity SHA-256 remains the authoritative recovery check.
## Evidence
- Curve fixture: `tests/files/web/nonmesh_scene.blend`, SHA-256
`ae8ef85d606aa120ce6b7611fc03407fd80bb60311e94a6ef73f6384d0b6c8b4`.
- Grease Pencil fixture: `tests/files/web/modifier_grease_pencil_scene.blend`, SHA-256
`3a8525077807f9178dac3a5ba84b524e5f8e80874b15c54e2a6658ecb80315a3`.
- Paint fixture: `tests/files/web/attribute_scene.blend`, SHA-256
`12fa75bb79f8c38e660d3d2a8fc9cc16dd3df4fc9208b0e2ad94fb1f8aa68f71`.
- `WEB_TEST_PORT=5435 npm --prefix web run test:editing-domain-recovery`: unit 3/3 and
Chromium 1/1. All three reports returned `RECOVERED` for Worker restart, OOM,
GPU release and small-scene stages.
- Full unit suite: 127/127 tests passed.
- `npm --prefix web run typecheck`: passed.
- `npm --prefix web run lint`: passed.
- `npm --prefix web run build`: passed, Vite production bundle transformed 73 modules.
- `git diff --check`: passed.
## Implementation hashes
- Protocol: `42ab53dde3161ec31ca7de420046a2c06f58408b3ac4919c9904e02d875af258`.
- Browser runner: `2bb9805795fc2104e5443207a08951ac7684e67913064d6795c9d165b0c54c7e`.
- Unit test: `ca565149e7769b40f59e865e42d2dfb0f4ecb2a4655371004678c14536ec9ef6`.
- Chromium test: `7ab3421d1b44eb78d13525f67a26f1f6ed20816ae9b43c010c9ed3ebdf32c09a`.
- Golden manifest: `f548052f39f6259f30b0569528160512aad955e61e8e8559e0d553c30d5619f9`.
## Recovery boundaries
- Worker restart uses a fresh `WebEngineClient`/Worker and reopens the saved bytes;
stable domain identity is required, while revision reset on a newly serialized
`.blend` is accepted and recorded.
- OOM uses the token-isolated `GPU_GEOMETRY_UPLOAD` fault point. A tracked texture
lease is released before close; the matching error is
`GPU_GEOMETRY_BUDGET_EXCEEDED`, with zero live temporary resources.
- GPU release disposes geometry, material and renderer once, then creates a fresh
WebGL2 renderer and requires non-zero pixels for the same domain probe.
- Small-scene recovery requires the reopened data IDs, identity hash, object count
and visible pixels to match the committed baseline.
## Rollback
Remove the M9-14 protocol/runner/tests/golden and package script together. Existing
Curve, Grease Pencil and Paint writers remain unchanged; no persisted schema or
release metadata migration is required.

View File

@@ -7,14 +7,46 @@
本轮已交付版本化 `GeometryNodeGraphIR`、node/socket/link/domain 校验、支持节点白名单、
循环依赖和自递归 group 检测。Object/Collection/Image Info 资源现在按 SceneIR stable ID、
资源类型、owner cycle 和 linked/missing/corrupt 沙箱校验。合法图仍只通过协议门native
lazy-function evaluator 未启用时返回 `CAPABILITY_MISSING`
资源类型、owner cycle 和 linked/missing/corrupt 沙箱校验。保存于 Blender Main 的 allowlist
图已启用有界 native evaluator;任意图写回仍返回 `CAPABILITY_MISSING`
`SimulationCacheManifestIR` 已绑定 graph/source blend/input hash、Blender 5.2、完整帧范围、
连续 byte range、总 payload 和逐帧 SHA-256。StorageWorker schema 6 使用 OPFS 内容寻址资产
`simulation_manifest` 索引,写入前核对当前 committed blendWorker 重启后重新逐帧验证;
缺失、版本不符和损坏均结构化阻断。
M10-01 已把真实 Blender Main 图拓扑、socket 默认值、link 和稳定 node/socket ID 发布到
SceneIRM10-02 将 16 项 allowlist 固定为 schema 1。M10-03 用 11 个 Blender 5.2 desktop
fixture 覆盖全部 16 项节点,并在 Node full-Main WASM 与 Chromium Worker 打开、求值、保存、
重开路径逐项比较 topology、position、bounds 和 point float attribute。当前误差为 0门限为
position max `1e-5`/RMS `1e-6`、attribute `1e-6`、bounds `1e-5`。真实 Simulation Zone 节点
仍返回 `GN_NODE_UNSUPPORTED` 并保留原图、graph hash 和 Main revision。
仍未声明:浏览器生成 bake、WASM Simulation Zone 求值、cache frame seek 接入 GN modifier、
任意 lazy-function/field/instance graph。验收`npm --prefix web run test:simulation-cache`
`npm --prefix web run test:capability-gates`
M10-04 已冻结 field materialization schema 1POINT/EDGE/FACE/CORNER/CURVE/INSTANCE/LAYER
分别具有元素上限,单批最多 64 fields、32 次 domain conversion、4,000,000 个目标元素和
64 MiB单 field 超过 65,536 scalar 时禁止 JSON transport。Depsgraph 的真实 point Float
attribute 回执携带 schema、domain cardinality、元素/scalar/byte 数并与 payload 交叉校验;
基数漂移、字节漂移、隐藏 JSON 数组和未知 domain 均 fail-closed。
M10-05 将 `SimulationCacheManifestIR` 升为 schema 2加入 committed `sourceRevision` 和可由
graph ID/hash、source blend hash、source revision、input hash、Blender 版本及 frame range
复算的完整 `revisionHash`。cache key 不再截取 hash 前缀,而是 `sim2-` 加完整 revision hash
Storage Worker 的 put/read/frame/list 均在 project lock 内核对当前 committed revision/hash
revision 只保留在存储中、不进入当前列表且显式读取返回 `SIMULATION_CACHE_REVISION_MISMATCH`
manifest/frame 未声明字段、legacy schema、graph/source/input/range 漂移均 fail-closed。
`SimulationCacheManifestIR` 已绑定 graph/source blend/input/revision hash、Blender 5.2、完整帧范围、
连续 byte range、总 payload 和逐帧 SHA-256。StorageWorker schema 7 使用 OPFS 内容寻址资产、
`simulation_manifest` 索引和 `simulation_quarantine` 隔离 store写入前核对当前 committed blend。
每个 Worker 生命周期必须先完整复验 cache 才允许逐帧播放;播放/写入支持 AbortSignal取消不发布
manifestLRU 按 `lastAccessAt/createdAt/cacheKey` 确定性淘汰并保护 active playbackWorker 重启
重新验证manifest 或 payload 损坏会从可播放索引移入 quarantine列表仍返回其他有效 cache。
M10-15 为 GN 和 Simulation 分别增加隔离 Chromium Worker 门GN 以 10 次 512-node
parse/validate、超 node budget 和 dependency cycle 覆盖性能/OOM-prevention/恶意输入Simulation
以 64 帧逐帧 hash、16 GiB+1 manifest 和未声明字段覆盖同三类门。两域均在失败后由同 Worker
完成小输入恢复。
仍未声明:任意 field/domain 实际求值、完整 lazy-function runtime、图写回、浏览器生成 bake、
WASM Simulation Zone 求值、cache frame seek 接入 GN modifier 和任意实例图。验收:
`npm --prefix web run test:geometry-node-evaluator-golden`
`npm --prefix web run test:geometry-node-field-budget``npm --prefix web run
test:simulation-cache-identity``npm --prefix web run test:simulation-cache`
`npm --prefix web run test:simulation-cache-performance``npm --prefix web run
test:simulation-cache-lifecycle``npm --prefix web run test:capability-gates`
`npm --prefix web run test:m10-domain-gates`

View File

@@ -1,6 +1,6 @@
# N-013 Shader Node 图
状态:`in_progress七节点 Main + 常量 PBR 映射子集)`
状态:`in_progress七节点 Main + 有界 WebGL2 compiler 子集)`
详细分解、ShaderIR、typed socket 校验、Main 写回、Web 编译、GLB 映射和验收命令见
[`docs/UNDECLARED_CAPABILITIES_EXECUTION_PLAN.md`](../UNDECLARED_CAPABILITIES_EXECUTION_PLAN.md)。
@@ -16,6 +16,19 @@ operation 保存重开其他运算继续结构化阻断。RGB/Value 到 Princ
Roughness、Metallic 的有限常量链接现可确定性映射为 glTF PBR factor缺值、越界、
重复输入和未知链接继续返回 `SHADER_GRAPH_UNMAPPABLE`。Mix、Mapping、Texture
Coordinate、Bump 及任意节点仍返回 `SHADER_NODE_UNSUPPORTED`。node properties 仅开放
Math operation;受限 Web 编译、完整色彩管理、完整 GLB 图映射和桌面渲染 golden 仍为 planned。
验收:`npm --prefix web run test:authoring-roundtrip``npm --prefix web run test:capability-gates`
Math operation。M10-07 新增 schema 1 有界 compiler只把 RGB/Value 常量、六项 Math、Image
Texture、Normal Map、Principled 和 Output 编译为 `WEBGL2_THREE_PHYSICAL` reportNative reader
发布 graph SHA-256Worker 在 Main transaction 前编译并失败关闭,主线程和 Offscreen 复用同一
`createPBRMaterial` 入口。M10-08 为 report 增加 graph/texture/color-space/backend 绑定的
`compileKey`M10-09 由 `PBRMaterialPipeline` 保留上一份可用材质并记录失败报告。节点/链接/
深度/纹理/标识预算固定M10-10 将任意未知节点名称归一化排序后返回稳定
`PBR-012/ARBITRARY_SHADER` capability block未知节点不被删除或静默降级。
M10-15 在独立 Chromium Worker 中连续完成 100 次有界 compile、129-node 超预算阻断、恶意
cycle 阻断和同会话小图恢复performance/OOM-prevention/malicious-input 三门均使用现有 compiler
不以 mock 成功替代材质编译。
完整色彩管理、sampler/alpha/tangent、WebGPU、完整 GLB 图映射和桌面渲染 golden 仍为 planned。验收
`npm --prefix web run test:shader-compile``npm --prefix web run test:authoring-roundtrip`
`npm --prefix web run test:capability-gates``npm --prefix web run test:m10-domain-gates`
以及 Shader GLB mapping E2E。

View File

@@ -13,8 +13,21 @@ Blender `NLASTRIP_FLAG_REVERSE`reader 可恢复该标志;反向正例得到
`[0.555556, 1.111111, 1.666667]`
同一正例已扩为 `repeat=2`frame 5 与第二周期 frame 14 的 native Depsgraph 矩阵一致。
M10-11 另以桌面 Blender 5.2 生成只读 fixture同一 Track 包含 scale=2 的正向 Clip 和
reverse + repeat=2 的 Clip。桌面、Node WASM 与生产 Chromium Worker 在 12 个边界/周期帧上
逐矩阵比较,最大误差为 0每帧求值前后的 Track/Strip/Action JSON 完全一致。本切片不调用
`setNLAStack`,不提前关闭细粒度 operator 工作。
当前只支持单对象 Action Clip、正 scale、Replace/Add/Multiply/Combine 和有限外插。
Transition/Meta/Sound、Animated Time、Drivers、细粒度拖动/resize 命令、NLA UI、
M10-12 仅新增 `moveNLAStrip`,以 revision gate 和单次 Main transaction 保留时长移动现有 Clip
并已通过 undo/redo 与保存重开。Transition/Meta/Sound、Animated Time、Drivers、其余
create/remove/resize/active 命令、NLA UI、
骨骼/约束混合矩阵及 GLB 展开仍为 planned/结构化阻断。
验收:`npm --prefix web run test:authoring-roundtrip``npm --prefix web run test:capability-gates`
M10-15 冻结 4,096 tracks、每 track 16,384 strips、总 65,536 strips 及 ID/name/reason 字节预算,
拒绝未声明字段。隔离 Chromium Worker 对 20 次 256-strip stack、超 track budget、恶意字段和
同会话小 stack 恢复分别建立 performance/OOM-prevention/malicious-input 门。
验收:`npm --prefix web run test:nla-evaluation-golden``npm --prefix web run test:nla-operator`
`npm --prefix web run test:authoring-roundtrip`
`npm --prefix web run test:capability-gates``npm --prefix web run test:m10-domain-gates`

View File

@@ -1,9 +1,9 @@
# N-015 非 Mesh 几何数据块
状态:`BLOCKED / in_progress`VDB core 已覆盖 server、OPFS、Float32 WebGPU、显式 GPU 分页、
有限 grid 材质与 Main 属性重开;自动 demand paging 缺页重绘、联合重开和发布 golden 仍阻断)
状态:`BLOCKED / in_progress`M8 自动 demand paging、联合重开、64 MiB sparse 门和三轴 density
golden 已完成;深度合成、完整 Volume 材质/GLB/USD 与 512 MiB/1 GiB 联合故障矩阵仍阻断)
更新时间2026-08-14
更新时间2026-08-16
## 当前声明
@@ -14,9 +14,10 @@ topology editor。
Volume 采用“桌面/服务端 OpenVDB 转 NanoVDB浏览器分块读取并以 WebGPU 渲染”的固定架构。
浏览器不直接解码 OpenVDB当前已完成真实资源 catalog、独立 OpenVDB 13 -> NanoVDB 32 desktop
converter、受控 server job、转换/清单/分块协议、OPFS 绑定/重开、Float32 CPU/WGSL 树遍历、
有界体积积分、主线程/Offscreen 生产视口、显式 hash 校验 GPU 分页/LRU、有限 grid 材质
Volume Main 属性保存重开自动缺页重绘、联合重开和发布 golden 尚未完成,因此 N-015 整体保持
`BLOCKED`
有界体积积分、主线程/Offscreen 生产视口、显式 hash 校验 GPU 分页/LRU、有限 grid 材质
Volume Main/asset binding 联合重开自动缺页重绘、64 MiB sparse 门和 OpenVDB/WebGPU 三轴
density golden。深度合成、完整 Volume 材质/GLB/USD 与更大真实 bundle 联合故障矩阵尚未完成,
因此 N-015 整体保持 `BLOCKED`
| 数据族 | Reader | Web 预览 | 当前门 |
| --- | --- | --- | --- |
@@ -26,7 +27,7 @@ Volume Main 属性保存重开。自动缺页重绘、联合重开和发布 gold
| Metaball | 元素类型、位置、半径、尺度 | bounded sphere proxydepsgraph implicit mesh | `READY` |
| PointCloud | position/radius/POINT 属性 | WNM 分块 Points | `READY`(空数据为 `summary-only` |
| Curves/Hair | position/radius/curve/POINT 属性 | WNM 分块 Points/curve line | `READY`(缺数据阻断) |
| Volume | path/grid metadata + VDB/NanoVDB 转换契约 | WebGPU 可渲染真实 density/color/temperature/emission双生产视口已接入 | `BLOCKED`(自动缺页/联合恢复/golden |
| Volume | path/grid metadata + VDB/NanoVDB 转换契约 | WebGPU 可渲染真实 density/color/temperature/emission自动分页、联合重开与三轴 density golden 已接入 | `BLOCKED`(深度合成/完整材质与 IO/更大联合故障矩阵 |
## 已实现任务
@@ -104,6 +105,16 @@ Volume Main 属性保存重开。自动缺页重绘、联合重开和发布 gold
20. GPU grid 支持初始空驻留、经 manifest chunk hash 校验的显式单页加载、页表间接寻址、确定性
LRU 替换和设备重建后按需页重放;多材质 grid 按同一 manifest `maxResidentBytes` 分配页槽,
Chromium 以 GPU 数据读取验证被触碰页保留、旧页淘汰和恢复后内容一致。
21. GPU feedback buffer、revision gate、manifest range/hash page request、同页请求合并、frame pin
LRU、渐进重绘预算、网络精确 offset 续传、Worker restart 和 device-loss 可见页回放已形成自动
demand paging 最小闭环hash 失败、stale feedback、取消和 OOM 均不污染 resident/page table。
22. 64 MiB sparse HTTP bundle 以 4 MiB page 实际 range 读取,首 page、总耗时、峰值工作集和取消
延迟均有 Chromium 证据;取消后 coordinator/consumer 归零,未将逻辑 64 MiB fixture 写入仓库。
23. 同一保存 hash 下Volume Main 属性、OPFS project revision、NanoVDB binding 和 bundle/grid bytes
在 Worker 清空后联合重开;主线程和 Offscreen 两个生产视口都从恢复 asset 输出非空像素。
24. OpenVDB 13 native `density` reader 按 `volume-wgsl-v1` 有界积分生成 X/Y/Z 三轴 64x64 RGBA8
desktop/Chromium 发布 golden主线程与 Offscreen WebGPU 对 49,152 bytes 的最大通道误差、MAE、RMS 和 alpha 覆盖
差均为 0。门限仍固定为 2/0.1/0.5/0.002,漂移返回 `NANOVDB_GOLDEN_MISMATCH`
## 后续分解
@@ -117,9 +128,9 @@ Volume Main 属性保存重开。自动缺页重绘、联合重开和发布 gold
raycast、多 handle preview、handle-local gizmo 与单次 Main 写回已完成。
4. N-015-D2evaluated preview 与源控制笼分层显示、WebGPU 等价。
5. N-015-B3/D2/E1Volume真实资源、desktop/server converter、可续传 HTTP range、OPFS、
有界网络/Worker/quota/tamper/device-loss 故障门、Float32 WGSL core、有限 grid 材质、显式 GPU
分页/LRU、resident/page-table OOM 恢复和 Main Volume 属性重开已完成继续执行自动缺页重绘、联合恢复、GLB/USD loss、大 bundle 和
desktop/Chromium golden未完成项保持阻断
有界网络/Worker/quota/tamper/device-loss 故障门、Float32 WGSL core、自动 GPU paging/LRU、
resident/page-table OOM、Main/binding/双视口联合重开、64 MiB sparse 和三轴 density golden 已完成;
继续执行场景深度合成、完整 Principled Volume/GLB/USD loss 与 512 MiB/1 GiB 联合故障矩阵
6. N-015-E2100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quotaChromium
64 KiB 真实 quota、失败后旧 revision 保持、Worker restart 恢复已通过。后续浏览器验收
仅以 Chromium 为基线,不配置 Firefox/WebKit。
@@ -131,7 +142,8 @@ Volume Main 属性保存重开。自动缺页重绘、联合重开和发布 gold
- Metaball sphere proxy 不得用于导出或保存为求值曲面。
- Volume/VDB 不得生成占位几何;协议 fixture 不得作为真实 decoder/renderer 证据。
- 浏览器构建不得通过开启 `WITH_OPENVDB` 绕过 desktop/server 转换边界。
- 自动缺页重绘、联合恢复、完整故障门和发布 golden 未齐前VDB 与 N-015 保持 `BLOCKED`
- 场景深度合成、完整 Volume 材质/IO 和更大真实 bundle 联合故障矩阵未齐前VDB 与 N-015 保持
`BLOCKED`M8 完成不能自动把 N-015 全域标为 `COMPLETE`
- Metaball sphere proxy 不得进入 GLBGLB 只能消费 depsgraph evaluated mesh。
- 任意新增 Main authoring 命令在 save/reopen 与 undo/redo 完成前不得开放 UI。
@@ -161,6 +173,10 @@ npm --prefix web run test:vdb-opfs
npm --prefix web run test:vdb-webgpu
npm --prefix web run test:vdb-viewport
npm --prefix web run test:vdb-faults
npm --prefix web run test:nanovdb-sparse-performance
npm --prefix web run test:nanovdb-volume-roundtrip
npm --prefix web run test:nanovdb-render-golden
node tools/vdb/generate-volume-golden.mjs --check
WEB_TEST_PORT=5349 npm --prefix web run test:e2e -- -g "real OPFS quota|reports quota exhaustion"
WEB_TEST_PORT=5201 npm --prefix web run test:e2e -- -g "N-015|non-mesh"
```

View File

@@ -1,7 +1,8 @@
# N-016 Grease Pencil
状态:`BLOCKED`(协议、有限 reader/Main transaction、当前帧及相邻帧 onion preview 已落地;
完整 2D canvas/Dope 编辑、modifier 语义和桌面 golden 仍阻断)
状态:`BLOCKED`(协议、有限 reader/Main transaction、当前帧及相邻帧 onion preview
current-drawing marquee 和有限 2D point canvas 已落地;完整 2D stroke/segment/Dope 编辑、
modifier 语义和桌面 golden 仍阻断)
## 已验证切片
@@ -22,8 +23,8 @@
drawing渲染真实 3D stroke/闭环Chromium 主线程与 OffscreenCanvas 均通过非空像素门。
6. N-016-Deditor 部分editor context schema 绑定 data/layer/frame、onion 开关、stroke/
point selection 和 revision拒绝 stale/重复/超 1M selectionProperties 面板已连接真实 Main
layer create/remove、当前 frame insert/remove 和整帧 clear transaction。完整 2D stroke/point
画布选择和 dope sheet integration 仍阻断。
layer create/remove、当前 frame insert/remove 和整帧 clear transaction。完整 2D stroke/segment
lasso 和 dope sheet integration 仍阻断。
7. N-016-D点编辑部分Properties 面板可在当前 layer/frame 选择实际 stroke/point执行
有界 X 轴平移;协议先校验 point identity、revision、有限坐标和 1M 点预算,再保留 radius、
opacity、vertex color、cyclic、material index 并合并为一个 `setGreasePencilStrokes` Main
@@ -40,11 +41,29 @@
11. N-016-D重启部分真实 Grease Pencil fixture 经首个 WebEngine Worker 点编辑和 `.blend`
保存后,终止 Worker 并由全新 Worker 重开data/layer/drawing identity、位置、radius、opacity
均重新解析验证。
12. N-016-DM9-06 marqueeMain reader 为 drawing、stroke 和 point 发布稳定 ID并发布 Blender
active layer旧文件未记录 active layer 时只读快照确定性选择首个实际 layer不修改 Main。
纯 marquee 合同绑定 Main revision、当前 data/layer/frame/drawing、归一化矩形和 1M candidate
预算,跨 drawing、重复/伪造 ID、stale revision 和超预算均稳定失败关闭。生产 UI 只在活动、
可见且未锁定 drawing 开放框选,主线程与 OffscreenCanvas 共用同一合同;真实 `.blend` 均选中
4 个稳定 point ID 和 1 个 stroke ID选择不递增 Main revision。
13. N-016-DM9-07 shared selection`grease-pencil-selection` schema 将当前 drawing、稳定
point ID、来源和独立 selection revision 绑定为唯一状态2D point canvas、主线程 3D viewport
和 OffscreenCanvas Worker 的 point/marquee 事件都携带 base selection revision迟到结果返回
`REVISION_CONFLICT`,不修改 Main。真实 fixture 从 2D 选择 1 点后由 3D 框选 4 点revision
`0 -> 1 -> 2`两条生产后端回执与画布高亮一致Main revision 保持不变。
14. N-016-DM9-08 layer/frame reorder`grease-pencil-reorder` schema 将 layer 方向移动和
drawing frame 移动绑定到稳定 data/layer/drawing ID 与 Main base revisionstale revision、
边界 no-op、伪造 drawing、已占用 target frame 和正负 1,000,000 之外帧号均在 Main 前失败关闭。
生产 Properties UI 的 layer 上下移动与 frame target 移动各只提交一次 Main transaction真实
fixture 经 undo/redo、OPFS save、close/recover 后保持 Blender 5.2 desktop golden 的 layer 顺序、
frame 12 和原 drawing identity。完整 group hierarchy、frame duplicate 与 Dope Sheet 拖拽仍阻断。
## 仍然阻断
- N-016-Cmaterial datablock 事务、onion fade/range 完整语义和完整 Grease Pencil modifier reader。
- N-016-D完整 2D canvas/marquee 和完整 timeline/dope 编辑3D current-frame 点 raycast、
- N-016-D完整 2D canvas 的 stroke/segment 选择、套索和完整 timeline/dope 编辑仍阻断;有限
2D point canvas 与 3D 共享 selection revision、3D current-drawing point marquee、点 raycast、
多点高亮、连续 preview、单次 gizmo commit、有界 drawing frame 导航和 worker restart 已完成。
- N-016-Edesktop drawing hash、Chromium 像素 golden、GLB/USD loss report 和 OPFS。
@@ -54,6 +73,9 @@
npm --prefix web run typecheck
npm --prefix web run lint
npm --prefix web run test:grease-pencil
WEB_TEST_PORT=5404 npm --prefix web run test:grease-pencil-marquee
WEB_TEST_PORT=5411 npm --prefix web run test:grease-pencil-selection
WEB_TEST_PORT=5416 npm --prefix web run test:grease-pencil-reorder
WEB_TEST_PORT=5205 npm --prefix web run test:grease-pencil-editor
WEB_TEST_PORT=5202 npm --prefix web run test:e2e -- --grep "N-016 Grease Pencil"
```

View File

@@ -1,7 +1,9 @@
# N-017 Paint 与权重
状态:`BLOCKED`stroke/patch schema、真实 Three raycast 命中、选择/遮罩门和 Main 顶点色/权重
transaction 已落地PBVH brush、texture paint 和 GPU/image 生命周期未实现)
状态:`BLOCKED`stroke/patch schema、真实 Three raycast 命中、GPU depth 可见性、选择/遮罩门、
单 undo 分块 pointer session、Main 顶点色/权重、浏览器 packed/UDIM 原子资产绑定和
PBVH WASM 入口显式能力门已落地;
PBVH brush、Blender Main image tile 写回、完整色彩转换和 GPU/image 生命周期仍未实现)
## 已验证切片
@@ -12,9 +14,11 @@ transaction 已落地PBVH brush、texture paint 和 GPU/image 生命周期未
tile 256 MiB非法 barycentric、pressure、color、weight 和非有限数会结构化拒绝。
4. N-017-B部分`setVertexColors` 在单用户 Mesh 上创建/转换 `POINT``CORNER`
`FLOAT_COLOR` 属性,并更新 active color`setVertexWeights` 在真实 Object/Mesh 上创建顶点组、
写入/移除 `MDeformWeight`,支持逐顶点 normalize
5. 顶点色和权重都已通过 Main undo/redo 与 `.blend` save/reopen`mirror:true` 在没有已验证
对称拓扑映射时返回 `CAPABILITY_MISSING`,不会伪造镜像结果。
写入/移除 `MDeformWeight`,支持逐顶点 normalize、最多 32 个 influence 的 limit以及
基于局部坐标 reciprocal map 的 mirror。
5. 顶点色和权重都已通过 Main undo/redo 与 `.blend` save/reopen镜像在没有已验证对称映射时
返回 `CAPABILITY_MISSING`不会伪造结果。limit/normalize/mirror 的组合已与 Blender 5.2
desktop golden 逐顶点比较。
6. `paintHitFromIntersection` 消费真实 Three `Raycaster` intersection按 indexed/non-indexed
triangle 解析顶点,输出对象/data stable ID、source face、局部 barycentric、世界法线、
插值 UV 与 pressure测试使用真实 BufferGeometry 射线,不注入伪命中。
@@ -24,7 +28,7 @@ transaction 已落地PBVH brush、texture paint 和 GPU/image 生命周期未
8. N-017-A/C协议边界有界 CPU brush 对候选顶点执行 smoothstep falloff、遮挡标记过滤和
front-face 法线门;它不宣称 PBVH 加速或桌面 brush 等价。`UdimTilePatchIR` 约束 1001-1999
tile、RGBA8、色彩空间、尺寸/256 MiB 预算、revision、base/result SHA-256并在内存中验证
原子 range patch;尚未绑定 Blender packed image/UDIM Main 写回与保存
原子 range patch。
9. N-017-A空间查询部分uniform-grid spatial index 在 1M vertex/1M cell 预算内按 brush AABB
粗筛候选再执行球半径、front-face、falloff 精筛depth-gated 查询必须显式提供已验证可见
vertex ID 集合,缺失或重复会拒绝。该结构减少全量扫描,但不是 Blender PBVH。
@@ -34,14 +38,35 @@ transaction 已落地PBVH brush、texture paint 和 GPU/image 生命周期未
11. Properties paint 面板可对当前真实 VERT selection 执行 selection-masked `Blend Color`
`Blend Weight`,每次仅提交一个 Main transaction。Blender 5.2 reader 从 Mesh
`vertex_group_names` 恢复顶点组摘要及 skin weight 名称;新增组经 undo/redo、保存重开后保持。
12. M9-09主线程和 Offscreen Worker 使用同一 WebGL2 RGBA depth pass/readback
以 Main revision 和稳定顶点索引返回真实可见集;遮挡 fixture 的两后端结果一致。
13. M9-10WebEngine Worker 可序列化接收有界 color/weight chunks一次 pointer
session 仅在 commit 时对 Main 发一个合并命令;真实 Main 历史只产生一个 undo step。
14. M9-11Storage Worker 将 packed/UDIM PNG 解码为 RGBA8复验 dirty range 的
base/result pixel hash新 PNG 经 OPFS content-addressed 临时写、回读验证后,
才以 IndexedDB 单事务切换 tile binding。中途故障保持旧 binding/旧资产可读,
packed 与 UDIM 均已通过 Worker restart 回读。
15. M9-12`WeightPaintOptionsIR` 固定 normalize、limit、mirror axis/tolerance 和 1M
patch budgetWorker 拒绝重复/越界/非法组合native 在 Main 内先写入 patch再做
mirror、limit、normalize。镜像 map 使用 mesh 局部坐标,要求每个顶点都有 reciprocal
counterpart且大于 100k 顶点时结构化预算阻断。`rigged_shape_scene.blend` 的四步
goldeninitial/normalize/limit-normalize/mirror与 WASM snapshot、save/reopen 逐项一致。
16. M9-13按 Blender 5.2 `DNA_brush_enums.h` 冻结 32 个 Sculpt、4 个 Vertex Color、
4 个 Weight 和 6 个 Texture 活跃 brush。生产 WebEngine Worker 探测
`_web_engine_apply_pbvh_stroke`;当前 stable/single/pthread 均无该 WASM 入口,因此 46 项
全部返回 `N-017/BLOCKED/PAINT_PBVH_UNAVAILABLE`。即使未来出现入口session context 和
逐 brush desktop/WASM golden 未就绪时仍分别失败关闭;查询不会修改 Main revision、handle
或 WASM allocated bytes畸形字段返回完整标准 `ErrorReport`
## 仍然阻断
- N-017-A真实 PBVH、由 viewport 深度缓冲生成可见 vertex 集合,以及 brush falloff 桌面
golden基础 Mesh/UV hit、uniform-grid 候选查询可见性硬门已完成。
- N-017-Blimit/clean、已验证拓扑映射上的 mirror、桌面 brush/falloff 对照
- N-017-CBlender packed/UDIM tile Main transaction、dirty tile、色彩转换和原子保存当前
只有内容哈希绑定的浏览器内存 patch 边界
- N-017-A真实 PBVH 与 brush falloff 桌面 golden基础 Mesh/UV hit、uniform-grid 候选
查询可见性硬门和双后端 GPU depth 可见集已完成。当前不存在 PBVH WASM writer完整
brush inventory 由 `PAINT_PBVH_UNAVAILABLE` 明确阻断,不会把 bounded helper 当作成功
- N-017-Bclean/高级 Blender weight-paint operator、真实 PBVH brush/falloff 与更大拓扑的
桌面对照;当前 bounded limit/normalize/verified mirror 已完成
- N-017-CBlender packed/UDIM tile Main transaction、完整色彩转换和 `.blend` 内 image
tile 保存仍阻断;浏览器 dirty tile 的 OPFS 原子 asset/binding 边界已完成。
- N-017-D/Earmature golden、seam bleed、face mask、GPU dispose、quota、坏图和桌面 UI 对照;
vertex selection 与数值 mask 门已完成,不等同于完整 Paint 面/纹理遮罩系统。
@@ -50,7 +75,9 @@ transaction 已落地PBVH brush、texture paint 和 GPU/image 生命周期未
```bash
WEB_TEST_PORT=5203 npm --prefix web run test:e2e -- --grep "N-017 paint"
npm --prefix web run test:paint-roundtrip
WEB_TEST_PORT=5422 npm --prefix web run test:texture-paint-asset
WEB_TEST_PORT=5427 npm --prefix web run test:paint-pbvh-capability
```
`test:paint-roundtrip` 覆盖 CORNER color、vertex group patch、normalize、mirror 错误门、
undo/redo 与 save/reopen。
`test:paint-roundtrip` 覆盖 CORNER color、vertex group patch、normalize、limit、verified
mirror、undo/redo 与 save/reopen`test:weight-paint-golden` 对比 Blender 5.2 desktop golden

View File

@@ -1,7 +1,7 @@
# N-018 Physics 与 Simulation
状态:`BLOCKED`family capability、settings/dependency/cache manifest、错误帧门浏览器自有
BTF1 播放会话已落地desktop bake playback、WASM solver 和 bake job 未实现)
状态:`BLOCKED`family capability、settings/dependency/cache manifest、错误帧门浏览器自有
BTF1 播放会话及逐 family solver probe 已落地desktop bake playback、WASM solver 和 bake job 未实现)
## 已验证切片
@@ -34,13 +34,19 @@ BTF1 播放会话已落地desktop bake playback、WASM solver 和 bake job
10. N-018-C持久缓存集成两帧真实 BTF1 经过 content-addressed Simulation cache 写入,终止
Storage Worker 后由新 Worker 精确 range 读取第二帧并应用到 SceneIR帧 hash、magic、offset
和最终 object translation 均验证。
11. N-018-DM10-13 probe 门):七个 family 分别检查 runtime export、初始化、线程和内存只有
四门全过才允许 `LOCAL_SOLVER`。当前生产 inventory 没有 solver adapter全部明确路由到
`DESKTOP_SERVER_BAKE`。合成正例只验证门逻辑,不声明真实 WASM solver 或 cache decoder。
12. N-018-C/DM10-14 cache 门):七类 desktop/server bake cache 均绑定 cache schema、family、
Blender 5.2、source/settings/input/cache hash、frame range、总/逐帧 byte range/hash消费前对
实际 source、payload 和逐帧 bytes 复算 SHA-256。decoder/playback capability 仍不开放。
## 仍然阻断
- N-018-A/B从真实 Main 提取完整 family settings、collection/effector/collision 依赖。
- N-018-Cdesktop bake payload 的逐 family decoder、正式 viewport UI 播放控制和 100 帧
golden浏览器自有 transform frame decoder/SceneIR 播放会话已完成但不替代 Blender bake。
- N-018-D逐 family WASM solver 初始化、线程、内存和确定性验证
- N-018-D逐 family 真实 WASM solver adapter、初始化实现和确定性 golden探测/路由门已完成
- N-018-Ebake start/cancel/commit、服务端 job、故障恢复、进度与 UI。
## 验收
@@ -49,8 +55,10 @@ BTF1 播放会话已落地desktop bake playback、WASM solver 和 bake job
WEB_TEST_PORT=5204 npm --prefix web run test:e2e -- --grep "N-018 physics"
npm --prefix web run test:simulation-cache
npm --prefix web run test:physics-main-reader
npm --prefix web run test:physics-solver-probe
npm --prefix web run test:physics-cache-family
```
本轮在 Chromium 隔离端口复验 content-addressed cache 的 Worker restart、目标帧 range read、
帧 SHA-256、BTF1 seek/play/cancel迟到结果抑制通过;没有 desktop bake family decoder 或
solver因此 desktop playback/solver/bake 状态不变,继续为 `BLOCKED`
帧 SHA-256、BTF1 seek/play/cancel迟到结果抑制和逐 family solver probe 通过;没有 desktop
bake family decoder 或真实 solver因此 desktop playback/solver/bake 状态不变,继续为 `BLOCKED`

View File

@@ -1,7 +1,8 @@
# N-019 灯光与渲染
状态:`BLOCKED`Camera/Light/World/Scene metadata、Camera/Light/World 有界 Main 写回
Three exposure/shadow 映射已落地Scene 颜色管理 writer 和渲染等价未实现)
状态:`BLOCKED`Camera/Light/World/Scene metadata、Camera/Light/World 有界 Main 写回
Three exposure/shadow 映射和一条有界 Blender reference 图像指标闭环已落地Scene 颜色管理
writer、全域渲染等价和高级后端仍未实现
## 已验证切片
@@ -24,6 +25,30 @@ Three exposure/shadow 映射已落地Scene 颜色管理 writer 和渲染等
7. N-019-B色温部分`useTemperature:true` 时,共享 PBR 适配器把 80020000 K 的有界
黑体近似归一到 6500 K 中性白并转换为线性 RGB再乘入 Light color关闭色温时原始颜色
保持不变。主线程与 Offscreen Worker 共用该实现,数值门和原生 temperature 保存重开通过。
8. M11-01 字段表CameraIR、LightIR、WorldIR 及 Scene render/color-management 共 60 个叶字段
已逐项标注 reader/writer/main viewport/Offscreen viewport 状态AST checker 保证零漏项、零重复。
当前 24 COMPLETE、17 PARTIAL、19 BLOCKED未把 metadata-only 或近似映射提升为视觉 parity。
9. M11-02 字段闭环:支持的 Camera/Light/World writer 已在同一 Chromium 任务中完成 Main edit、
revision 单调的 undo/redo、保存、全新 Worker 重开和共享 PBR 映射;水平/垂直 sensor fit、
temperature linear RGB、shadow、spot/area 参数及 World 背景均有字段级断言。
10. M11-03 资源预算:主线程与 Offscreen 共用 schema 1 light/shadow/texture plannerWebGL2
总预算为 16 lights、4 x 1024 shadow maps均包含内置保留槽纹理同时限制数量、单边、
aggregate payload 与 decoded RGBA bytes。WebGPU 只冻结 64/8 x 2048 及 device-limit 下调合同,
未把未安装的 WebGPU renderer 标为可用。
11. M11-04 有界实时 referenceBlender 5.2 Eevee 256×256 fixture 绑定 source/generator/image
SHA-256SRGB8/STRAIGHT RGBA 比较输出 MAE、RMS、P95 channel、坏像素比例、前景 IoU 和
alpha 覆盖率。主线程与 Offscreen 生产 viewport 均为同一报告MAE 2.0433、RMS 5.4490、
P95 4、坏像素 0.004776、IoU 0.98534);非空但错误构图负例稳定返回
`RENDER_REFERENCE_MISMATCH`
12. M11-05 最终渲染路由schema 1 只让 bounded Eevee 的 WebGL2以及 capability 全通过的
WebGPU进入 `WEB_LOCAL_BOUNDED`Cycles、complex Eevee、Workbench 与 CUDA/OptiX/HIP/
Metal/oneAPI 固定为 `SERVER_JOB`。默认 endpoint 未配置时返回 `SERVER_JOB_UNAVAILABLE`
未知 SceneIR render engine 返回 `PLATFORM_CAPABILITY_UNAVAILABLE`,均不产生本地等价成功。
13. M11-06 server render jobschema 1 请求绑定 source `.blend` SHA-256/byte length/revision、
Blender 5.2 build SHA-256、规范化 render settings SHA-256 和 request SHA-256成功结果
绑定同一 provenance 并公开 output MIME/byte length/output SHA-256/result SHA-256。真实
loopback server 由 Blender 5.2 headless 打开 fixture 并输出 PNGsource、settings、build
和 output 篡改均在提交或消费前结构化拒绝。
## 仍然阻断
@@ -31,8 +56,11 @@ Three exposure/shadow 映射已落地Scene 颜色管理 writer 和渲染等
Blender/RNA 颜色管理 API 后才能开放。Camera writer 已完成。
- N-019-B/CAgX/Standard/Raw 的视觉等价、Area spread、Mist、DOF、
transparent sorting、probe 和高级 shadow 参数。
- N-019-DCycles/Freestyle/denoise 服务端 job 协议与结果 hash。
- N-019-Edesktop/Chromium 像素 golden、设备丢失和 1M triangles。
- N-019-DM11-05 已冻结 Cycles/复杂 Eevee/硬件后端的 server-only 路由M11-06 已完成
有界 server-style submit 和 source/settings/build/output hash provenance真实远程队列、
进度、取消、Freestyle 和 denoise job 仍未完成。
- N-019-E完整 desktop/Chromium 像素 parity、设备丢失和 1M trianglesM11-04 仅关闭固定
fixture 的有界 reference 指标门,不代表全场景或全色彩管理等价。
- N-019-B/C/EVolumeFloat32 NanoVDB WGSL 树遍历、有界积分、有限
temperature/color/emission grid 语义和显式 GPU resident LRU 已有真实 Chromium 数值/图像专项门;
自动缺页重绘、生产视口深度合成和 desktop/Chromium 三视角 golden 仍阻断,不能据此声明发布级体渲染。
@@ -41,5 +69,11 @@ Three exposure/shadow 映射已落地Scene 颜色管理 writer 和渲染等
```bash
npm --prefix web run test:lighting-roundtrip
npm --prefix web run test:lighting-field-parity
WEB_TEST_PORT=5545 npm --prefix web run test:lighting-field-roundtrip
WEB_TEST_PORT=5548 npm --prefix web run test:render-resource-budget
WEB_TEST_PORT=5552 npm --prefix web run test:render-reference
WEB_TEST_PORT=5556 npm --prefix web run test:render-routing
WEB_TEST_PORT=5560 npm --prefix web run test:server-render-job
WEB_TEST_PORT=5319 npm --prefix web run test:e2e -- --grep "N-019 Scene exposure"
```

View File

@@ -1,7 +1,7 @@
# N-020 Compositor
状态:`BLOCKED`GraphIR、有界 CPU executor真实 Main graph 结构 reader 已落地;完整节点
参数映射、WebGPU、HDR golden 和服务端执行未实现)
状态:`BLOCKED`GraphIR、有界 CPU executor真实 Main graph reader、四节点 WebGPU golden
与 Unsupported 全图执行门已落地;完整节点参数映射、通用 WebGPU、HDR golden 和服务端执行未实现)
## 已验证切片
@@ -27,14 +27,23 @@
Output 的动态 `Socket_0` 规范化为 GraphIR `Image`。桌面生成 fixture 经 WASM/Worker 后直接由
CPU executor 求值 Constant→Exposure→Invert→CompositeRGBA 结果通过;未连接的 Glare 仍保留
Unsupported 并使完整图 capability gate 保持阻断。
8. N-020-B/EM11-07 有界 WebGPU golden显式 allowlist 只包含 Constant Color、Exposure、
Invert、Composite。Blender 5.2 生成的四场景 fixture 经真实 WASM Main reader 后,生产 CPU
executor 与 Chromium WebGPU compute 对 2×2 `LINEAR_SRGB` Float32 RGBA 输出逐字节 SHA-256
相同CPU-only、资源输入、未声明节点、断链和错误 socket 在 shader 编译前结构化阻断。
9. N-020-DM11-08 全图执行门CPU 与 frame-cache 入口在求值、取消回调和 cache hit 前扫描
完整 graph即使 Unsupported 节点未连接到 Composite也返回
`COMPOSITOR_NODE_UNSUPPORTED`。真实 Blender 5.2 Main fixture 的 Glare node name/type/
`blenderType`、graph JSON 与 revision 在失败前后完全不变;预置 cache 不能绕过该门。
## 仍然阻断
- N-020-AMain graph 写回,以及 Transform、非默认 Invert、Alpha Over、Blur、Mix、Image/Render
Layer 的逐节点参数与 resource/pass reader基础真实图结构、常量色、Exposure、默认 Invert、
Viewer/Composite 已完成。
- N-020-B/CWebGPU executor、tile scheduler、OPFS 持久 frame cache、增量 invalidation 和 GPU
disposeCPU 周期取消和内容寻址内存 LRU 已完成。
- N-020-B/CM11-07 只实现四节点、无资源、单输入链的有界 WebGPU compute通用 WebGPU
executor、tile scheduler、OPFS 持久 frame cache、增量 invalidation 和生产 GPU 调度仍阻断;
CPU 周期取消和内容寻址内存 LRU 已完成。
- N-020-D服务端 Blender job、source hash 和结果提交。
- N-020-Edesktop HDR/alpha/color-space golden、OOM/fault/device-loss。
@@ -43,4 +52,6 @@
```bash
WEB_TEST_PORT=5320 npm --prefix web run test:e2e -- --grep "N-020 CPU compositor"
npm --prefix web run test:compositor-main-reader
WEB_TEST_PORT=5562 npm --prefix web run test:compositor-node-golden
WEB_TEST_PORT=5565 npm --prefix web run test:compositor-unsupported-gate
```

View File

@@ -1,7 +1,9 @@
# N-021 Sequencer 与音频
状态:`BLOCKED`strip schema、真实 Main reader、确定性时间编辑、长媒体有界索引/seek/cache
和 codec 能力门已落地Main 写回、完整媒体解码/渲染、音频波形和服务端编码仍未实现)
状态:`BLOCKED`strip schema、真实 Main reader、确定性时间编辑、长媒体有界索引/seek/cache
三类实际 runtime codec probe、source/decode-bound 首帧 proxy cache、三类迟到结果 revision
gate、最终编码 server-export 路由和实时 AudioContext 恢复门已落地Main 写回、完整媒体
解码/渲染、音频波形、A/V sync、实际混音和真正服务端编码 job 仍未实现)
## 已验证切片
@@ -14,8 +16,8 @@
映射,避免左右片段复用越界源帧。
4. N-021-B/C部分`resolveSequencerFrame` 在切分边界解析当前顶层 strip、source frame
与依赖META 覆盖的子 strip 和 inactive dependency 不会泄漏到渲染帧。
5. N-021-C/D运行时只报告 WebCodecs/HTMLMedia 需要精确 probecodec 必须
出现在已验证 MIME 集合中才可放行,本地编码固定为 `BLOCKED`
5. N-021-C/D运行时 inventory 只报告 WebCodecs/HTMLMedia 需要精确 probe旧的
MIME 集合不能作为 READY receipt,本地编码固定为 `BLOCKED`
6. N-021-A部分Blender 5.2 `Scene.ed/Editing.seqbase` 读取 Scene、Movie、Image、Sound、
Meta 和协议支持的 Effect显示范围由持久 `start/startofs/endofs/len` 计算,保留 channel、
mute/lock、相对媒体路径、24/1.001 FPS 和 effect input stable ID。绝对路径、未知类型、
@@ -32,16 +34,45 @@
仍 residenttimeline/current frame/asset hashes 作为内容寻址 session manifest 写入 OPFS终止
StorageWorker 与 media Worker 后在 frame 765432 恢复。session SHA-256 固定为
`d2e7e3c5ed9ae4fda6fe358cebb474f5774f1d2037dff3df9b6dfde9e0bebd2d`
11. 上述门只放行 fixture 的 IMAGE/SOUND 原始本地资源读取,不声称 WebCodecs MOVIE decode、
waveform/proxy 生成或最终编码完成;`video/mp4`local encoding 仍结构化阻断。
11. 上述 V1 长媒体门只放行 fixture 的 IMAGE/SOUND 原始本地资源读取,不声称 WebCodecs MOVIE
seek、waveform/proxy 生成或最终编码完成local encoding 仍结构化阻断。
12. M11-09 runtime codec probeschema 1 request/result 绑定 IMAGE/SOUND/MOVIE family、规范 MIME、
byte length 和 source SHA-256且不接收 source path/扩展名字段。Chromium 用 ImageBitmap
解码 8×8 PNG用固定 48 kHz OfflineAudioContext 解码 1 秒 WAV用 HTMLMedia 加载、seek
并绘制 16×16 两帧 H.264 MP4三类均 READY。错误扩展名不影响结果损坏 bytes、hash
drift、MIME/family 和 backend 伪造均返回 `SEQUENCER_CODEC_UNSUPPORTED`
13. M11-10 proxy cacheschema 1 identity 绑定 MOVIE source family/MIME/bytes/SHA-256、当前
READY HTMLMedia receipt、SRGB8/STRAIGHT RGBA8 profile 和 source framepayload 再独立绑定
byte length/SHA-256。Chromium 从真实 H.264 初始 decoded frame 生成 8x8/256-byte proxy
一帧预算 LRU 发生确定性淘汰content-addressed payload/manifest 经新 Storage Worker 重开
后复验。source、decode receipt、identity 或 payload 漂移均在消费前稳定阻断。
14. M11-11 media revision gateSEEK/SCRUB/DECODE 使用同一 schema 1 request绑定 timeline
ID/revision、单调 request revision、operation 和 framecompleted result 必须逐字段回显并绑定
source frame/payload hash。Chromium 中旧 SEEK 晚于新 SCRUB 返回、真实 H.264 DECODE 跨
timeline replacement 返回及伪造 result 全部在 publish/cache callback 前成为
`STALE/REVISION_CONFLICT`;只有当前 SCRUB 与当前 DECODE 发布。
15. M11-12 final export routeschema 1 将真实 Main timeline ID/revision、source `.blend`
SHA-256、帧范围/帧率、分辨率和 container/video/audio codec 绑定到 settings/request SHA-256。
无 endpoint 时返回 `SERVER_EXPORT/BLOCKED/SEQUENCER_EXPORT_SERVER_UNAVAILABLE`;有 endpoint
时只返回 `SERVER_EXPORT_REQUIRED`。Chromium 中注入和实际探测到的 `VideoEncoder` 都不能
`localEncoding``BLOCKED` 提升为本地完成。
16. M11-13 audio session recoveryschema 1 分开报告
`UNAVAILABLE/SUSPENDED/RUNNING/CLOSED` context 与 `BLOCKED/SILENT/ENABLED` output
revision 随 initialize/mute/resume/suspend/close 单调推进。Chromium 在真实用户手势内创建
`AudioContext`,通过 suspend、mute、muted resume、unmute、二次 suspend/resume 和 close
mute 将 gain 归零unmute 恢复 0.75close 再归零并断开节点。API/构造器缺失与 resume
failure 分别稳定返回 `SEQUENCER_AUDIO_DEVICE_UNAVAILABLE`
`SEQUENCER_AUDIO_RESUME_FAILED`,不会误报输出启用。
## 仍然阻断
- N-021-A/BMain strip 写回、非 CROSS transition/modifier 完整参数、undo/redo 和 save/reopen
基础真实 reader 与 CROSS/GAMMA_CROSS 帧描述已完成。
- N-021-CWebCodecs 精确 seek/decode、音频 waveform、proxy 生成、A/V sync、丢帧和
损坏媒体处理。
- N-021-D浏览器不支持的 codec、混音与最终编码的服务端 Blender job
- N-021-CM11-09/10/11/13 完成三类短 fixture 的初始实际解码门、MOVIE 初始帧 RGBA8 proxy
cache identity、迟到结果 publish/cache gate 和实时 AudioContext 生命周期WebCodecs 帧精确
seek/decode、多帧 proxy、音频 waveform、A/V sync、实际混音、丢帧和长媒体损坏恢复仍阻断
- N-021-DM11-12 已冻结最终编码的 server-export 路由与请求 identity真正服务端 Blender
encode job、进度/取消、混音和输出结果校验仍阻断。
- N-021-EV1 有界 Chromium/OPFS 长媒体门已完成;完整桌面/Chromium 解码/渲染 golden 仍阻断。
## 验收
@@ -50,4 +81,9 @@
WEB_TEST_PORT=5321 npm --prefix web run test:e2e -- --grep "N-021 sequencer"
npm --prefix web run test:sequencer-main-reader
npm --prefix web run test:long-media-performance
WEB_TEST_PORT=5570 npm --prefix web run test:sequencer-codec-probe
WEB_TEST_PORT=5573 npm --prefix web run test:sequencer-media-cache
WEB_TEST_PORT=5576 npm --prefix web run test:sequencer-media-revision
WEB_TEST_PORT=5579 npm --prefix web run test:sequencer-final-export
WEB_TEST_PORT=5586 npm --prefix web run test:sequencer-audio-recovery
```

View File

@@ -1,6 +1,6 @@
{
"schemaVersion": 2,
"updatedAt": "2026-08-14",
"updatedAt": "2026-08-17",
"source": "docs/BLENDER_5_2_WEB_FEATURE_PARITY.md",
"parityStatusEnum": ["COMPLETE", "BLOCKED"],
"releaseClassEnum": ["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "EXCLUDED"],
@@ -13,10 +13,10 @@
"releaseClass": "LOCAL_BOUNDED",
"releaseStatus": "READY",
"v1RequiredSlices": ["C3-roundtrip", "D2-nanovdb-bounded-webgpu-integration", "E2-vdb-network-worker-quota-tamper-rollback-faults"],
"v1ExcludedSlices": ["C2-new-external-font-import", "D2-nanovdb-production-viewport-automatic-page-fault-depth-composition", "C3-volume-combined-asset-viewport-reopen", "E1-volume-glb-usd-loss-desktop-chromium-golden", "E2-vdb-large-stream-device-loss-oom"],
"v1ExcludedSlices": ["C2-new-external-font-import", "D2-nanovdb-production-viewport-automatic-page-fault-depth-composition", "E1-volume-glb-usd-loss-desktop-chromium-golden"],
"roadmapStatus": "in_progress",
"completedSlices": ["A1", "A2-malformed-binary-partial", "A2-integer-overflow-fixtures", "A2-multichunk-attribute-completeness", "B2", "B3-metadata", "B3-vdb-conversion-request-boundary", "B3-nanovdb-manifest-range-hash-protocol", "B3-nanovdb-serial-range-streamer", "B3-vdb-stage-capability-gates", "B3-vdb-real-resource-license-hash-catalog", "B3-desktop-openvdb13-nanovdb32-converter", "B3-native-conversion-determinism-malformed-input", "B3-vdb-server-job-isolation-cancel-timeout", "B3-vdb-desktop-server-hash-equality", "B3-nanovdb-http-retry-if-range-body-resume", "C1-topology-partial", "C1-handle-points-partial", "C1-handle-preview-raycast-partial", "C1-handle-identity-gizmo-main-roundtrip", "C1-multi-handle-selection-highlight-bounded-commit", "C1-gizmo-revision-delta-transaction-boundary", "C1-continuous-handle-gizmo-preview-single-commit", "C1-handle-local-origin-orientation-gizmo", "C1-rename-partial", "C1-create-delete-poly-partial", "C1-poly-bezier-nurbs-1d-conversion", "C1-multispline-create-delete-bulk-handle-cyclic-transaction", "C1-surface-2d-topology-transaction", "C2-font-geometry-partial", "C2-font-layout-partial", "C2-font-character-style-textbox-roundtrip", "C2-existing-packed-vfont-style-links", "C2-builtin-font-evaluation-exact", "C3-roundtrip", "C3-volume-main-properties-save-reopen", "C3-nanovdb-opfs-binding-worker-reopen", "D1-raycast-partial", "D1-offscreen-vert-edge-partial", "D1-selection-history-partial", "D1-cross-object-history-range-patch", "D1-handle-identity-axis-gizmo", "D2-partial", "D2-nanovdb-float32-cpu-wgsl-sampling", "D2-nanovdb-bounded-webgpu-integration", "D2-webgpu-device-loss-session-recovery", "D2-nanovdb-grid-material-mapping", "D2-nanovdb-explicit-resident-page-lru-recovery", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", "E1-glb-evaluated-nonmesh-roundtrip-partial", "E1-glb-curve-line-surface-mesh-evaluated", "E1-usda-four-object-desktop-roundtrip", "E1-pointcloud-curves-hair-glb-usd-loss-fixture", "E2-1M-chromium", "E2-chromium-worker-recovery", "E2-opfs-quota-chromium", "E2-vdb-network-worker-quota-tamper-rollback-faults"],
"blockedSlices": ["C2-new-external-font-import", "D2-nanovdb-production-viewport-automatic-page-fault-depth-composition", "C3-volume-combined-asset-viewport-reopen", "E1-volume-glb-usd-loss-desktop-chromium-golden", "E2-vdb-large-stream-device-loss-oom"],
"completedSlices": ["A1", "A2-malformed-binary-partial", "A2-integer-overflow-fixtures", "A2-multichunk-attribute-completeness", "B2", "B3-metadata", "B3-vdb-conversion-request-boundary", "B3-nanovdb-manifest-range-hash-protocol", "B3-nanovdb-serial-range-streamer", "B3-vdb-stage-capability-gates", "B3-vdb-real-resource-license-hash-catalog", "B3-desktop-openvdb13-nanovdb32-converter", "B3-native-conversion-determinism-malformed-input", "B3-vdb-server-job-isolation-cancel-timeout", "B3-vdb-desktop-server-hash-equality", "B3-nanovdb-http-retry-if-range-body-resume", "C1-topology-partial", "C1-handle-points-partial", "C1-handle-preview-raycast-partial", "C1-handle-identity-gizmo-main-roundtrip", "C1-multi-handle-selection-highlight-bounded-commit", "C1-gizmo-revision-delta-transaction-boundary", "C1-continuous-handle-gizmo-preview-single-commit", "C1-handle-local-origin-orientation-gizmo", "C1-rename-partial", "C1-create-delete-poly-partial", "C1-poly-bezier-nurbs-1d-conversion", "C1-multispline-create-delete-bulk-handle-cyclic-transaction", "C1-surface-2d-topology-transaction", "C2-font-geometry-partial", "C2-font-layout-partial", "C2-font-character-style-textbox-roundtrip", "C2-existing-packed-vfont-style-links", "C2-builtin-font-evaluation-exact", "C3-roundtrip", "C3-volume-main-properties-save-reopen", "C3-nanovdb-opfs-binding-worker-reopen", "C3-volume-combined-asset-viewport-reopen", "D1-raycast-partial", "D1-offscreen-vert-edge-partial", "D1-selection-history-partial", "D1-cross-object-history-range-patch", "D1-handle-identity-axis-gizmo", "D2-partial", "D2-nanovdb-float32-cpu-wgsl-sampling", "D2-nanovdb-bounded-webgpu-integration", "D2-webgpu-device-loss-session-recovery", "D2-nanovdb-grid-material-mapping", "D2-nanovdb-explicit-resident-page-lru-recovery", "D2-nanovdb-production-viewport-automatic-page-fault", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", "E1-glb-evaluated-nonmesh-roundtrip-partial", "E1-glb-curve-line-surface-mesh-evaluated", "E1-usda-four-object-desktop-roundtrip", "E1-pointcloud-curves-hair-glb-usd-loss-fixture", "E1-volume-desktop-main-offscreen-pixel-golden", "E2-1M-chromium", "E2-chromium-worker-recovery", "E2-opfs-quota-chromium", "E2-vdb-network-worker-quota-tamper-rollback-faults", "E2-vdb-large-stream-device-loss-oom"],
"blockedSlices": ["C2-new-external-font-import", "D2-nanovdb-production-viewport-automatic-page-fault-depth-composition", "E1-volume-glb-usd-loss-desktop-chromium-golden"],
"excludedSlices": [],
"acceptance": ["web:test:nonmesh-roundtrip", "web:test:nonmesh-desktop-golden", "web:test:nonmesh-glb-blender-roundtrip", "web:test:nonmesh-usd-serialization", "web:test:nonmesh-usd-blender-roundtrip", "web:test:nonmesh-binary", "web:test:selection-history", "web:test:nonmesh-interaction", "web:test:vdb", "web:test:vdb-availability", "web:test:vdb-native", "web:test:vdb-server", "web:test:vdb-opfs", "web:test:vdb-webgpu", "web:test:vdb-viewport", "web:test:vdb-faults", "web:e2e:N-015|non-mesh", "web:e2e:real OPFS quota"],
"dependencies": []
@@ -72,7 +72,7 @@
"v1RequiredSlices": ["B-world-scene-render-delta-roundtrip", "B-C-nanovdb-float32-wgsl-bounded-ray-integration"],
"v1ExcludedSlices": ["A", "B", "C", "D", "E", "B-C-nanovdb-production-viewport-paging-advanced-material", "E-volume-desktop-chromium-pixel-golden"],
"roadmapStatus": "planned",
"completedSlices": ["A-camera-light-world-scene-reader-partial", "A-main-camera-dof-properties-partial", "A-main-light-world-properties-partial", "A-white-balance-integrity-gate", "B-three-exposure-shadow-mapping-partial", "B-bounded-kelvin-linear-light-color", "B-world-scene-render-delta-roundtrip", "B-volume-material-manifest-boundary", "B-C-nanovdb-float32-wgsl-bounded-ray-integration"],
"completedSlices": ["A-camera-light-world-scene-reader-partial", "A-main-camera-dof-properties-partial", "A-main-light-world-properties-partial", "A-white-balance-integrity-gate", "B-three-exposure-shadow-mapping-partial", "B-bounded-kelvin-linear-light-color", "B-world-scene-render-delta-roundtrip", "B-volume-material-manifest-boundary", "B-C-nanovdb-float32-wgsl-bounded-ray-integration", "D-final-render-server-routing-fail-closed", "D-server-render-source-build-settings-output-hash-binding", "E-bounded-realtime-blender-reference-image-metrics"],
"blockedSlices": ["A", "B", "C", "D", "E", "B-C-nanovdb-production-viewport-paging-advanced-material", "E-volume-desktop-chromium-pixel-golden"],
"acceptance": ["web:test:lighting-roundtrip", "web:e2e:N-019 Scene exposure"],
"dependencies": ["N-018"]
@@ -86,7 +86,7 @@
"v1RequiredSlices": ["B-real-main-exposure-invert-cpu-chain", "D-unsupported-node-preservation-gate"],
"v1ExcludedSlices": ["A", "B", "C", "D", "E"],
"roadmapStatus": "planned",
"completedSlices": ["A-graph-resource-cycle-schema", "A-main-graph-structure-reader-partial", "A-main-exposure-default-invert-parameter-reader", "B-bounded-cpu-executor-partial", "B-real-main-exposure-invert-cpu-chain", "C-image-operation-budget-cancel-partial", "C-content-addressed-frame-lru-cache", "D-unsupported-node-preservation-gate"],
"completedSlices": ["A-graph-resource-cycle-schema", "A-main-graph-structure-reader-partial", "A-main-exposure-default-invert-parameter-reader", "B-bounded-cpu-executor-partial", "B-real-main-exposure-invert-cpu-chain", "B-webgpu-constant-exposure-invert-golden-partial", "B-D-unsupported-full-graph-execution-block", "C-image-operation-budget-cancel-partial", "C-content-addressed-frame-lru-cache", "D-unsupported-node-preservation-gate"],
"blockedSlices": ["A", "B", "C", "D", "E"],
"acceptance": ["web:test:compositor-main-reader", "web:e2e:N-020 CPU compositor"],
"dependencies": ["N-019"]
@@ -100,7 +100,7 @@
"v1RequiredSlices": ["B-real-main-cross-still-frame-resolution", "C-runtime-codec-probe-gate"],
"v1ExcludedSlices": ["A", "B", "C", "D", "E"],
"roadmapStatus": "planned",
"completedSlices": ["A-strip-resource-schema", "A-main-strip-timeline-reader-partial", "B-deterministic-move-trim-split-partial", "B-source-frame-seek", "B-active-frame-dependency-resolution", "B-cross-transition-progress-input-resolution", "B-real-main-cross-still-frame-resolution", "C-runtime-codec-probe-gate"],
"completedSlices": ["A-strip-resource-schema", "A-main-strip-timeline-reader-partial", "B-deterministic-move-trim-split-partial", "B-source-frame-seek", "B-active-frame-dependency-resolution", "B-cross-transition-progress-input-resolution", "B-real-main-cross-still-frame-resolution", "C-runtime-codec-probe-gate", "C-runtime-image-sound-movie-byte-decode-probe", "C-source-decode-bound-movie-proxy-cache", "C-seek-scrub-decode-revision-gate", "C-audio-context-suspend-device-mute-recovery", "D-final-export-server-routing-fail-closed"],
"blockedSlices": ["A", "B", "C", "D", "E"],
"acceptance": ["web:test:sequencer-main-reader", "web:e2e:N-021 sequencer"],
"dependencies": ["N-020"]

View File

@@ -1,8 +1,8 @@
{
"schemaVersion": 4,
"source": "docs/status/parity-ledger.json",
"sourceSha256": "1e16518a25c6b6bed4ab7f605ea1ad5babe529f2842c858b20ae6daae30bd478",
"generatedAt": "2026-08-15T05:37:25.215Z",
"sourceSha256": "0a9b4c1026c42b5f77869f222b1531601ed229184a1d1737e74047c7b3406730",
"generatedAt": "2026-08-17T08:22:00.415Z",
"families": [
{
"id": "N-015",
@@ -18,9 +18,7 @@
"v1ExcludedSlices": [
"C2-new-external-font-import",
"D2-nanovdb-production-viewport-automatic-page-fault-depth-composition",
"C3-volume-combined-asset-viewport-reopen",
"E1-volume-glb-usd-loss-desktop-chromium-golden",
"E2-vdb-large-stream-device-loss-oom"
"E1-volume-glb-usd-loss-desktop-chromium-golden"
],
"roadmapStatus": "in_progress",
"completedSlices": [
@@ -61,6 +59,7 @@
"C3-roundtrip",
"C3-volume-main-properties-save-reopen",
"C3-nanovdb-opfs-binding-worker-reopen",
"C3-volume-combined-asset-viewport-reopen",
"D1-raycast-partial",
"D1-offscreen-vert-edge-partial",
"D1-selection-history-partial",
@@ -72,6 +71,7 @@
"D2-webgpu-device-loss-session-recovery",
"D2-nanovdb-grid-material-mapping",
"D2-nanovdb-explicit-resident-page-lru-recovery",
"D2-nanovdb-production-viewport-automatic-page-fault",
"E1-partial",
"E1-desktop-geometry-golden",
"E1-true-2d-surface-desktop-golden",
@@ -79,17 +79,17 @@
"E1-glb-curve-line-surface-mesh-evaluated",
"E1-usda-four-object-desktop-roundtrip",
"E1-pointcloud-curves-hair-glb-usd-loss-fixture",
"E1-volume-desktop-main-offscreen-pixel-golden",
"E2-1M-chromium",
"E2-chromium-worker-recovery",
"E2-opfs-quota-chromium",
"E2-vdb-network-worker-quota-tamper-rollback-faults"
"E2-vdb-network-worker-quota-tamper-rollback-faults",
"E2-vdb-large-stream-device-loss-oom"
],
"blockedSlices": [
"C2-new-external-font-import",
"D2-nanovdb-production-viewport-automatic-page-fault-depth-composition",
"C3-volume-combined-asset-viewport-reopen",
"E1-volume-glb-usd-loss-desktop-chromium-golden",
"E2-vdb-large-stream-device-loss-oom"
"E1-volume-glb-usd-loss-desktop-chromium-golden"
],
"excludedSlices": [],
"acceptance": [
@@ -274,7 +274,10 @@
"B-bounded-kelvin-linear-light-color",
"B-world-scene-render-delta-roundtrip",
"B-volume-material-manifest-boundary",
"B-C-nanovdb-float32-wgsl-bounded-ray-integration"
"B-C-nanovdb-float32-wgsl-bounded-ray-integration",
"D-final-render-server-routing-fail-closed",
"D-server-render-source-build-settings-output-hash-binding",
"E-bounded-realtime-blender-reference-image-metrics"
],
"blockedSlices": [
"A",
@@ -317,6 +320,8 @@
"A-main-exposure-default-invert-parameter-reader",
"B-bounded-cpu-executor-partial",
"B-real-main-exposure-invert-cpu-chain",
"B-webgpu-constant-exposure-invert-golden-partial",
"B-D-unsupported-full-graph-execution-block",
"C-image-operation-budget-cancel-partial",
"C-content-addressed-frame-lru-cache",
"D-unsupported-node-preservation-gate"
@@ -362,7 +367,12 @@
"B-active-frame-dependency-resolution",
"B-cross-transition-progress-input-resolution",
"B-real-main-cross-still-frame-resolution",
"C-runtime-codec-probe-gate"
"C-runtime-codec-probe-gate",
"C-runtime-image-sound-movie-byte-decode-probe",
"C-source-decode-bound-movie-proxy-cache",
"C-seek-scrub-decode-revision-gate",
"C-audio-context-suspend-device-mute-recovery",
"D-final-export-server-routing-fail-closed"
],
"blockedSlices": [
"A",

Binary file not shown.

Binary file not shown.

View File

@@ -19,6 +19,16 @@
"objectNames": ["BasicArea", "BasicCamera", "BasicCube"],
"frameRange": [1, 24]
},
{
"id": "m11_render_reference",
"path": "m11_render_reference.blend",
"objects": 2,
"meshes": 1,
"materials": 1,
"objectNames": ["M11 Reference Camera", "M11 Reference Cube"],
"frameRange": [1, 250],
"features": ["blender-eevee-reference", "webgl2-image-error-metrics", "main-offscreen-golden"]
},
{
"id": "attribute_scene",
"path": "attribute_scene.blend",
@@ -38,6 +48,17 @@
"frameRange": [1, 10],
"animationTargets": ["object:AnimatedObject"]
},
{
"id": "nla_time_mapping_scene",
"path": "nla_time_mapping_scene.blend",
"objects": 1,
"meshes": 1,
"materials": 0,
"objectNames": ["M10_NLA_TimeMapping"],
"frameRange": [1, 70],
"animationTargets": ["object:M10_NLA_TimeMapping"],
"features": ["nla-track-reader", "scaled-action-time", "reverse-repeat", "desktop-depsgraph-golden"]
},
{
"id": "rigged_shape_scene",
"path": "rigged_shape_scene.blend",
@@ -157,6 +178,13 @@
"meshes": 0,
"features": ["compositor-main-reader", "exposure-invert-parameters", "socket-links", "unsupported-node-preservation"]
},
{
"id": "m11_compositor_allowlist",
"path": "m11_compositor_allowlist.blend",
"objects": 0,
"meshes": 0,
"features": ["compositor-node-allowlist", "cpu-webgpu-golden", "constant-color", "exposure", "invert"]
},
{
"id": "sequencer_scene",
"path": "sequencer_scene.blend",

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,46 @@
{
"schemaVersion": 1,
"fixture": "tests/files/web/modifier_geometry_nodes_scene.blend",
"fixtureSha256": "f3820511f791769837d75be092934130c397cf46ee0acf79db28efca9a7948c9",
"desktopGolden": "tests/golden/W-075/modifier_geometry_nodes_scene.json",
"desktopGoldenSha256": "93c21e5158daefce10f2d4df674e636bf3f6ed3dd43e1576c45a02a8592f5886",
"graphs": [
{
"name": "WebGeometryNodes",
"nodeTypes": ["NodeGroupInput", "GeometryNodeTransform", "NodeGroupOutput"],
"linkCount": 2,
"defaults": {
"GeometryNodeTransform:Mode": 0,
"GeometryNodeTransform:Translation": [0.25, 0.5, 1.0],
"GeometryNodeTransform:Rotation": [0.0, 0.0, 0.2617993950843811],
"GeometryNodeTransform:Scale": [1.0, 1.0, 1.0]
}
},
{
"name": "WebGeometryNodesSetPosition",
"nodeTypes": ["NodeGroupInput", "GeometryNodeSetPosition", "NodeGroupOutput"],
"linkCount": 2,
"defaults": {
"GeometryNodeSetPosition:Selection": true,
"GeometryNodeSetPosition:Position": [0.0, 0.0, 0.0],
"GeometryNodeSetPosition:Offset": [0.0, 0.0, 1.0]
}
},
{
"name": "WebGeometryNodesSimulation",
"nodeTypes": ["GeometryNodeSimulationInput", "NodeGroupOutput", "GeometryNodeSimulationOutput", "NodeGroupInput"],
"linkCount": 3,
"defaults": {
"GeometryNodeSimulationInput:Delta Time": 0.0,
"GeometryNodeSimulationOutput:Skip": false
}
}
],
"budgets": {
"maxGraphs": 4096,
"maxNodesPerGraph": 4096,
"maxLinksPerGraph": 16384,
"maxSocketsPerGraph": 65536,
"maxInterfaceSocketsPerGraph": 4096
}
}

View File

@@ -0,0 +1,32 @@
{
"schemaVersion": 1,
"fixture": "tests/files/web/modifier_geometry_nodes_scene.blend",
"fixtureSha256": "f3820511f791769837d75be092934130c397cf46ee0acf79db28efca9a7948c9",
"allowlistSchemaVersion": 1,
"allowlist": [
"NodeGroupInput",
"NodeGroupOutput",
"GeometryNodeTransform",
"GeometryNodeSetPosition",
"GeometryNodeJoinGeometry",
"GeometryNodeSeparateGeometry",
"GeometryNodeRealizeInstances",
"GeometryNodeStoreNamedAttribute",
"FunctionNodeInputInt",
"FunctionNodeInputVector",
"FunctionNodeCompare",
"ShaderNodeValue",
"ShaderNodeMath",
"GeometryNodeObjectInfo",
"GeometryNodeCollectionInfo",
"GeometryNodeImageInfo"
],
"supportedGraph": "WebGeometryNodes",
"unsupportedGraph": "WebGeometryNodesSimulation",
"unsupportedNodeTypes": [
"GeometryNodeSimulationInput",
"GeometryNodeSimulationOutput"
],
"unsupportedErrorCode": "GN_NODE_UNSUPPORTED",
"evaluatorUnavailableErrorCode": "CAPABILITY_MISSING"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"schemaVersion": 1,
"taskId": "M10-07",
"fixture": "tests/files/web/basic_scene.blend",
"fixtureSha256": "6fcc55bda74da8c95da96ba068b60339bf60ca44147ff8000fbb95d296db2a73",
"backend": "WEBGL2_THREE_PHYSICAL",
"allowlist": [
"RGB",
"VALUE",
"MATH",
"IMAGE_TEXTURE",
"NORMAL_MAP",
"PRINCIPLED",
"OUTPUT"
],
"mathOperations": [
"ADD",
"SUBTRACT",
"MULTIPLY",
"DIVIDE",
"MINIMUM",
"MAXIMUM"
],
"budget": {
"maxNodes": 128,
"maxLinks": 512,
"maxDepth": 64,
"maxTextures": 16,
"maxIdentifierBytes": 256,
"maxNameBytes": 1024
},
"expectedMathResult": 0.42,
"unsupportedNodeCode": "SHADER_NODE_UNSUPPORTED"
}

View File

@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"taskId": "M10-08",
"backend": "WEBGL2_THREE_PHYSICAL",
"textureColorSpaces": ["SRGB", "NON_COLOR", "LINEAR"],
"keyInputs": ["graphHash", "rendererBackend", "imageId", "usage", "assetId", "sha256", "colorSpace"],
"keyDigest": "sha256",
"unknownBackendCode": "CAPABILITY_MISSING",
"invalidTextureIdentityCode": "SHADER_INVALID_GRAPH"
}

View File

@@ -0,0 +1,8 @@
{
"schemaVersion": 1,
"taskId": "M10-09",
"failureStatus": "BLOCKED",
"preservedPipeline": true,
"replacedOnSuccess": true,
"failureDoesNotDisposePrevious": true
}

View File

@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"taskId": "M10-10",
"capability": "ARBITRARY_SHADER",
"status": "BLOCKED",
"unsupportedNodeTypes": ["VORONOI", "CUSTOM_OSL"],
"errorCode": "SHADER_NODE_UNSUPPORTED",
"recoverable": true,
"graphDataPolicy": "PRESERVE"
}

View File

@@ -0,0 +1,308 @@
{
"schemaVersion": 1,
"fixture": "tests/files/web/nla_time_mapping_scene.blend",
"fixtureSha256": "b32f783debe213a1345ec4528e5ed27f0f4de00d7896b626df39756289119855",
"blenderVersion": "5.2.0 LTS",
"object": "M10_NLA_TimeMapping",
"mesh": "M10_NLA_TimeMappingMesh",
"tracks": [
{
"name": "M10 Time Mapping",
"muted": false,
"solo": false,
"strips": [
{
"id": "M10 Scaled Clip",
"action": "M10_NLA_Scaled_X",
"frameStart": 20.0,
"frameEnd": 40.0,
"actionFrameStart": 1.0,
"actionFrameEnd": 11.0,
"scale": 2.0,
"repeat": 1.0,
"blendIn": 0.0,
"blendOut": 0.0,
"influence": 1.0,
"blendMode": "REPLACE",
"extrapolation": "NOTHING",
"muted": false,
"reverse": false
},
{
"id": "M10 Reverse Repeat Clip",
"action": "M10_NLA_ReverseRepeat_Y",
"frameStart": 45.0,
"frameEnd": 65.0,
"actionFrameStart": 1.0,
"actionFrameEnd": 11.0,
"scale": 1.0,
"repeat": 2.0,
"blendIn": 0.0,
"blendOut": 0.0,
"influence": 1.0,
"blendMode": "REPLACE",
"extrapolation": "NOTHING",
"muted": false,
"reverse": true
}
]
}
],
"frames": [
{
"frame": 1,
"worldMatrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 20,
"worldMatrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 25,
"worldMatrix": [
1.0,
0.0,
0.0,
2.5,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 30,
"worldMatrix": [
1.0,
0.0,
0.0,
5.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 35,
"worldMatrix": [
1.0,
0.0,
0.0,
7.5,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 40,
"worldMatrix": [
1.0,
0.0,
0.0,
10.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 45,
"worldMatrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
10.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 50,
"worldMatrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
5.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 55,
"worldMatrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
10.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 60,
"worldMatrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
5.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 65,
"worldMatrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
},
{
"frame": 70,
"worldMatrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0
]
}
],
"tolerance": {
"maxMatrixError": 1e-05
}
}

View File

@@ -0,0 +1,24 @@
{
"familyOrder": [
"RIGID_BODY",
"SOFT_BODY",
"CLOTH",
"FLUID",
"DYNAMIC_PAINT",
"PARTICLE",
"HAIR"
],
"defaultProbe": "EXPORT_UNAVAILABLE",
"probes": {
"RIGID_BODY": "READY",
"SOFT_BODY": "INITIALIZATION_FAILED",
"CLOTH": "THREADS_UNAVAILABLE",
"FLUID": "MEMORY_UNAVAILABLE",
"DYNAMIC_PAINT": "INVALID_RESULT",
"PARTICLE": "INITIALIZATION_FAILED",
"HAIR": "EXPORT_UNAVAILABLE"
},
"localRoute": "LOCAL_SOLVER",
"fallbackRoute": "DESKTOP_SERVER_BAKE",
"fallbackErrorCode": "PHYSICS_SOLVER_UNAVAILABLE"
}

View File

@@ -0,0 +1,26 @@
{
"families": [
"RIGID_BODY",
"SOFT_BODY",
"CLOTH",
"FLUID",
"DYNAMIC_PAINT",
"PARTICLE",
"HAIR"
],
"sources": [
"BLENDER_DESKTOP_BAKE",
"BLENDER_SERVER_BAKE",
"BLENDER_DESKTOP_BAKE",
"BLENDER_SERVER_BAKE",
"BLENDER_DESKTOP_BAKE",
"BLENDER_SERVER_BAKE",
"BLENDER_DESKTOP_BAKE"
],
"byteLength": 4,
"frameCount": 2,
"sourceMismatch": "PHYSICS_CACHE_SOURCE_MISMATCH",
"payloadMismatch": "PHYSICS_CACHE_HASH_MISMATCH",
"versionMismatch": "PROTOCOL_MISMATCH",
"budgetExceeded": "PHYSICS_BUDGET_EXCEEDED"
}

View File

@@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"domains": {
"GN": {
"workUnits": 10,
"maximumMs": 5000,
"performanceStatus": "SUPPORTED",
"oomCode": "GN_GRAPH_BUDGET_EXCEEDED",
"maliciousCode": "GN_DEPENDENCY_CYCLE"
},
"SHADER": {
"workUnits": 100,
"maximumMs": 5000,
"performanceStatus": "COMPILED",
"oomCode": "SHADER_NODE_UNSUPPORTED",
"maliciousCode": "SHADER_GRAPH_CYCLE"
},
"NLA": {
"workUnits": 20,
"maximumMs": 5000,
"performanceStatus": "SUPPORTED",
"oomCode": "NLA_BUDGET_EXCEEDED",
"maliciousCode": "NLA_INVALID_STACK"
},
"SIMULATION": {
"workUnits": 64,
"maximumMs": 10000,
"performanceStatus": "VERIFIED",
"oomCode": "SIMULATION_CACHE_BUDGET_EXCEEDED",
"maliciousCode": "SIMULATION_CACHE_INVALID"
}
}
}

View File

@@ -0,0 +1,128 @@
{
"schemaVersion": 1,
"task": "M11-01",
"blenderVersion": "5.2.0",
"legend": {
"VERIFIED": "Implemented and covered by the cited bounded path",
"PARTIAL": "Implemented for a declared subset or approximate mapping",
"METADATA_ONLY": "Exposed to the viewport but not visually evaluated",
"BLOCKED": "Not implemented or not parity-safe",
"NOT_APPLICABLE": "The stage does not mutate or visually consume this identity/status field"
},
"domains": [
{
"domain": "CAMERA",
"sourceInterface": "CameraIR",
"evidence": [
"web/protocol/scene-ir.ts",
"web/protocol/web-engine.ts",
"blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp",
"tools/web/check-lighting-roundtrip.mjs",
"web/app/src/three-adapter/viewport.ts",
"web/app/src/workers/viewport-render.worker.ts"
],
"fields": [
{ "path": "id", "reader": "VERIFIED", "writer": "NOT_APPLICABLE", "viewportMain": "NOT_APPLICABLE", "viewportOffscreen": "NOT_APPLICABLE", "parity": "COMPLETE" },
{ "path": "name", "reader": "VERIFIED", "writer": "NOT_APPLICABLE", "viewportMain": "NOT_APPLICABLE", "viewportOffscreen": "NOT_APPLICABLE", "parity": "COMPLETE" },
{ "path": "projection", "reader": "VERIFIED", "writer": "PARTIAL", "viewportMain": "PARTIAL", "viewportOffscreen": "PARTIAL", "parity": "PARTIAL", "note": "Writer supports Perspective/Orthographic; viewport still uses PerspectiveCamera and blocks Panoramic/Custom parity." },
{ "path": "lensMm", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "sensorWidthMm", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "sensorHeightMm", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "sensorFit", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "shift", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "PARTIAL", "viewportOffscreen": "PARTIAL", "parity": "PARTIAL", "note": "Horizontal film offset is mapped; vertical shift is not." },
{ "path": "near", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "far", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "orthoScale", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "panoramaType", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "fisheyeFov", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "depthOfField.enabled", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "depthOfField.focusObjectId", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "depthOfField.focusDistance", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "depthOfField.apertureFStop", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "depthOfField.apertureBlades", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "depthOfField.apertureRotation", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "depthOfField.apertureRatio", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" }
]
},
{
"domain": "LIGHT",
"sourceInterface": "LightIR",
"evidence": [
"web/protocol/scene-ir.ts",
"web/protocol/web-engine.ts",
"blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp",
"tools/web/check-lighting-roundtrip.mjs",
"web/app/src/three-adapter/pbr.ts"
],
"fields": [
{ "path": "id", "reader": "VERIFIED", "writer": "NOT_APPLICABLE", "viewportMain": "NOT_APPLICABLE", "viewportOffscreen": "NOT_APPLICABLE", "parity": "COMPLETE" },
{ "path": "name", "reader": "VERIFIED", "writer": "NOT_APPLICABLE", "viewportMain": "NOT_APPLICABLE", "viewportOffscreen": "NOT_APPLICABLE", "parity": "COMPLETE" },
{ "path": "lightType", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "PARTIAL" },
{ "path": "color", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "energy", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "exposure", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "temperature", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "useTemperature", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "castsShadow", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "radius", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "spotAngle", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "spotBlend", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "areaShape", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "PARTIAL", "viewportOffscreen": "PARTIAL", "parity": "PARTIAL", "note": "All Area shapes currently map to RectAreaLight." },
{ "path": "areaSize", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "areaSizeY", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "areaSpread", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "sunAngle", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" }
]
},
{
"domain": "WORLD",
"sourceInterface": "WorldIR",
"evidence": [
"web/protocol/scene-ir.ts",
"web/protocol/web-engine.ts",
"blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp",
"tools/web/check-lighting-roundtrip.mjs",
"web/app/src/three-adapter/viewport.ts",
"web/app/src/three-adapter/texture-assets.ts"
],
"fields": [
{ "path": "id", "reader": "VERIFIED", "writer": "NOT_APPLICABLE", "viewportMain": "NOT_APPLICABLE", "viewportOffscreen": "NOT_APPLICABLE", "parity": "COMPLETE" },
{ "path": "name", "reader": "VERIFIED", "writer": "NOT_APPLICABLE", "viewportMain": "NOT_APPLICABLE", "viewportOffscreen": "NOT_APPLICABLE", "parity": "COMPLETE" },
{ "path": "color", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "COMPLETE" },
{ "path": "exposure", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "PARTIAL", "viewportOffscreen": "PARTIAL", "parity": "PARTIAL", "note": "World exposure is used only when Scene exposure is absent." },
{ "path": "environmentImageId", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "PARTIAL" },
{ "path": "environmentStrength", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "PARTIAL" },
{ "path": "environmentRotation", "reader": "BLOCKED", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "backgroundVisible", "reader": "PARTIAL", "writer": "BLOCKED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "PARTIAL", "note": "Reader currently emits true rather than resolving the full node visibility graph." },
{ "path": "mist.enabled", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "METADATA_ONLY", "viewportOffscreen": "METADATA_ONLY", "parity": "PARTIAL" },
{ "path": "mist.type", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "METADATA_ONLY", "viewportOffscreen": "METADATA_ONLY", "parity": "PARTIAL" },
{ "path": "mist.start", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "METADATA_ONLY", "viewportOffscreen": "METADATA_ONLY", "parity": "PARTIAL" },
{ "path": "mist.depth", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "METADATA_ONLY", "viewportOffscreen": "METADATA_ONLY", "parity": "PARTIAL" },
{ "path": "mist.intensity", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "METADATA_ONLY", "viewportOffscreen": "METADATA_ONLY", "parity": "PARTIAL" },
{ "path": "mist.height", "reader": "VERIFIED", "writer": "VERIFIED", "viewportMain": "METADATA_ONLY", "viewportOffscreen": "METADATA_ONLY", "parity": "PARTIAL" }
]
},
{
"domain": "SCENE_COLOR_MANAGEMENT",
"sourceInterface": "SceneIR",
"evidence": [
"web/protocol/scene-ir.ts",
"blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp",
"tools/web/check-lighting-roundtrip.mjs",
"web/app/src/three-adapter/viewport.ts",
"web/app/src/workers/viewport-render.worker.ts"
],
"fields": [
{ "path": "renderEngine", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "colorManagement.displayDevice", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "colorManagement.viewTransform", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "METADATA_ONLY", "viewportOffscreen": "METADATA_ONLY", "parity": "PARTIAL" },
{ "path": "colorManagement.look", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "METADATA_ONLY", "viewportOffscreen": "METADATA_ONLY", "parity": "PARTIAL" },
{ "path": "colorManagement.exposure", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "VERIFIED", "viewportOffscreen": "VERIFIED", "parity": "PARTIAL" },
{ "path": "colorManagement.gamma", "reader": "VERIFIED", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "colorManagement.temperature", "reader": "PARTIAL", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "colorManagement.tint", "reader": "PARTIAL", "writer": "BLOCKED", "viewportMain": "BLOCKED", "viewportOffscreen": "BLOCKED", "parity": "BLOCKED" },
{ "path": "colorManagement.whiteBalanceStatus", "reader": "VERIFIED", "writer": "NOT_APPLICABLE", "viewportMain": "NOT_APPLICABLE", "viewportOffscreen": "NOT_APPLICABLE", "parity": "COMPLETE" }
]
}
]
}

View File

@@ -0,0 +1,46 @@
{
"camera": {
"lensMm": 35,
"sensorWidthMm": 32,
"sensorHeightMm": 18,
"sensorFit": 2,
"shift": [0.1, -0.2],
"near": 0.2,
"far": 500,
"orthoScale": 8,
"viewportFov": 28.841546,
"viewportHorizontalFov": 49.134343,
"viewportFilmGauge": 18,
"viewportFilmOffset": 1.8
},
"light": {
"color": [0.25, 0.5, 0.75],
"energy": 400,
"exposure": 1,
"temperature": 5000,
"useTemperature": true,
"castsShadow": false,
"radius": 0.3,
"spotAngle": 1.1,
"spotBlend": 0.25,
"areaSize": 3,
"areaSizeY": 2,
"areaSpread": 2.4,
"sunAngle": 0.1,
"viewportIntensity": 80,
"viewportColor": [0.25, 0.3910381443, 0.4834313443]
},
"world": {
"color": [0.1, 0.2, 0.3],
"exposure": 0.5,
"mist": {
"enabled": true,
"type": "LINEAR",
"start": 2,
"depth": 50,
"intensity": 0.2,
"height": 3
}
},
"tolerance": 0.00001
}

View File

@@ -0,0 +1,32 @@
{
"schemaVersion": 1,
"webgl2": {
"maxLights": 16,
"reservedLights": 2,
"maxShadowMaps": 4,
"reservedShadowMaps": 1,
"shadowMapDimension": 1024,
"maxTextureAssets": 256,
"maxTexturePayloadBytes": 536870912,
"maxTextureGPUBytes": 536870912
},
"webgpu": {
"maxLights": 64,
"reservedLights": 2,
"maxShadowMaps": 8,
"reservedShadowMaps": 1,
"shadowMapDimension": 2048,
"maxTextureAssets": 256,
"maxTexturePayloadBytes": 536870912,
"maxTextureGPUBytes": 1073741824
},
"overflow": {
"requestedLights": 20,
"renderedLights": 14,
"droppedLights": 6,
"requestedShadowMaps": 14,
"renderedShadowMaps": 3,
"blockedShadowMaps": 11,
"codes": ["GPU_LIGHT_BUDGET_EXCEEDED", "GPU_SHADOW_BUDGET_EXCEEDED"]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"task": "M11-04",
"source": {
"fixture": "tests/files/web/m11_render_reference.blend",
"fixtureSha256": "d2bea55fe4de0b00e73a9241b4d1b0c802c2eb0e6159c0cd48e007b97b9d3963",
"generator": "tools/web/generate-m11-render-reference.py",
"generatorSha256": "118c98c77d98922e72660898bfb65842e11743fc77f4124c9f5088a76cb6a94a",
"blenderVersion": "5.2.0 LTS"
},
"reference": {
"file": "blender-eevee-reference.png",
"sha256": "f5c22636a232bcbb0ed0f772418cb12fced18ff72f4999804197e42caa61480a",
"width": 256,
"height": 256,
"colorSpace": "SRGB8",
"alphaMode": "STRAIGHT",
"renderEngine": "BLENDER_EEVEE"
},
"thresholds": {
"maxMeanAbsoluteError": 4,
"maxRootMeanSquaredError": 12,
"maxP95ChannelError": 12,
"maxBadPixelRatio": 0.02,
"badPixelChannelError": 48,
"foregroundDeltaFromReferenceBackground": 40,
"minForegroundIntersectionOverUnion": 0.97,
"maxAlphaCoverageDeltaRatio": 0
}
}

View File

@@ -0,0 +1,48 @@
{
"schemaVersion": 1,
"boundedEevee": {
"target": "WEB_LOCAL_BOUNDED",
"status": "READY",
"capability": "WEB_REALTIME_BOUNDED",
"reason": "BOUNDED_EEVEE"
},
"cycles": {
"target": "SERVER_JOB",
"withoutEndpoint": {
"status": "BLOCKED",
"code": "SERVER_JOB_UNAVAILABLE",
"reason": "CYCLES_REQUIRES_SERVER"
},
"withEndpoint": {
"status": "READY",
"capability": "CYCLES_SERVER_RENDER"
}
},
"complexEevee": {
"target": "SERVER_JOB",
"withoutEndpoint": {
"status": "BLOCKED",
"code": "SERVER_JOB_UNAVAILABLE",
"reason": "COMPLEX_EEVEE_REQUIRES_SERVER"
}
},
"hardware": {
"target": "SERVER_JOB",
"withoutEndpoint": {
"status": "BLOCKED",
"code": "SERVER_JOB_UNAVAILABLE",
"reason": "HARDWARE_BACKEND_REQUIRES_SERVER"
}
},
"webgpu": {
"target": "WEB_LOCAL_BOUNDED",
"status": "BLOCKED",
"code": "WEBGPU_RENDERER_UNAVAILABLE"
},
"unknownEngine": {
"target": "SERVER_JOB",
"status": "BLOCKED",
"capability": "UNSUPPORTED_RENDER_ENGINE",
"code": "PLATFORM_CAPABILITY_UNAVAILABLE"
}
}

View File

@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"task": "M11-06",
"blenderVersion": "5.2.0",
"sourceBlendSha256": "d2bea55fe4de0b00e73a9241b4d1b0c802c2eb0e6159c0cd48e007b97b9d3963",
"settingsSha256": "97bb832618bd27e8763722afa0d250ef74383fb355f0ff38763930f57ba1590b",
"output": {
"mime": "image/png",
"minimumByteLength": 1,
"sha256Pattern": "^[a-f0-9]{64}$"
},
"tamperCodes": {
"source": "SERVER_RENDER_SOURCE_HASH_MISMATCH",
"build": "SERVER_RENDER_BUILD_MISMATCH",
"settings": "SERVER_RENDER_SETTINGS_HASH_MISMATCH",
"output": "SERVER_RENDER_OUTPUT_HASH_MISMATCH"
}
}

View File

@@ -0,0 +1,46 @@
{
"schemaVersion": 1,
"task": "M11-07",
"fixture": {
"path": "tests/files/web/m11_compositor_allowlist.blend",
"sha256": "e844eba69002101c8cf4f376cdd844c4be78729091627d898634088707374b29",
"generator": "tools/web/generate-m11-compositor-allowlist.py",
"generatorSha256": "1389998aac849065311c85ef559a7d381274c0ebe1987afc3b6783c66a9a678e",
"blenderVersion": "5.2.0"
},
"width": 2,
"height": 2,
"maxAbsoluteError": 0,
"allowlist": ["CONSTANT_COLOR", "EXPOSURE", "INVERT", "COMPOSITE"],
"cases": [
{
"scene": "M11 Constant",
"newNode": "CONSTANT_COLOR",
"nodeTypes": ["CONSTANT_COLOR", "COMPOSITE"],
"pixel": [0.125, 0.25, 0.5, 0.75],
"float32Sha256": "3664d5dd48f309ba6a45e84ad84bf931c7775de21b2f8eef255651743dcc7bf5"
},
{
"scene": "M11 Exposure",
"newNode": "EXPOSURE",
"nodeTypes": ["CONSTANT_COLOR", "EXPOSURE", "COMPOSITE"],
"pixel": [0.25, 0.5, 1, 0.75],
"float32Sha256": "0c8caa21f76305cf3fc3a677f5c7af34811a0b29aaa035a5f55be15e2d89accd"
},
{
"scene": "M11 Invert",
"newNode": "INVERT",
"nodeTypes": ["CONSTANT_COLOR", "INVERT", "COMPOSITE"],
"pixel": [0.875, 0.75, 0.5, 0.75],
"float32Sha256": "d96848cf025a567550b7de8a3fcfb304594bb3fbad5c8722fa39f55e23845c55"
},
{
"scene": "M11 Chain",
"newNode": "INTEGRATION",
"nodeTypes": ["CONSTANT_COLOR", "EXPOSURE", "INVERT", "COMPOSITE"],
"pixel": [0.75, 0.5, 0, 0.75],
"float32Sha256": "20394b91c848df1aca00179b6785df2d5b38d44c333c9d9d1c926fe674f03a04"
}
],
"blockedNodeTypes": ["ALPHA_OVER", "BLUR", "IMAGE", "MIX", "RENDER_LAYER", "TRANSFORM", "UNSUPPORTED", "VIEWER"]
}

View File

@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"task": "M11-08",
"fixture": {
"path": "tests/files/web/compositor_scene.blend",
"sha256": "348eeefc28a0fdaa2d55b3c7251a737e0a04d82f61dd1eba804662a8f03713b9",
"generator": "tools/web/generate-compositor-fixture.py",
"generatorSha256": "e6ca40c989fb4bf1a26a34aa55861c2979142817a4cbe4e2628f4d0318775dc0",
"blenderVersion": "5.2.0"
},
"scene": "CompositorScene",
"unsupportedNode": {
"name": "PreservedUnsupportedGlare",
"type": "UNSUPPORTED",
"blenderType": "CompositorNodeGlare"
},
"expectedErrorCode": "COMPOSITOR_NODE_UNSUPPORTED",
"expectedRevisionDelta": 0
}

View File

@@ -0,0 +1,41 @@
{
"schemaVersion": 1,
"task": "M11-09",
"assets": [
{
"stripType": "IMAGE",
"mimeType": "image/png",
"path": "tests/files/web/media/sequencer-frame.png",
"sourcePath": "media/not-an-image.movie",
"byteLength": 261,
"sha256": "295b083db2299ab904947eb39c17173de70c211882b0d855537d8f3cc27698ed",
"backend": "IMAGE_BITMAP",
"decoded": { "width": 8, "height": 8 }
},
{
"stripType": "SOUND",
"mimeType": "audio/wav",
"path": "tests/files/web/media/sequencer-silence.wav",
"sourcePath": "media/not-a-sound.png",
"byteLength": 16044,
"sha256": "56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9",
"backend": "WEB_AUDIO",
"decoded": { "sampleRate": 48000, "channels": 1, "durationFrames": 48000 }
},
{
"stripType": "MOVIE",
"mimeType": "video/mp4",
"path": "tests/files/web/media/sequencer-probe.mp4",
"sourcePath": "media/not-a-movie.wav",
"byteLength": 1484,
"sha256": "f35a5a2765aef9d0fed7146108c699d0beb03f34414cb27e6b7a7d4871187b65",
"backend": "HTML_MEDIA",
"decoded": { "width": 16, "height": 16, "durationMicros": 1000000 }
}
],
"movieGenerator": {
"path": "tools/web/generate-sequencer-codec-fixture.sh",
"sha256": "bab7b0b56a16b96fd87302894c1c35d72ea316542d0ffffe9610458e47875b7a"
},
"blockedCode": "SEQUENCER_CODEC_UNSUPPORTED"
}

View File

@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"task": "M11-10",
"sourceSha256": "f35a5a2765aef9d0fed7146108c699d0beb03f34414cb27e6b7a7d4871187b65",
"profile": {
"kind": "MOVIE_RGBA8_FRAME",
"width": 8,
"height": 8,
"colorSpace": "SRGB8",
"alphaMode": "STRAIGHT"
},
"identitySha256": "68a3af14865841e81f69bd75f2605461de4819fe025d158ac3723fa0cdf31525",
"proxyByteLength": 256
}

View File

@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"task": "M11-11",
"sourceSha256": "f35a5a2765aef9d0fed7146108c699d0beb03f34414cb27e6b7a7d4871187b65",
"decisions": [
["SCRUB", 2, "PUBLISH", null],
["SEEK", 1, "STALE", "REVISION_CONFLICT"],
["DECODE", 3, "STALE", "REVISION_CONFLICT"],
["DECODE", 5, "PUBLISH", null],
["SEEK", 6, "STALE", "REVISION_CONFLICT"]
],
"published": ["SCRUB@2", "DECODE@5"],
"cacheWrites": ["DECODE@5"]
}

View File

@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"task": "M11-12",
"sourceBlendSha256": "5f5212487bb6b5df62b5ca915c75133f1ca45678614712ea4c7d902d823cd90c",
"settingsSha256": "01e39d1fdd88c75aecad7bc48a2b756bbcd645929963528b42ceb180b51e5566",
"requestSha256": "20d3a9e333804e014a993a9892a557842293fc44902229dd6eb65661f0691cfc",
"withoutServer": {
"schemaVersion": 1,
"requestSha256": "20d3a9e333804e014a993a9892a557842293fc44902229dd6eb65661f0691cfc",
"settingsSha256": "01e39d1fdd88c75aecad7bc48a2b756bbcd645929963528b42ceb180b51e5566",
"route": "SERVER_EXPORT",
"status": "BLOCKED",
"code": "SEQUENCER_EXPORT_SERVER_UNAVAILABLE",
"localEncoding": "BLOCKED",
"browserVideoEncoderDetected": false
},
"withServer": {
"schemaVersion": 1,
"requestSha256": "20d3a9e333804e014a993a9892a557842293fc44902229dd6eb65661f0691cfc",
"settingsSha256": "01e39d1fdd88c75aecad7bc48a2b756bbcd645929963528b42ceb180b51e5566",
"route": "SERVER_EXPORT",
"status": "SERVER_EXPORT_REQUIRED",
"code": null,
"localEncoding": "BLOCKED",
"browserVideoEncoderDetected": false
}
}

View File

@@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"task": "M11-13",
"outputGain": 0.75,
"lifecycle": [
["SUSPENDED", "SILENT", false, 0.75, null],
["SUSPENDED", "SILENT", true, 0, null],
["RUNNING", "SILENT", true, 0, null],
["RUNNING", "ENABLED", false, 0.75, null],
["SUSPENDED", "SILENT", false, 0.75, null],
["RUNNING", "ENABLED", false, 0.75, null],
["CLOSED", "SILENT", false, 0, null]
],
"missingDevice": ["UNAVAILABLE", "BLOCKED", "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE"],
"resumeFailure": ["SUSPENDED", "SILENT", "SEQUENCER_AUDIO_RESUME_FAILED"]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,75 @@
{
"schemaVersion": 1,
"taskId": "M8-19",
"source": {
"resourceId": "generated-smoke-vdb",
"sha256": "586f7cdf4b3329fcb7ef00fc57b12a268baafdbaac0a2b3d1bfa142a22ccda33",
"grid": "density",
"activeVoxelCount": 13997,
"indexBounds": {
"min": [
-14,
-14,
-14
],
"max": [
14,
14,
14
]
}
},
"native": {
"openVDBVersion": "13.0.0",
"generatorSourceSha256": "4d18306b9a498f8de73dccea9efd9f268fc24e07404c3e425c994b51434584eb"
},
"renderContract": {
"shaderSemanticVersion": "volume-wgsl-v1",
"width": 64,
"height": 64,
"format": "RGBA8_UNORM",
"interpolation": "LINEAR",
"densityScale": 1,
"emissionScale": 0,
"anisotropy": 0,
"color": [
0.72,
0.78,
0.86
],
"axes": [
"X",
"Y",
"Z"
],
"thresholds": {
"maxChannelError": 2,
"meanAbsoluteError": 0.1,
"rmsError": 0.5,
"alphaCoverageDeltaRatio": 0.002
}
},
"images": [
{
"axis": "X",
"file": "generated-smoke-x.rgba",
"byteLength": 16384,
"sha256": "87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c",
"alphaPixels": 3552
},
{
"axis": "Y",
"file": "generated-smoke-y.rgba",
"byteLength": 16384,
"sha256": "87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c",
"alphaPixels": 3552
},
{
"axis": "Z",
"file": "generated-smoke-z.rgba",
"byteLength": 16384,
"sha256": "87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c",
"alphaPixels": 3552
}
]
}

View File

@@ -0,0 +1,29 @@
{
"afterCyclicU": [
true,
false
],
"beforeCyclicU": [
false,
false
],
"blenderVersion": "5.2.0",
"curveName": "WebCurveData",
"fixture": "tests/files/web/nonmesh_scene.blend",
"fixtureSha256": "ae8ef85d606aa120ce6b7611fc03407fd80bb60311e94a6ef73f6384d0b6c8b4",
"operator": "TOGGLE_CYCLIC",
"reopenedCyclicU": [
true,
false
],
"schemaVersion": 1,
"splineIndex": 0,
"splinePointCounts": [
4,
3
],
"splineTypes": [
"POLY",
"BEZIER"
]
}

View File

@@ -0,0 +1,59 @@
{
"afterReorder": {
"framesByLayer": {
"Lines": [
1
],
"Web Drafts": [
12
]
},
"layerOrder": [
"Web Drafts",
"Lines"
]
},
"beforeReorder": {
"framesByLayer": {
"Lines": [
1
],
"Web Drafts": [
1
]
},
"layerOrder": [
"Lines",
"Web Drafts"
]
},
"blenderVersion": "5.2.0",
"fixture": "tests/files/web/modifier_grease_pencil_scene.blend",
"fixtureSha256": "3a8525077807f9178dac3a5ba84b524e5f8e80874b15c54e2a6658ecb80315a3",
"greasePencilName": "GreasePencilData",
"reopened": {
"framesByLayer": {
"Lines": [
1
],
"Web Drafts": [
12
]
},
"layerOrder": [
"Web Drafts",
"Lines"
]
},
"schemaVersion": 1,
"source": {
"framesByLayer": {
"Lines": [
1
]
},
"layerOrder": [
"Lines"
]
}
}

View File

@@ -0,0 +1,193 @@
{
"blenderVersion": "5.2.0 LTS",
"fixture": "rigged_shape_scene.blend",
"mesh": "RiggedShapeMesh",
"object": "RiggedShapeObject",
"operations": [
{
"name": "normalize",
"vertex": 2
},
{
"limit": 2,
"name": "limit-normalize",
"vertex": 3
},
{
"axis": 0,
"name": "mirror",
"tolerance": 0.0001,
"vertices": [
0,
1
]
}
],
"schemaVersion": 1,
"steps": [
{
"groups": [
"Root",
"Tip",
"WebPaintGroup"
],
"name": "initial",
"vertices": [
{
"index": 0,
"weights": {
"Root": 1.0,
"Tip": 0.25,
"WebPaintGroup": 0.75
}
},
{
"index": 1,
"weights": {
"Root": 1.0,
"Tip": 0.25,
"WebPaintGroup": 0.25
}
},
{
"index": 2,
"weights": {
"Root": 0.25,
"Tip": 1.0
}
},
{
"index": 3,
"weights": {
"Root": 0.25,
"Tip": 1.0
}
}
]
},
{
"groups": [
"Root",
"Tip",
"WebPaintGroup"
],
"name": "normalize",
"vertices": [
{
"index": 0,
"weights": {
"Root": 1.0,
"Tip": 0.25,
"WebPaintGroup": 0.75
}
},
{
"index": 1,
"weights": {
"Root": 1.0,
"Tip": 0.25,
"WebPaintGroup": 0.25
}
},
{
"index": 2,
"weights": {
"Root": 0.142857149,
"Tip": 0.571428597,
"WebPaintGroup": 0.285714298
}
},
{
"index": 3,
"weights": {
"Root": 0.25,
"Tip": 1.0
}
}
]
},
{
"groups": [
"Root",
"Tip",
"WebPaintGroup"
],
"name": "limit-normalize",
"vertices": [
{
"index": 0,
"weights": {
"Root": 1.0,
"Tip": 0.25,
"WebPaintGroup": 0.75
}
},
{
"index": 1,
"weights": {
"Root": 1.0,
"Tip": 0.25,
"WebPaintGroup": 0.25
}
},
{
"index": 2,
"weights": {
"Root": 0.142857149,
"Tip": 0.571428597,
"WebPaintGroup": 0.285714298
}
},
{
"index": 3,
"weights": {
"Tip": 0.714285731,
"WebPaintGroup": 0.285714298
}
}
]
},
{
"groups": [
"Root",
"Tip",
"WebPaintGroup"
],
"name": "mirror",
"vertices": [
{
"index": 0,
"weights": {
"Root": 1.0,
"Tip": 0.25,
"WebPaintGroup": 0.899999976
}
},
{
"index": 1,
"weights": {
"Root": 1.0,
"Tip": 0.25,
"WebPaintGroup": 0.899999976
}
},
{
"index": 2,
"weights": {
"Root": 0.142857149,
"Tip": 0.571428597,
"WebPaintGroup": 0.285714298
}
},
{
"index": 3,
"weights": {
"Tip": 0.714285731,
"WebPaintGroup": 0.285714298
}
}
]
}
],
"tolerance": 1e-06
}

View File

@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"task": "M9-14",
"domains": ["CURVE", "GREASE_PENCIL", "PAINT"],
"fixtures": {
"CURVE": {
"path": "tests/files/web/nonmesh_scene.blend",
"sha256": "ae8ef85d606aa120ce6b7611fc03407fd80bb60311e94a6ef73f6384d0b6c8b4"
},
"GREASE_PENCIL": {
"path": "tests/files/web/modifier_grease_pencil_scene.blend",
"sha256": "3a8525077807f9178dac3a5ba84b524e5f8e80874b15c54e2a6658ecb80315a3"
},
"PAINT": {
"path": "tests/files/web/attribute_scene.blend",
"sha256": "12fa75bb79f8c38e660d3d2a8fc9cc16dd3df4fc9208b0e2ad94fb1f8aa68f71"
}
},
"requiredStages": ["WORKER_RESTART", "OOM", "GPU_RELEASE", "SMALL_SCENE"],
"invariants": {
"workerRestart": "identity hash preserved; new engine worker has one live Main handle",
"oom": "GPU_GEOMETRY_UPLOAD maps to GPU_GEOMETRY_BUDGET_EXCEEDED and leaves zero temporary resources",
"gpuRelease": "one WebGL2 resource release and one reinitialization render non-zero pixels",
"smallScene": "domain identity, data IDs and visible pixels are restored"
},
"verification": {
"unit": "web/tests/unit/editing-domain-recovery.test.mjs",
"browser": "web/tests/e2e/editing-domain-recovery.spec.ts",
"command": "npm --prefix web run test:editing-domain-recovery"
}
}

View File

@@ -18,7 +18,7 @@ target_include_directories(vdb_toolchain INTERFACE "${OPENVDB_INCLUDE_DIR}" "${T
target_compile_definitions(vdb_toolchain INTERFACE NANOVDB_USE_OPENVDB)
target_link_libraries(vdb_toolchain INTERFACE "${OPENVDB_LIBRARY}" "${TBB_LIBRARY}")
foreach(target vdb_fixture_generator vdb_to_nanovdb)
foreach(target vdb_fixture_generator vdb_to_nanovdb vdb_volume_golden)
add_executable(${target} "${target}.cc")
target_link_libraries(${target} PRIVATE vdb_toolchain)
set_target_properties(${target} PROPERTIES

View File

@@ -0,0 +1,86 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(os.homedir(), "resource-library/blender-web-vdb"));
const source = path.join(resourceRoot, "generated", "generated-smoke.vdb");
const generator = path.join(root, "build_vdb_tools", "vdb_volume_golden");
const committedRoot = path.join(root, "tests", "golden", "M8-19");
const check = process.argv.includes("--check");
const outputRoot = check ? fs.mkdtempSync(path.join(os.tmpdir(), "vdb-volume-golden-")) : committedRoot;
const outputPrefix = path.join(outputRoot, "generated-smoke");
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
if (!fs.existsSync(generator)) throw new Error(`VDB_VOLUME_GOLDEN_TOOL_MISSING: ${generator}`);
if (!fs.existsSync(source)) throw new Error(`VDB_VOLUME_GOLDEN_SOURCE_MISSING: ${source}`);
fs.mkdirSync(outputRoot, { recursive: true });
try {
const result = spawnSync(generator, [source, outputPrefix], { encoding: "utf8" });
if (result.status !== 0) throw new Error(result.stderr || `vdb_volume_golden exited ${result.status}`);
const native = JSON.parse(result.stdout.trim());
const axes = ["X", "Y", "Z"];
const images = axes.map((axis) => {
const fileName = `generated-smoke-${axis.toLowerCase()}.rgba`;
const file = path.join(outputRoot, fileName);
const bytes = fs.readFileSync(file);
let alphaPixels = 0;
for (let index = 3; index < bytes.byteLength; index += 4) if (bytes[index] > 0) alphaPixels++;
return { axis, file: fileName, byteLength: bytes.byteLength, sha256: sha256(file), alphaPixels };
});
const manifest = {
schemaVersion: 1,
taskId: "M8-19",
source: {
resourceId: "generated-smoke-vdb",
sha256: sha256(source),
grid: native.grid,
activeVoxelCount: native.activeVoxelCount,
indexBounds: native.indexBounds,
},
native: {
openVDBVersion: native.openVDBVersion,
generatorSourceSha256: sha256(path.join(root, "tools", "vdb", "vdb_volume_golden.cc")),
},
renderContract: {
shaderSemanticVersion: "volume-wgsl-v1",
width: native.width,
height: native.height,
format: "RGBA8_UNORM",
interpolation: "LINEAR",
densityScale: 1,
emissionScale: 0,
anisotropy: 0,
color: [0.72, 0.78, 0.86],
axes,
thresholds: {
maxChannelError: 2,
meanAbsoluteError: 0.1,
rmsError: 0.5,
alphaCoverageDeltaRatio: 0.002,
},
},
images,
};
const manifestText = `${JSON.stringify(manifest, null, 2)}\n`;
const manifestFile = path.join(outputRoot, "manifest.json");
fs.writeFileSync(manifestFile, manifestText);
if (check) {
for (const image of images) {
const expected = fs.readFileSync(path.join(committedRoot, image.file));
const actual = fs.readFileSync(path.join(outputRoot, image.file));
if (!expected.equals(actual)) throw new Error(`VDB_VOLUME_GOLDEN_MISMATCH: ${image.file}`);
}
const expectedManifest = fs.readFileSync(path.join(committedRoot, "manifest.json"), "utf8");
if (expectedManifest !== manifestText) throw new Error("VDB_VOLUME_GOLDEN_MISMATCH: manifest.json");
}
process.stdout.write(`vdb-volume-golden-${check ? "check" : "generated"} axes=3 bytes=${images.reduce((total, image) => total + image.byteLength, 0)} sha256=${images.map((image) => image.sha256).join(",")}\n`);
}
finally {
if (check) fs.rmSync(outputRoot, { recursive: true, force: true });
}

View File

@@ -0,0 +1,179 @@
#include <openvdb/openvdb.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
namespace fs = std::filesystem;
namespace {
constexpr int kImageSize = 64;
constexpr std::array<float, 3> kColor = {0.72f, 0.78f, 0.86f};
float sample_linear(const openvdb::FloatGrid::ConstAccessor &accessor,
const std::array<float, 3> &position)
{
const std::array<int, 3> base = {
static_cast<int>(std::floor(position[0])),
static_cast<int>(std::floor(position[1])),
static_cast<int>(std::floor(position[2])),
};
const std::array<float, 3> fraction = {
position[0] - static_cast<float>(base[0]),
position[1] - static_cast<float>(base[1]),
position[2] - static_cast<float>(base[2]),
};
float value = 0.0f;
for (int x = 0; x < 2; ++x) {
for (int y = 0; y < 2; ++y) {
for (int z = 0; z < 2; ++z) {
const float weight = (x ? fraction[0] : 1.0f - fraction[0]) *
(y ? fraction[1] : 1.0f - fraction[1]) *
(z ? fraction[2] : 1.0f - fraction[2]);
value += accessor.getValue(openvdb::Coord(base[0] + x, base[1] + y, base[2] + z)) *
weight;
}
}
}
return value;
}
uint8_t pack_unorm(float value)
{
return static_cast<uint8_t>(std::lround(std::clamp(value, 0.0f, 1.0f) * 255.0f));
}
std::vector<uint8_t> render_axis(const openvdb::FloatGrid &density,
const openvdb::CoordBBox &bounds,
int view_axis)
{
const auto accessor = density.getConstAccessor();
const openvdb::Coord minimum = bounds.min();
const openvdb::Coord maximum = bounds.max();
const std::array<int, 3> plane_a = view_axis == 0 ? std::array<int, 3>{1, 2, 0} :
view_axis == 1 ? std::array<int, 3>{0, 2, 1} :
std::array<int, 3>{0, 1, 2};
const int ray_axis = plane_a[2];
const int ray_min = minimum[ray_axis];
const int ray_max = maximum[ray_axis];
const int ray_count = std::max(1, ray_max - ray_min + 1);
const int stride = std::max(1, (ray_count + 255) / 256);
const float voxel_size = std::max(0.01f, static_cast<float>(density.voxelSize()[ray_axis]));
const float phase = 1.0f / 12.5663706f;
const float source_scale = 0.5f + 8.0f * phase;
std::vector<uint8_t> pixels(kImageSize * kImageSize * 4, 0);
for (int y = 0; y < kImageSize; ++y) {
for (int x = 0; x < kImageSize; ++x) {
const std::array<int, 2> plane_min = {minimum[plane_a[0]], minimum[plane_a[1]]};
const std::array<int, 2> plane_max = {maximum[plane_a[0]], maximum[plane_a[1]]};
const std::array<float, 2> extent = {
static_cast<float>(plane_max[0] - plane_min[0] + 1),
static_cast<float>(plane_max[1] - plane_min[1] + 1),
};
const std::array<float, 2> plane_position = {
static_cast<float>(plane_min[0]) +
((static_cast<float>(x) + 0.5f) / static_cast<float>(kImageSize)) * extent[0] - 0.5f,
static_cast<float>(plane_min[1]) +
((static_cast<float>(y) + 0.5f) / static_cast<float>(kImageSize)) * extent[1] - 0.5f,
};
float transmittance = 1.0f;
std::array<float, 3> radiance = {0.0f, 0.0f, 0.0f};
for (int ray = ray_min; ray <= ray_max; ray += stride) {
std::array<float, 3> position{};
position[plane_a[0]] = plane_position[0];
position[plane_a[1]] = plane_position[1];
position[ray_axis] = static_cast<float>(ray) + 0.5f;
const float sampled_density = std::max(0.0f, sample_linear(accessor, position));
const float alpha = 1.0f - std::exp(-sampled_density * voxel_size * static_cast<float>(stride));
for (int channel = 0; channel < 3; ++channel) {
radiance[channel] += transmittance * alpha * kColor[channel] * source_scale;
}
transmittance *= 1.0f - alpha;
if (transmittance < 0.005f) {
break;
}
}
const size_t offset = static_cast<size_t>(y * kImageSize + x) * 4;
pixels[offset] = pack_unorm(radiance[0]);
pixels[offset + 1] = pack_unorm(radiance[1]);
pixels[offset + 2] = pack_unorm(radiance[2]);
pixels[offset + 3] = pack_unorm(1.0f - transmittance);
}
}
return pixels;
}
void write_bytes(const fs::path &path, const std::vector<uint8_t> &bytes)
{
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) {
throw std::runtime_error("failed to create golden image: " + path.string());
}
output.write(reinterpret_cast<const char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!output) {
throw std::runtime_error("failed to write golden image: " + path.string());
}
}
} // namespace
int main(int argc, char **argv)
{
if (argc != 3) {
std::cerr << "usage: vdb_volume_golden INPUT.vdb OUTPUT_PREFIX\n";
return 2;
}
try {
openvdb::initialize();
const fs::path input = fs::absolute(argv[1]);
const fs::path output_prefix = fs::absolute(argv[2]);
if (input.extension() != ".vdb") {
throw std::runtime_error("input extension must be .vdb");
}
fs::create_directories(output_prefix.parent_path());
openvdb::io::File file(input.string());
file.open(false);
const openvdb::GridBase::Ptr base = file.readGrid("density");
file.close();
const openvdb::FloatGrid::Ptr density = openvdb::gridPtrCast<openvdb::FloatGrid>(base);
if (!density || density->getGridClass() != openvdb::GRID_FOG_VOLUME) {
throw std::runtime_error("density must be an OpenVDB FloatGrid fog volume");
}
const openvdb::CoordBBox bounds = density->evalActiveVoxelBoundingBox();
if (bounds.empty()) {
throw std::runtime_error("density grid has no active voxels");
}
const std::array<const char *, 3> names = {"x", "y", "z"};
for (int axis = 0; axis < 3; ++axis) {
write_bytes(output_prefix.string() + "-" + names[axis] + ".rgba",
render_axis(*density, bounds, axis));
}
std::cout << "{\"schemaVersion\":1,\"openVDBVersion\":\""
<< openvdb::getLibraryVersionString() << "\",\"grid\":\"density\",\"width\":"
<< kImageSize << ",\"height\":" << kImageSize << ",\"activeVoxelCount\":"
<< density->activeVoxelCount() << ",\"indexBounds\":{\"min\":["
<< bounds.min().x() << ',' << bounds.min().y() << ',' << bounds.min().z()
<< "],\"max\":[" << bounds.max().x() << ',' << bounds.max().y() << ','
<< bounds.max().z() << "]}}\n";
openvdb::uninitialize();
return 0;
}
catch (const std::exception &error) {
std::cerr << "VDB_VOLUME_GOLDEN_FAILED: " << error.what() << '\n';
return 1;
}
}

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-07/compositor-node-golden.json"), "utf8"));
const fixturePath = path.join(root, golden.fixture.path);
const generatorPath = path.join(root, golden.fixture.generator);
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
assert.equal(sha256(fs.readFileSync(fixturePath)), golden.fixture.sha256);
assert.equal(sha256(fs.readFileSync(generatorPath)), golden.fixture.generatorSha256);
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), new RegExp(`^Blender ${golden.fixture.blenderVersion.replaceAll(".", "\\.")}\\b`, "m"));
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m11-compositor-golden-"));
const requireFromWeb = createRequire(path.join(root, "web/package.json"));
const ts = requireFromWeb("typescript");
for (const [sourceName, outputName] of [["capability-gates.ts", "capability-gates.mjs"], ["compositor.ts", "compositor.mjs"]]) {
const sourcePath = path.join(root, "web/protocol", sourceName);
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const output = sourceName === "compositor.ts" ? transpiled.outputText.replaceAll('from "./capability-gates"', 'from "./capability-gates.mjs"') : transpiled.outputText;
fs.writeFileSync(path.join(temporary, outputName), output);
}
const compositor = await import(pathToFileURL(path.join(temporary, "compositor.mjs")));
const wasmBinary = fs.readFileSync(path.join(root, "web/app/src/vendor/blender/web_engine.wasm"));
const fixture = fs.readFileSync(fixturePath);
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function snapshot(engine, handle) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally { engine._free(dataOut); engine._free(lengthOut); }
}
try {
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
try {
open(engine, handle, fixture);
const value = snapshot(engine, handle);
for (const candidate of golden.cases) {
const scene = value.scenes.find((item) => item.name === candidate.scene);
assert.equal(scene?.compositorStatus, "AVAILABLE", `${candidate.scene} has no Main compositor graph`);
assert.deepEqual(scene.compositorGraph.nodes.map((node) => node.type), candidate.nodeTypes);
assert.deepEqual(compositor.compileCompositorWebGPUPlan(scene.compositorGraph).instructions.map((instruction) => instruction.type), candidate.nodeTypes);
const cpu = compositor.executeCompositorGraph(scene.compositorGraph, new Map(), { width: golden.width, height: golden.height });
assert.deepEqual(Array.from(cpu.composite.data.slice(0, 4)), candidate.pixel);
assert.equal(sha256(new Uint8Array(cpu.composite.data.buffer)), candidate.float32Sha256);
}
}
finally { engine._web_engine_destroy(handle); }
process.stdout.write(`compositor-node-golden-ok fixture=${golden.fixture.sha256} scenes=${golden.cases.length} allowlist=${golden.allowlist.join(",")} cpu-hashes=4\n`);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const reportPath = path.join(repoRoot, "release/soak-reports/editing.json");
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
assert.equal(report.schemaVersion, 1);
assert.equal(report.status, "READY");
assert.equal(report.profile, "FORMAL");
assert.equal(report.requiredDurationMs, 1_800_000);
assert.ok(report.configuredDurationMs >= report.requiredDurationMs);
assert.ok(report.actualDurationMs >= report.requiredDurationMs);
assert.ok(report.cycles > 100);
assert.equal(report.autosaves, report.cycles);
assert.ok(report.reopens >= 20);
assert.ok(report.finalRevision >= report.initialRevision + report.cycles * 2);
assert.match(report.finalSha256, /^[a-f0-9]{64}$/);
assert.ok(report.finalBytes > 0);
assert.ok(report.finalSnapshotCount <= report.limits.maxSnapshots);
assert.equal(report.downloadCount, 1);
assert.deepEqual(report.pageErrors, []);
assert.equal(report.failure, null);
assert.ok(report.observed.heapGrowthBytes <= report.limits.maxHeapGrowthBytes);
assert.ok(report.observed.storageGrowthBytes <= report.limits.maxStorageGrowthBytes);
assert.ok(report.resourceSamples.length >= report.reopens + 1);
assert.equal(report.resourceSamples[0].label, "baseline");
assert.equal(report.resourceSamples.at(-1).label, "final");
process.stdout.write(`editing-soak-report-ok durationMs=${report.actualDurationMs} cycles=${report.cycles} autosaves=${report.autosaves} reopens=${report.reopens} revision=${report.finalRevision} sha256=${report.finalSha256} heapGrowth=${report.observed.heapGrowthBytes} storageGrowth=${report.observed.storageGrowthBytes}\n`);

View File

@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const golden = JSON.parse(fs.readFileSync(new URL("tests/golden/M10-03/geometry-node-evaluator.json", root), "utf8"));
const fixture = fs.readFileSync(new URL(golden.fixture, root));
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
assert.equal(golden.schemaVersion, 1);
assert.equal(golden.blenderVersion, "5.2.0 LTS");
assert.equal(crypto.createHash("sha256").update(fixture).digest("hex"), golden.fixtureSha256);
assert.equal(golden.allowlist.length, 16);
assert.deepEqual(Object.keys(golden.nodeCoverage).sort(), [...golden.allowlist].sort());
for (const nodeType of golden.allowlist) {
assert.ok(golden.nodeCoverage[nodeType].length > 0, `${nodeType} has no desktop fixture coverage`);
}
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally {
engine._free(pointer);
}
}
function output(engine, handle, fn) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(fn(handle, dataOut, lengthOut), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
assert.ok(pointer > 0 && length > 0);
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function errors(expected, actual) {
assert.equal(actual.length, expected.length);
return actual.map((value, index) => value - expected[index]);
}
function compareFloatArray(expected, actual, maximum, rmsMaximum, label) {
const delta = errors(expected, actual);
const maxError = Math.max(0, ...delta.map((value) => Math.abs(value)));
const rmsError = delta.length === 0 ? 0 : Math.sqrt(
delta.reduce((sum, value) => sum + value * value, 0) / delta.length,
);
assert.ok(maxError <= maximum, `${label} max error ${maxError} exceeds ${maximum}`);
assert.ok(rmsError <= rmsMaximum, `${label} RMS error ${rmsError} exceeds ${rmsMaximum}`);
return { maxError, rmsError };
}
function bounds(positions) {
const result = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
for (let index = 0; index < positions.length; index += 3) {
for (let axis = 0; axis < 3; axis++) {
result.min[axis] = Math.min(result.min[axis], positions[index + axis]);
result.max[axis] = Math.max(result.max[axis], positions[index + axis]);
}
}
if (positions.length === 0) return { min: [0, 0, 0], max: [0, 0, 0] };
return result;
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
assert.ok(handle > 0);
try {
open(engine, handle, fixture);
const snapshot = output(engine, handle, engine._web_engine_get_scene_snapshot);
const report = output(engine, handle, engine._web_engine_evaluate_depsgraph);
assert.equal(report.engine, "BlenderDepsgraph");
assert.equal(report.status, "EVALUATED");
const graphs = new Map((snapshot.geometryNodeGraphs ?? []).map((graph) => [graph.name, graph]));
const meshes = new Map(report.meshes.map((mesh) => [mesh.objectId, mesh]));
let maximumPositionError = 0;
let maximumRmsError = 0;
for (const expectedCase of golden.cases) {
const graph = graphs.get(expectedCase.graph);
assert.ok(graph, `${expectedCase.name} graph is missing from the Main snapshot`);
assert.deepEqual(graph.nodes.map((node) => node.type).sort(), [...expectedCase.nodeTypes].sort(),
`${expectedCase.name} node inventory`);
const actual = meshes.get(`object:${expectedCase.name}`);
assert.ok(actual, `${expectedCase.name} evaluated mesh is missing`);
assert.equal(actual.vertexCount, expectedCase.mesh.vertexCount, `${expectedCase.name} vertex count`);
assert.equal(actual.triangleCount, expectedCase.mesh.triangleCount, `${expectedCase.name} triangle count`);
assert.deepEqual(actual.indices, expectedCase.mesh.indices, `${expectedCase.name} topology`);
assert.equal(actual.modifiers.length, 1, `${expectedCase.name} modifier count`);
assert.equal(actual.modifiers[0].status, "EVALUATED", `${expectedCase.name} modifier status`);
const positionError = compareFloatArray(
expectedCase.mesh.positions,
actual.positions,
golden.tolerance.maxPositionError,
golden.tolerance.rmsPositionError,
`${expectedCase.name} positions`,
);
maximumPositionError = Math.max(maximumPositionError, positionError.maxError);
maximumRmsError = Math.max(maximumRmsError, positionError.rmsError);
const actualBounds = bounds(actual.positions);
compareFloatArray(expectedCase.mesh.bounds.min, actualBounds.min,
golden.tolerance.boundsError, golden.tolerance.boundsError, `${expectedCase.name} minimum bounds`);
compareFloatArray(expectedCase.mesh.bounds.max, actualBounds.max,
golden.tolerance.boundsError, golden.tolerance.boundsError, `${expectedCase.name} maximum bounds`);
assert.deepEqual(Object.keys(actual.attributes ?? {}).sort(),
Object.keys(expectedCase.mesh.attributes).sort(), `${expectedCase.name} attribute names`);
for (const [name, expectedAttribute] of Object.entries(expectedCase.mesh.attributes)) {
const actualAttribute = actual.attributes[name];
assert.equal(actualAttribute.domain, expectedAttribute.domain, `${expectedCase.name}/${name} domain`);
assert.equal(actualAttribute.dataType, expectedAttribute.dataType, `${expectedCase.name}/${name} data type`);
compareFloatArray(expectedAttribute.values, actualAttribute.values,
golden.tolerance.maxAttributeError, golden.tolerance.maxAttributeError,
`${expectedCase.name}/${name} values`);
}
}
process.stdout.write(
`geometry-node-evaluator-ok nodes=${golden.allowlist.length} cases=${golden.cases.length} ` +
`max-position-error=${maximumPositionError} max-rms-error=${maximumRmsError} attributes=passed\n`,
);
}
finally {
engine._web_engine_destroy(handle);
}

View File

@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const expected = JSON.parse(fs.readFileSync(new URL("tests/golden/M10-01/geometry-node-main-reader.json", root), "utf8"));
const fixture = fs.readFileSync(new URL(expected.fixture, root));
const desktopGoldenBytes = fs.readFileSync(new URL(expected.desktopGolden, root));
const desktopGolden = JSON.parse(desktopGoldenBytes.toString("utf8"));
assert.equal(crypto.createHash("sha256").update(fixture).digest("hex"), expected.fixtureSha256);
assert.equal(crypto.createHash("sha256").update(desktopGoldenBytes).digest("hex"), expected.desktopGoldenSha256);
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function output(engine, handle, fn, owned = false) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(fn(handle, dataOut, lengthOut), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
const bytes = engine.HEAPU8.slice(pointer, pointer + length);
if (owned) engine._web_engine_free_buffer(pointer);
return bytes;
}
finally { engine._free(dataOut); engine._free(lengthOut); }
}
function snapshot(engine, handle) {
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
}
function socketDefault(graph, key) {
const separator = key.indexOf(":");
const nodeType = key.slice(0, separator);
const socketName = key.slice(separator + 1);
const node = graph.nodes.find((candidate) => candidate.type === nodeType);
assert.ok(node, `missing node ${nodeType} in ${graph.name}`);
const socket = node.sockets.find((candidate) => candidate.name === socketName);
assert.ok(socket, `missing socket ${nodeType}:${socketName}`);
return socket.defaultValue;
}
function assertGraphs(scene) {
const graphs = scene.geometryNodeGraphs;
assert.equal(graphs?.length, expected.graphs.length);
const desktopByName = new Map(desktopGolden.nodeGroups.map((graph) => [graph.name, graph]));
const expectedByName = new Map(expected.graphs.map((graph) => [graph.name, graph]));
for (const graph of graphs) {
const manifest = expectedByName.get(graph.name);
const desktop = desktopByName.get(graph.name);
assert.ok(manifest && desktop, `unexpected graph ${graph.name}`);
assert.equal(graph.schemaVersion, 1);
assert.equal(graph.id, `node-group:${graph.name}`);
assert.match(graph.graphHash, /^[0-9a-f]{64}$/);
assert.deepEqual(graph.nodes.map((node) => node.type), manifest.nodeTypes);
const nodeKey = (node) => `${node.name}\u0000${node.type}`;
assert.deepEqual(
[...graph.nodes].map((node) => ({ name: node.name, type: node.type })).sort((a, b) => nodeKey(a).localeCompare(nodeKey(b))),
[...desktop.nodes].sort((a, b) => nodeKey(a).localeCompare(nodeKey(b))),
);
assert.equal(graph.links.length, manifest.linkCount);
assert.equal(graph.links.length, desktop.links);
assert.deepEqual(graph.interfaceInputs.map((socket) => [socket.direction, socket.dataType]), [["INPUT", "GEOMETRY"]]);
assert.deepEqual(graph.interfaceOutputs.map((socket) => [socket.direction, socket.dataType]), [["OUTPUT", "GEOMETRY"]]);
assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length);
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
for (const node of graph.nodes) {
assert.match(node.id, /^geometry-node:[1-9][0-9]*$/);
assert.equal(node.type.includes("Undefined["), false);
assert.equal(node.sockets.some((socket) => socket.id.endsWith(":__extend__")), false);
assert.equal(new Set(node.sockets.map((socket) => socket.id)).size, node.sockets.length);
}
for (const link of graph.links) {
const fromNode = nodeById.get(link.fromNodeId);
const toNode = nodeById.get(link.toNodeId);
assert.ok(fromNode?.sockets.some((socket) => socket.id === link.fromSocketId && socket.direction === "OUTPUT"));
assert.ok(toNode?.sockets.some((socket) => socket.id === link.toSocketId && socket.direction === "INPUT"));
}
for (const [key, value] of Object.entries(manifest.defaults)) {
assert.deepEqual(socketDefault(graph, key), value);
}
}
return graphs;
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const initialGraphs = assertGraphs(snapshot(engine, handle));
const saved = output(engine, handle, engine._web_engine_save_blend, true);
engine._web_engine_destroy(handle);
const reopened = engine._web_engine_create();
open(engine, reopened, saved);
const reopenedGraphs = assertGraphs(snapshot(engine, reopened));
assert.deepEqual(reopenedGraphs, initialGraphs);
engine._web_engine_destroy(reopened);
process.stdout.write("geometry-node-main-reader-ok graphs=3 nodes=10 links=7 defaults=9 stable-id=passed graph-hash=passed desktop-golden=passed save-reopen=passed simulation-preserved=passed\n");

View File

@@ -64,11 +64,19 @@ function assertFixture(scene) {
assert.equal(data?.pointCount, 4);
const layer = data.layers[0];
const stroke = layer.frames[0].drawing.strokes[0];
assert.equal(data.activeLayerId, layer.id);
assert.equal(layer.name, "Lines");
assert.equal(layer.visible, true);
assert.equal(layer.locked, false);
assert.equal(stroke.cyclic, false);
assert.equal(stroke.materialIndex, 0);
assert.equal(stroke.id, "grease-pencil-stroke:GreasePencilData:0:0");
assert.deepEqual(stroke.points.map((point) => point.id), [
"grease-pencil-point:GreasePencilData:0:0:0",
"grease-pencil-point:GreasePencilData:0:0:1",
"grease-pencil-point:GreasePencilData:0:0:2",
"grease-pencil-point:GreasePencilData:0:0:3",
]);
assert.deepEqual(stroke.points[0].position, [-1.5, 0, 0]);
close(stroke.points[0].radius, 0.05, "radius");
close(stroke.points[0].opacity, 0.9, "opacity");
@@ -85,9 +93,13 @@ command(engine, handle, { type: "createGreasePencilLayer", dataId, name: "Web Dr
let edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.layerCount, 2);
const layerId = edited.layers.find((layer) => layer.name === "Web Drafts").id;
command(engine, handle, { type: "moveGreasePencilLayer", dataId, layerId, direction: "BOTTOM" });
command(engine, handle, { type: "moveGreasePencilLayer", dataId, layerId, direction: "BOTTOM", baseRevision: edited.revision });
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.layers[0].id, layerId);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].layers[1].id, layerId);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].layers[0].id, layerId);
command(engine, handle, { type: "insertGreasePencilFrame", dataId, layerId, frame: 10, duration: 4 });
const webStrokes = [{
cyclic: true,
@@ -107,8 +119,18 @@ assert.equal(edited.layers.find((layer) => layer.id === layerId).frames[0].drawi
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].strokeCount, 1);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].pointCount, 7);
command(engine, handle, { type: "removeGreasePencilFrame", dataId, layerId, frame: 10 });
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.pointCount, 7);
const sourceFrame = edited.layers.find((layer) => layer.id === layerId).frames[0];
command(engine, handle, { type: "moveGreasePencilFrame", dataId, layerId, frame: 10, targetFrame: 12, drawingId: sourceFrame.drawing.id, baseRevision: snapshot(engine, handle).revision });
edited = snapshot(engine, handle).greasePencils[0];
assert.equal(edited.layers.find((layer) => layer.id === layerId).frames[0].frame, 12);
assert.equal(edited.layers.find((layer) => layer.id === layerId).frames[0].drawing.id, sourceFrame.drawing.id);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].layers.find((layer) => layer.id === layerId).frames[0].frame, 10);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].layers.find((layer) => layer.id === layerId).frames[0].frame, 12);
command(engine, handle, { type: "removeGreasePencilFrame", dataId, layerId, frame: 12 });
assert.equal(snapshot(engine, handle).greasePencils[0].frameCount, 1);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.equal(snapshot(engine, handle).greasePencils[0].frameCount, 2);
@@ -130,8 +152,15 @@ assert.equal(reopenedData.frameCount, 2);
assert.equal(reopenedData.strokeCount, 2);
assert.equal(reopenedData.pointCount, 7);
const reopenedStroke = reopenedData.layers.find((layer) => layer.name === "Web Drafts").frames[0].drawing.strokes[0];
assert.deepEqual(reopenedData.layers.map((layer) => layer.name), ["Web Drafts", "Lines"]);
assert.equal(reopenedData.layers.find((layer) => layer.name === "Web Drafts").frames[0].frame, 12);
assert.equal(reopenedData.layers.find((layer) => layer.name === "Web Drafts").frames[0].drawing.id, sourceFrame.drawing.id);
assert.equal(reopenedStroke.cyclic, true);
assert.deepEqual(reopenedStroke.points[2].position, [2, 0, 0]);
close(reopenedStroke.points[2].radius, 0.3, "reopened radius");
assert.deepEqual(
reopenedData.layers.find((layer) => layer.name === "Lines").frames[0].drawing.strokes[0].points.map((point) => point.id),
original.layers[0].frames[0].drawing.strokes[0].points.map((point) => point.id),
);
engine._web_engine_destroy(reopened);
process.stdout.write("grease-pencil-roundtrip-ok layer-frame-stroke=passed undo-redo=passed save-reopen=passed\n");

Some files were not shown because too many files have changed in this diff Show More