diff --git a/.gitignore b/.gitignore index fc078711..e83dc9f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Local toolchains and caches .emcache/ .emscripten-web +__pycache__/ +*.py[cod] *.log mylog.txt diff --git a/WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md b/WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md index 99adceac..fe6b842d 100644 --- a/WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md +++ b/WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md @@ -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/ 场景/资产/缩略图/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 仍待完成 | diff --git a/blender-5.2.0/source/blender/modifiers/intern/MOD_nodes_web.cc b/blender-5.2.0/source/blender/modifiers/intern/MOD_nodes_web.cc index 25cf7e89..54bf0185 100644 --- a/blender-5.2.0/source/blender/modifiers/intern/MOD_nodes_web.cc +++ b/blender-5.2.0/source/blender/modifiers/intern/MOD_nodes_web.cc @@ -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 +#include +#include #include +#include +#include +#include #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(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(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(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(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; + +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 input_links(const bNodeSocket &socket) const + { + Vector 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(socket.default_value)->value != 0; + case SOCK_INT: + return static_cast(socket.default_value)->value; + case SOCK_FLOAT: + return static_cast(socket.default_value)->value; + case SOCK_VECTOR: + return float3(static_cast(socket.default_value)->value); + case SOCK_STRING: + return std::string( + static_cast(socket.default_value)->value); + case SOCK_OBJECT: + return static_cast(socket.default_value)->value; + case SOCK_COLLECTION: + return static_cast(socket.default_value)->value; + case SOCK_IMAGE: + return static_cast(socket.default_value)->value; + default: + return {}; + } + } + + std::optional number(const WebNodeValue &value) const + { + if (const int *integer = std::get_if(&value)) { + return float(*integer); + } + if (const float *scalar = std::get_if(&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 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 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(&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(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 a = number(evaluate_input(*a_socket)); + const std::optional 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 a = number(evaluate_input(*a_socket)); + const std::optional 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_value); + const bool *separate_children = std::get_if(&separate_value); + const bool *reset_children = std::get_if(&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 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(); + instances->resize(objects.size()); + MutableSpan handles = instances->reference_handles_for_write(); + MutableSpan transforms = instances->transforms_for_write(); + for (const int index : objects.index_range()) { + Object *evaluated = reinterpret_cast( + 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(node->storage); + return storage == nullptr ? WebNodeValue{} : WebNodeValue{storage->integer}; + } + if (node_is(*node, "FunctionNodeInputVector")) { + const NodeInputVector *storage = static_cast(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_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_value); + if (object == nullptr || *object == nullptr || *object == ctx_.object) { + error_ = "Object Info target is unavailable or recursive"; + return {}; + } + Object *evaluated = reinterpret_cast( + 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 geometries; + for (const bNodeLink *link : input_links(*input)) { + WebNodeValue value = evaluate_output(*link->fromsock); + bke::GeometrySet *geometry = std::get_if(&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 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(&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 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 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(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 attribute_value = number(evaluate_input(*value_socket)); + const bool *selection = std::get_if(&selection_value); + const std::string *name = std::get_if(&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 writer = + result_mesh->attributes_for_write().lookup_or_add_for_write_span( + *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 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(&translation_value); + if (translation == nullptr) { + error_ = "Transform Geometry translation is unavailable"; + return {}; + } + const bNodeSocketValueRotation &rotation_value = + *static_cast(rotation_socket->default_value); + const math::Quaternion rotation = math::to_quaternion( + math::EulerXYZ(float3(rotation_value.value_euler))); + const float3 scale( + static_cast(scale_socket->default_value)->value); + bke::GeometrySet result = copy_geometry(*geometry); + geometry::transform_geometry( + result, math::from_loc_rot_scale(*translation, rotation, scale)); + return bounded_geometry(std::move(result)); + } + if (node_is(*node, "GeometryNodeSetPosition")) { + std::optional 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(&selection_value); + const float3 *offset = std::get_if(&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(&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(); + 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(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( - selection_socket->default_value) - ->value; - if (selected) { - const float3 offset( - static_cast(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(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(translation_socket->default_value)->value); - const bNodeSocketValueRotation &rotation_value = - *static_cast(rotation_socket->default_value); - const math::Quaternion rotation = math::to_quaternion( - math::EulerXYZ(float3(rotation_value.value_euler))); - const float3 scale( - static_cast(scale_socket->default_value)->value); - - Mesh *result = BKE_mesh_copy_for_eval(*mesh); - bke::mesh_transform( - *result, math::from_loc_rot_scale(translation, rotation, scale), false); return result; } diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp index 39bcd9e6..c0427d59 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp @@ -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 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()), command.value("values", std::vector()), - 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") { diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp index cdd33a89..b9faa0dd 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp @@ -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 == ""}, - {"packed", read_pointer(*blend.sdna, element, "packedfile").value_or(0) != 0}}); + const std::optional packed_file = read_pointer(*blend.sdna, element, "packedfile"); + const std::vector packed_bytes = packed_file ? packed_file_bytes(blend, *packed_file) : + std::vector(); + json resource = {{"id", record.id}, + {"name", record.name}, + {"sourcePath", filepath}, + {"builtin", filepath == ""}, + {"packed", !packed_bytes.empty()}}; + if (!packed_bytes.empty()) { + resource["packedByteLength"] = packed_bytes.size(); + resource["sha256"] = sha256_hex(std::string( + reinterpret_cast(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); diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_depsgraph.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_depsgraph.cpp index 159e24ad..723d405d 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_depsgraph.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_depsgraph.cpp @@ -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 m10_value = mesh->attributes().lookup( + "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]]); diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp index a98aed07..4d6c0f75 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include @@ -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 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(default_value)->value; + return std::isfinite(value) ? std::optional(value) : std::nullopt; + } + case SOCK_INT: + return static_cast(default_value)->value; + case SOCK_BOOLEAN: + return static_cast(default_value)->value != 0; + case SOCK_VECTOR: { + const bNodeSocketValueVector &value = *static_cast(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(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(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(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(default_value); + return std::string(value.value, strnlen(value.value, sizeof(value.value))); + } + case SOCK_MENU: + return static_cast(default_value)->value; + case SOCK_OBJECT: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_IMAGE: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_COLLECTION: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_TEXTURE: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_MATERIAL: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_FONT: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_SCENE: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_TEXT_ID: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_MASK: + return geometry_id_reference(reinterpret_cast( + static_cast(default_value)->value)); + case SOCK_SOUND: + return geometry_id_reference(reinterpret_cast( + static_cast(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 value = geometry_socket_default(socket.type, socket.default_value)) { + if (!(value->is_string() && value->get_ref().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 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(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(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 &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 value = geometry_socket_default(type, socket.socket_data)) { + if (!(value->is_string() && value->get_ref().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 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 node_identifiers; + std::unordered_set 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 socket_ids; + auto append_node_sockets = [&](const ListBaseT &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 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 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 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 &indices, const std::vector &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 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 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 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> 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 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::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>(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 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 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 ¤t = 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); } diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h index 91135984..a428e62e 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h +++ b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h @@ -346,6 +346,12 @@ bool web_engine_blend_main_set_font_advanced(WebBlendMainState *state, const std::vector &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 &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 &indices, const std::vector &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, diff --git a/docs/CURRENT_EXECUTION_PLAN.md b/docs/CURRENT_EXECUTION_PLAN.md index 7946fb1e..4b7b6523 100644 --- a/docs/CURRENT_EXECUTION_PLAN.md +++ b/docs/CURRENT_EXECUTION_PLAN.md @@ -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/71;M7 已完成 7/18,下一领取点为 `M7-08` Worker 崩溃恢复。 +1. `P0`:V1 可部署 RC 已完成 M6 71/71;M7 核心体验硬化已完成 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-256:binary `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 对应 slice,N-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 对应 slice,N-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 与 Simulation(15/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 与 Sequencer(13/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 render,source/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-256,Chromium 分别以 ImageBitmap、 + 固定采样率 Web Audio 和 HTMLMedia 实际解码 PNG/WAV/H.264 MP4。 +- [x] `M11-10` long media proxy/cache 绑定源 hash 和 decode capability;schema 1 将 source + family/MIME/bytes/SHA-256、M11-09 READY receipt、RGBA8 profile 和 source frame 共同绑定为 + cache identity,payload 独立验 hash,Chromium 实际生成首帧 proxy 并通过 LRU/Storage Worker + 重开门。 +- [x] `M11-11` seek/scrub/decode 迟到结果全部受 revision gate 控制;三类请求共享单调 + request revision,结果必须回显 timeline/request identity,timeline 换代、旧请求和伪造结果 + 均在 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 与工作流 diff --git a/docs/PROJECT_STATUS_AND_NEXT_WORK.md b/docs/PROJECT_STATUS_AND_NEXT_WORK.md index 71620773..5b82481c 100644 --- a/docs/PROJECT_STATUS_AND_NEXT_WORK.md +++ b/docs/PROJECT_STATUS_AND_NEXT_WORK.md @@ -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 golden;GP 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 已完成最终渲染路由/provenance;M11-07/08 已完成有限 Compositor golden 与 Unsupported 全图阻断;M11-09/10/11/12 已完成 codec/proxy/revision/export gate;M11-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/14;M10 已完成 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 Cut,Main 保存重开 | 已完成当前操作集 | | 材质与 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/UV,native 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 整体 BLOCKED;VDB 的 desktop/server converter、HTTP/OPFS、Float32 WebGPU 双生产视口、有限 grid 材质、显式 GPU resident LRU、确定性 resident OOM 和 Main 属性重开已完成,自动 demand paging、联合重开、大 bundle 和发布 golden 未完成 | +| 实时渲染 reference | Blender 5.2 Eevee 固定相机/黑体/World fixture;SRGB8 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 hash;movie RGBA8 proxy identity 再绑定 READY receipt、profile 和 source frame;SEEK/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 帧 golden;M10-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 整体 BLOCKED;VDB 自动 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 发布 golden(main/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-table;M8 后续专项已补齐 +自动分页、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 diff --git a/docs/UNDECLARED_CAPABILITIES_EXECUTION_PLAN.md b/docs/UNDECLARED_CAPABILITIES_EXECUTION_PLAN.md index a0f55753..03912c26 100644 --- a/docs/UNDECLARED_CAPABILITIES_EXECUTION_PLAN.md +++ b/docs/UNDECLARED_CAPABILITIES_EXECUTION_PLAN.md @@ -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 depsgraph;frame 5 矩阵回归通过 | +| N-014-B2 | 细粒度 create/remove/move/resize/active 命令 | 已完成有限切片 | M10-12 `moveNLAStrip` 通过 revision gate、单次 Main `setNLAStack` transaction、undo/redo 和 save/reopen;create/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 reader;GLB/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 Texture,UV 名称/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 physical;M10-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` | planned,C1a 已覆盖常量子集 | | 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 ``` diff --git a/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md b/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md index 319e2f52..86a0dbb5 100644 --- a/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md +++ b/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md @@ -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 Volume、OPFS asset binding、Worker 重建与主线程/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 与联合故障矩阵仍阻断。 diff --git a/docs/status/M10-01.md b/docs/status/M10-01.md new file mode 100644 index 00000000..79868789 --- /dev/null +++ b/docs/status/M10-01.md @@ -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. diff --git a/docs/status/M10-02.md b/docs/status/M10-02.md new file mode 100644 index 00000000..b26f987f --- /dev/null +++ b/docs/status/M10-02.md @@ -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. diff --git a/docs/status/M10-03.md b/docs/status/M10-03.md new file mode 100644 index 00000000..3d817880 --- /dev/null +++ b/docs/status/M10-03.md @@ -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. diff --git a/docs/status/M10-04.md b/docs/status/M10-04.md new file mode 100644 index 00000000..e41cf9cb --- /dev/null +++ b/docs/status/M10-04.md @@ -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. diff --git a/docs/status/M10-05.md b/docs/status/M10-05.md new file mode 100644 index 00000000..e423af09 --- /dev/null +++ b/docs/status/M10-05.md @@ -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. diff --git a/docs/status/M10-06.md b/docs/status/M10-06.md new file mode 100644 index 00000000..268edba9 --- /dev/null +++ b/docs/status/M10-06.md @@ -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. diff --git a/docs/status/M10-07.md b/docs/status/M10-07.md new file mode 100644 index 00000000..b96b6dae --- /dev/null +++ b/docs/status/M10-07.md @@ -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. diff --git a/docs/status/M10-08.md b/docs/status/M10-08.md new file mode 100644 index 00000000..17a8b4c5 --- /dev/null +++ b/docs/status/M10-08.md @@ -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. diff --git a/docs/status/M10-09.md b/docs/status/M10-09.md new file mode 100644 index 00000000..36f59d03 --- /dev/null +++ b/docs/status/M10-09.md @@ -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. diff --git a/docs/status/M10-10.md b/docs/status/M10-10.md new file mode 100644 index 00000000..b94e0a0d --- /dev/null +++ b/docs/status/M10-10.md @@ -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. diff --git a/docs/status/M10-11.md b/docs/status/M10-11.md new file mode 100644 index 00000000..925743fc --- /dev/null +++ b/docs/status/M10-11.md @@ -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. diff --git a/docs/status/M10-12.md b/docs/status/M10-12.md new file mode 100644 index 00000000..ac414229 --- /dev/null +++ b/docs/status/M10-12.md @@ -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` diff --git a/docs/status/M10-13.md b/docs/status/M10-13.md new file mode 100644 index 00000000..56953ad0 --- /dev/null +++ b/docs/status/M10-13.md @@ -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. diff --git a/docs/status/M10-14.md b/docs/status/M10-14.md new file mode 100644 index 00000000..e09acc75 --- /dev/null +++ b/docs/status/M10-14.md @@ -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. diff --git a/docs/status/M10-15.md b/docs/status/M10-15.md new file mode 100644 index 00000000..e50e8a3a --- /dev/null +++ b/docs/status/M10-15.md @@ -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. diff --git a/docs/status/M11-01.md b/docs/status/M11-01.md new file mode 100644 index 00000000..16480c21 --- /dev/null +++ b/docs/status/M11-01.md @@ -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. diff --git a/docs/status/M11-02.md b/docs/status/M11-02.md new file mode 100644 index 00000000..e2dfd58c --- /dev/null +++ b/docs/status/M11-02.md @@ -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. diff --git a/docs/status/M11-03.md b/docs/status/M11-03.md new file mode 100644 index 00000000..24f6bdd0 --- /dev/null +++ b/docs/status/M11-03.md @@ -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. diff --git a/docs/status/M11-04.md b/docs/status/M11-04.md new file mode 100644 index 00000000..924aa70f --- /dev/null +++ b/docs/status/M11-04.md @@ -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. diff --git a/docs/status/M11-05.md b/docs/status/M11-05.md new file mode 100644 index 00000000..2b702a4a --- /dev/null +++ b/docs/status/M11-05.md @@ -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. diff --git a/docs/status/M11-06.md b/docs/status/M11-06.md new file mode 100644 index 00000000..d7311d66 --- /dev/null +++ b/docs/status/M11-06.md @@ -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. diff --git a/docs/status/M11-07.md b/docs/status/M11-07.md new file mode 100644 index 00000000..3c198569 --- /dev/null +++ b/docs/status/M11-07.md @@ -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. diff --git a/docs/status/M11-08.md b/docs/status/M11-08.md new file mode 100644 index 00000000..6548d3f6 --- /dev/null +++ b/docs/status/M11-08.md @@ -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. diff --git a/docs/status/M11-09.md b/docs/status/M11-09.md new file mode 100644 index 00000000..a90886e8 --- /dev/null +++ b/docs/status/M11-09.md @@ -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. diff --git a/docs/status/M11-10.md b/docs/status/M11-10.md new file mode 100644 index 00000000..cd83b221 --- /dev/null +++ b/docs/status/M11-10.md @@ -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. diff --git a/docs/status/M11-11.md b/docs/status/M11-11.md new file mode 100644 index 00000000..a9beb143 --- /dev/null +++ b/docs/status/M11-11.md @@ -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. diff --git a/docs/status/M11-12.md b/docs/status/M11-12.md new file mode 100644 index 00000000..1c4609e4 --- /dev/null +++ b/docs/status/M11-12.md @@ -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. diff --git a/docs/status/M11-13.md b/docs/status/M11-13.md new file mode 100644 index 00000000..ff149018 --- /dev/null +++ b/docs/status/M11-13.md @@ -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. diff --git a/docs/status/M9-14.md b/docs/status/M9-14.md new file mode 100644 index 00000000..2b145a65 --- /dev/null +++ b/docs/status/M9-14.md @@ -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. diff --git a/docs/status/N-012.md b/docs/status/N-012.md index a65f9b50..60ac6c01 100644 --- a/docs/status/N-012.md +++ b/docs/status/N-012.md @@ -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 blend,Worker 重启后重新逐帧验证; -缺失、版本不符和损坏均结构化阻断。 +M10-01 已把真实 Blender Main 图拓扑、socket 默认值、link 和稳定 node/socket ID 发布到 +SceneIR;M10-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 1:POINT/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,取消不发布 +manifest;LRU 按 `lastAccessAt/createdAt/cacheKey` 确定性淘汰并保护 active playback;Worker 重启 +重新验证,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`。 diff --git a/docs/status/N-013.md b/docs/status/N-013.md index a48c8c18..6c4b5e06 100644 --- a/docs/status/N-013.md +++ b/docs/status/N-013.md @@ -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` report;Native reader +发布 graph SHA-256,Worker 在 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。 diff --git a/docs/status/N-014.md b/docs/status/N-014.md index 3a24e972..a3e3faa1 100644 --- a/docs/status/N-014.md +++ b/docs/status/N-014.md @@ -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`。 diff --git a/docs/status/N-015.md b/docs/status/N-015.md index 4ebbc00c..f45775a1 100644 --- a/docs/status/N-015.md +++ b/docs/status/N-015.md @@ -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 proxy;depsgraph 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-D2:evaluated preview 与源控制笼分层显示、WebGPU 等价。 5. N-015-B3/D2/E1(Volume):真实资源、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-E2:100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quota;Chromium 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 不得进入 GLB;GLB 只能消费 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" ``` diff --git a/docs/status/N-016.md b/docs/status/N-016.md index 3908db02..eef099b5 100644 --- a/docs/status/N-016.md +++ b/docs/status/N-016.md @@ -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-D(editor 部分):editor context schema 绑定 data/layer/frame、onion 开关、stroke/ point selection 和 revision,拒绝 stale/重复/超 1M selection;Properties 面板已连接真实 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-D(M9-06 marquee):Main 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-D(M9-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-D(M9-08 layer/frame reorder):纯 `grease-pencil-reorder` schema 将 layer 方向移动和 + drawing frame 移动绑定到稳定 data/layer/drawing ID 与 Main base revision;stale 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-C:material 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-E:desktop 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" ``` diff --git a/docs/status/N-017.md b/docs/status/N-017.md index ef248cd8..fef8045f 100644 --- a/docs/status/N-017.md +++ b/docs/status/N-017.md @@ -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-10:WebEngine Worker 可序列化接收有界 color/weight chunks,一次 pointer + session 仅在 commit 时对 Main 发一个合并命令;真实 Main 历史只产生一个 undo step。 +14. M9-11:Storage 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 budget;Worker 拒绝重复/越界/非法组合,native 在 Main 内先写入 patch,再做 + mirror、limit、normalize。镜像 map 使用 mesh 局部坐标,要求每个顶点都有 reciprocal + counterpart,且大于 100k 顶点时结构化预算阻断。`rigged_shape_scene.blend` 的四步 + golden(initial/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-B:limit/clean、已验证拓扑映射上的 mirror、桌面 brush/falloff 对照。 -- N-017-C:Blender 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-B:clean/高级 Blender weight-paint operator、真实 PBVH brush/falloff 与更大拓扑的 + 桌面对照;当前 bounded limit/normalize/verified mirror 已完成。 +- N-017-C:Blender packed/UDIM tile Main transaction、完整色彩转换和 `.blend` 内 image + tile 保存仍阻断;浏览器 dirty tile 的 OPFS 原子 asset/binding 边界已完成。 - N-017-D/E:armature 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。 diff --git a/docs/status/N-018.md b/docs/status/N-018.md index 1be17431..a6fe9a72 100644 --- a/docs/status/N-018.md +++ b/docs/status/N-018.md @@ -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-D(M10-13 probe 门):七个 family 分别检查 runtime export、初始化、线程和内存;只有 + 四门全过才允许 `LOCAL_SOLVER`。当前生产 inventory 没有 solver adapter,全部明确路由到 + `DESKTOP_SERVER_BAKE`。合成正例只验证门逻辑,不声明真实 WASM solver 或 cache decoder。 +12. N-018-C/D(M10-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-C:desktop 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-E:bake 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`。 diff --git a/docs/status/N-019.md b/docs/status/N-019.md index e7d17fd4..d9c6c585 100644 --- a/docs/status/N-019.md +++ b/docs/status/N-019.md @@ -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 适配器把 800–20000 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 planner;WebGL2 + 总预算为 16 lights、4 x 1024 shadow maps(均包含内置保留槽),纹理同时限制数量、单边、 + aggregate payload 与 decoded RGBA bytes。WebGPU 只冻结 64/8 x 2048 及 device-limit 下调合同, + 未把未安装的 WebGPU renderer 标为可用。 +11. M11-04 有界实时 reference:Blender 5.2 Eevee 256×256 fixture 绑定 source/generator/image + SHA-256;SRGB8/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 job:schema 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 并输出 PNG;source、settings、build + 和 output 篡改均在提交或消费前结构化拒绝。 ## 仍然阻断 @@ -31,8 +56,11 @@ Three exposure/shadow 映射已落地;Scene 颜色管理 writer 和渲染等 Blender/RNA 颜色管理 API 后才能开放。Camera writer 已完成。 - N-019-B/C:AgX/Standard/Raw 的视觉等价、Area spread、Mist、DOF、 transparent sorting、probe 和高级 shadow 参数。 -- N-019-D:Cycles/Freestyle/denoise 服务端 job 协议与结果 hash。 -- N-019-E:desktop/Chromium 像素 golden、设备丢失和 1M triangles。 +- N-019-D:M11-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 triangles;M11-04 仅关闭固定 + fixture 的有界 reference 指标门,不代表全场景或全色彩管理等价。 - N-019-B/C/E(Volume):Float32 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" ``` diff --git a/docs/status/N-020.md b/docs/status/N-020.md index 9d34fd40..d672f2a2 100644 --- a/docs/status/N-020.md +++ b/docs/status/N-020.md @@ -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→Composite,RGBA 结果通过;未连接的 Glare 仍保留 Unsupported 并使完整图 capability gate 保持阻断。 +8. N-020-B/E(M11-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-D(M11-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-A:Main graph 写回,以及 Transform、非默认 Invert、Alpha Over、Blur、Mix、Image/Render Layer 的逐节点参数与 resource/pass reader;基础真实图结构、常量色、Exposure、默认 Invert、 Viewer/Composite 已完成。 -- N-020-B/C:WebGPU executor、tile scheduler、OPFS 持久 frame cache、增量 invalidation 和 GPU - dispose;CPU 周期取消和内容寻址内存 LRU 已完成。 +- N-020-B/C:M11-07 只实现四节点、无资源、单输入链的有界 WebGPU compute;通用 WebGPU + executor、tile scheduler、OPFS 持久 frame cache、增量 invalidation 和生产 GPU 调度仍阻断; + CPU 周期取消和内容寻址内存 LRU 已完成。 - N-020-D:服务端 Blender job、source hash 和结果提交。 - N-020-E:desktop 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 ``` diff --git a/docs/status/N-021.md b/docs/status/N-021.md index 711dd34b..9343ab4e 100644 --- a/docs/status/N-021.md +++ b/docs/status/N-021.md @@ -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 需要精确 probe;codec 必须 - 出现在已验证 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 @@ 仍 resident;timeline/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 probe:schema 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 cache:schema 1 identity 绑定 MOVIE source family/MIME/bytes/SHA-256、当前 + READY HTMLMedia receipt、SRGB8/STRAIGHT RGBA8 profile 和 source frame;payload 再独立绑定 + 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 gate:SEEK/SCRUB/DECODE 使用同一 schema 1 request,绑定 timeline + ID/revision、单调 request revision、operation 和 frame;completed 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 route:schema 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 recovery:schema 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.75,close 再归零并断开节点。API/构造器缺失与 resume + failure 分别稳定返回 `SEQUENCER_AUDIO_DEVICE_UNAVAILABLE` 和 + `SEQUENCER_AUDIO_RESUME_FAILED`,不会误报输出启用。 ## 仍然阻断 - N-021-A/B:Main strip 写回、非 CROSS transition/modifier 完整参数、undo/redo 和 save/reopen; 基础真实 reader 与 CROSS/GAMMA_CROSS 帧描述已完成。 -- N-021-C:WebCodecs 精确 seek/decode、音频 waveform、proxy 生成、A/V sync、丢帧和 - 损坏媒体处理。 -- N-021-D:浏览器不支持的 codec、混音与最终编码的服务端 Blender job。 +- N-021-C:M11-09/10/11/13 完成三类短 fixture 的初始实际解码门、MOVIE 初始帧 RGBA8 proxy + cache identity、迟到结果 publish/cache gate 和实时 AudioContext 生命周期;WebCodecs 帧精确 + seek/decode、多帧 proxy、音频 waveform、A/V sync、实际混音、丢帧和长媒体损坏恢复仍阻断。 +- N-021-D:M11-12 已冻结最终编码的 server-export 路由与请求 identity;真正服务端 Blender + encode job、进度/取消、混音和输出结果校验仍阻断。 - N-021-E:V1 有界 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 ``` diff --git a/docs/status/parity-ledger.json b/docs/status/parity-ledger.json index 9d2b2977..83806ded 100644 --- a/docs/status/parity-ledger.json +++ b/docs/status/parity-ledger.json @@ -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"] diff --git a/docs/status/release-evidence.json b/docs/status/release-evidence.json index 12ad4ac3..76365e45 100644 --- a/docs/status/release-evidence.json +++ b/docs/status/release-evidence.json @@ -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", diff --git a/tests/files/web/geometry_node_allowlist_evaluator.blend b/tests/files/web/geometry_node_allowlist_evaluator.blend new file mode 100644 index 00000000..e5666063 Binary files /dev/null and b/tests/files/web/geometry_node_allowlist_evaluator.blend differ diff --git a/tests/files/web/m11_compositor_allowlist.blend b/tests/files/web/m11_compositor_allowlist.blend new file mode 100644 index 00000000..42e15371 Binary files /dev/null and b/tests/files/web/m11_compositor_allowlist.blend differ diff --git a/tests/files/web/m11_render_reference.blend b/tests/files/web/m11_render_reference.blend new file mode 100644 index 00000000..36fefd5f Binary files /dev/null and b/tests/files/web/m11_render_reference.blend differ diff --git a/tests/files/web/manifest.json b/tests/files/web/manifest.json index a6313217..9bfbd76f 100644 --- a/tests/files/web/manifest.json +++ b/tests/files/web/manifest.json @@ -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", diff --git a/tests/files/web/media/sequencer-probe.mp4 b/tests/files/web/media/sequencer-probe.mp4 new file mode 100644 index 00000000..c9adfa5f Binary files /dev/null and b/tests/files/web/media/sequencer-probe.mp4 differ diff --git a/tests/files/web/nla_time_mapping_scene.blend b/tests/files/web/nla_time_mapping_scene.blend new file mode 100644 index 00000000..145be1ed Binary files /dev/null and b/tests/files/web/nla_time_mapping_scene.blend differ diff --git a/tests/golden/M10-01/geometry-node-main-reader.json b/tests/golden/M10-01/geometry-node-main-reader.json new file mode 100644 index 00000000..d249b2c4 --- /dev/null +++ b/tests/golden/M10-01/geometry-node-main-reader.json @@ -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 + } +} diff --git a/tests/golden/M10-02/geometry-node-allowlist.json b/tests/golden/M10-02/geometry-node-allowlist.json new file mode 100644 index 00000000..42c047d9 --- /dev/null +++ b/tests/golden/M10-02/geometry-node-allowlist.json @@ -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" +} diff --git a/tests/golden/M10-03/geometry-node-evaluator.json b/tests/golden/M10-03/geometry-node-evaluator.json new file mode 100644 index 00000000..e21a4f93 --- /dev/null +++ b/tests/golden/M10-03/geometry-node-evaluator.json @@ -0,0 +1,1187 @@ +{ + "allowlist": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeTransform", + "GeometryNodeSetPosition", + "GeometryNodeJoinGeometry", + "GeometryNodeSeparateGeometry", + "GeometryNodeRealizeInstances", + "GeometryNodeStoreNamedAttribute", + "FunctionNodeInputInt", + "FunctionNodeInputVector", + "FunctionNodeCompare", + "ShaderNodeValue", + "ShaderNodeMath", + "GeometryNodeObjectInfo", + "GeometryNodeCollectionInfo", + "GeometryNodeImageInfo" + ], + "blenderVersion": "5.2.0 LTS", + "cases": [ + { + "graph": "M10GN_PassthroughGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 1.0, + 1.0, + 1.0 + ], + "min": [ + -1.0, + -1.0, + -1.0 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_Passthrough", + "positions": [ + -1.0, + -1.0, + -1.0, + 1.0, + -1.0, + -1.0, + 1.0, + 1.0, + -1.0, + -1.0, + 1.0, + -1.0, + -1.0, + -1.0, + 1.0, + 1.0, + -1.0, + 1.0, + 1.0, + 1.0, + 1.0, + -1.0, + 1.0, + 1.0 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_Passthrough", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput" + ] + }, + { + "graph": "M10GN_TransformGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 1.6515215635299683, + 0.5479682683944702, + 2.5 + ], + "min": [ + -1.1515215635299683, + -1.5479682683944702, + -0.5 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_Transform", + "positions": [ + -0.7632929086685181, + -1.5479682683944702, + -0.5, + 1.6515215635299683, + -0.9009205102920532, + -0.5, + 1.263292908668518, + 0.5479682683944702, + -0.5, + -1.1515215635299683, + -0.09907945990562439, + -0.5, + -0.7632929086685181, + -1.5479682683944702, + 2.5, + 1.6515215635299683, + -0.9009205102920532, + 2.5, + 1.263292908668518, + 0.5479682683944702, + 2.5, + -1.1515215635299683, + -0.09907945990562439, + 2.5 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_Transform", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeTransform" + ] + }, + { + "graph": "M10GN_SetPositionGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 1.5, + 1.25, + 0.25 + ], + "min": [ + -0.5, + -0.75, + -1.75 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_SetPosition", + "positions": [ + -0.5, + -0.75, + -1.75, + 1.5, + -0.75, + -1.75, + 1.5, + 1.25, + -1.75, + -0.5, + 1.25, + -1.75, + -0.5, + -0.75, + 0.25, + 1.5, + -0.75, + 0.25, + 1.5, + 1.25, + 0.25, + -0.5, + 1.25, + 0.25 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_SetPosition", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeSetPosition" + ] + }, + { + "graph": "M10GN_JoinGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 4.0, + 1.0, + 1.0 + ], + "min": [ + -1.0, + -1.0, + -1.0 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7, + 8, + 9, + 10, + 8, + 10, + 11, + 12, + 15, + 14, + 12, + 14, + 13, + 8, + 12, + 13, + 8, + 13, + 9, + 9, + 13, + 14, + 9, + 14, + 10, + 10, + 14, + 15, + 10, + 15, + 11, + 12, + 8, + 11, + 12, + 11, + 15 + ], + "object": "M10GN_Join", + "positions": [ + 2.0, + -1.0, + -1.0, + 4.0, + -1.0, + -1.0, + 4.0, + 1.0, + -1.0, + 2.0, + 1.0, + -1.0, + 2.0, + -1.0, + 1.0, + 4.0, + -1.0, + 1.0, + 4.0, + 1.0, + 1.0, + 2.0, + 1.0, + 1.0, + -1.0, + -1.0, + -1.0, + 1.0, + -1.0, + -1.0, + 1.0, + 1.0, + -1.0, + -1.0, + 1.0, + -1.0, + -1.0, + -1.0, + 1.0, + 1.0, + -1.0, + 1.0, + 1.0, + 1.0, + 1.0, + -1.0, + 1.0, + 1.0 + ], + "triangleCount": 24, + "vertexCount": 16 + }, + "name": "M10GN_Join", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeJoinGeometry", + "GeometryNodeTransform" + ] + }, + { + "graph": "M10GN_SeparateIntCompareGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 1.0, + 1.0, + 1.0 + ], + "min": [ + -1.0, + -1.0, + -1.0 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_SeparateIntCompare", + "positions": [ + -1.0, + -1.0, + -1.0, + 1.0, + -1.0, + -1.0, + 1.0, + 1.0, + -1.0, + -1.0, + 1.0, + -1.0, + -1.0, + -1.0, + 1.0, + 1.0, + -1.0, + 1.0, + 1.0, + 1.0, + 1.0, + -1.0, + 1.0, + 1.0 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_SeparateIntCompare", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeSeparateGeometry", + "FunctionNodeInputInt", + "FunctionNodeInputInt", + "FunctionNodeCompare" + ] + }, + { + "graph": "M10GN_CollectionRealizeGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 2.5, + 1.0, + 1.5 + ], + "min": [ + -2.5, + -1.0, + 0.0 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 0, + 3, + 1, + 1, + 3, + 2, + 4, + 5, + 6, + 4, + 6, + 7, + 4, + 7, + 5, + 5, + 7, + 6 + ], + "object": "M10GN_CollectionRealize", + "positions": [ + -1.5, + 0.0, + 1.5, + -2.5, + -1.0, + 0.0, + -0.5, + -1.0, + 0.0, + -1.5, + 1.0, + 0.0, + 1.5, + 0.0, + 1.5, + 0.5, + -1.0, + 0.0, + 2.5, + -1.0, + 0.0, + 1.5, + 1.0, + 0.0 + ], + "triangleCount": 8, + "vertexCount": 8 + }, + "name": "M10GN_CollectionRealize", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeCollectionInfo", + "GeometryNodeRealizeInstances" + ] + }, + { + "graph": "M10GN_StoreAttributeGraph", + "mesh": { + "attributes": { + "m10_value": { + "dataType": "FLOAT", + "domain": "POINT", + "values": [ + 0.375, + 0.375, + 0.375, + 0.375, + 0.375, + 0.375, + 0.375, + 0.375 + ] + } + }, + "bounds": { + "max": [ + 1.0, + 1.0, + 1.0 + ], + "min": [ + -1.0, + -1.0, + -1.0 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_StoreAttribute", + "positions": [ + -1.0, + -1.0, + -1.0, + 1.0, + -1.0, + -1.0, + 1.0, + 1.0, + -1.0, + -1.0, + 1.0, + -1.0, + -1.0, + -1.0, + 1.0, + 1.0, + -1.0, + 1.0, + 1.0, + 1.0, + 1.0, + -1.0, + 1.0, + 1.0 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_StoreAttribute", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeStoreNamedAttribute" + ] + }, + { + "graph": "M10GN_InputVectorGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 1.125, + 1.5, + 2.25 + ], + "min": [ + -0.875, + -0.5, + 0.25 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_InputVector", + "positions": [ + -0.875, + -0.5, + 0.25, + 1.125, + -0.5, + 0.25, + 1.125, + 1.5, + 0.25, + -0.875, + 1.5, + 0.25, + -0.875, + -0.5, + 2.25, + 1.125, + -0.5, + 2.25, + 1.125, + 1.5, + 2.25, + -0.875, + 1.5, + 2.25 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_InputVector", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "FunctionNodeInputVector", + "GeometryNodeSetPosition" + ] + }, + { + "graph": "M10GN_ValueMathGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 1.0, + 1.0, + 1.625 + ], + "min": [ + -1.0, + -1.0, + -0.375 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_ValueMath", + "positions": [ + -1.0, + -1.0, + -0.375, + 1.0, + -1.0, + -0.375, + 1.0, + 1.0, + -0.375, + -1.0, + 1.0, + -0.375, + -1.0, + -1.0, + 1.625, + 1.0, + -1.0, + 1.625, + 1.0, + 1.0, + 1.625, + -1.0, + 1.0, + 1.625 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_ValueMath", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "ShaderNodeValue", + "ShaderNodeValue", + "ShaderNodeMath", + "FunctionNodeCompare", + "GeometryNodeSetPosition" + ] + }, + { + "graph": "M10GN_ObjectInfoGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 1.5, + 1.25, + 2.5 + ], + "min": [ + -0.5, + -0.75, + 0.5 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_ObjectInfo", + "positions": [ + -0.5, + -0.75, + 0.5, + 1.5, + -0.75, + 0.5, + 1.5, + 1.25, + 0.5, + -0.5, + 1.25, + 0.5, + -0.5, + -0.75, + 2.5, + 1.5, + -0.75, + 2.5, + 1.5, + 1.25, + 2.5, + -0.5, + 1.25, + 2.5 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_ObjectInfo", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeObjectInfo", + "GeometryNodeSetPosition" + ] + }, + { + "graph": "M10GN_ImageInfoGraph", + "mesh": { + "attributes": {}, + "bounds": { + "max": [ + 1.0, + 0.25, + 1.7999999523162842 + ], + "min": [ + -1.0, + -1.75, + -0.19999998807907104 + ] + }, + "indices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 7, + 6, + 4, + 6, + 5, + 0, + 4, + 5, + 0, + 5, + 1, + 1, + 5, + 6, + 1, + 6, + 2, + 2, + 6, + 7, + 2, + 7, + 3, + 4, + 0, + 3, + 4, + 3, + 7 + ], + "object": "M10GN_ImageInfo", + "positions": [ + -1.0, + -1.75, + -0.19999998807907104, + 1.0, + -1.75, + -0.19999998807907104, + 1.0, + 0.25, + -0.19999998807907104, + -1.0, + 0.25, + -0.19999998807907104, + -1.0, + -1.75, + 1.7999999523162842, + 1.0, + -1.75, + 1.7999999523162842, + 1.0, + 0.25, + 1.7999999523162842, + -1.0, + 0.25, + 1.7999999523162842 + ], + "triangleCount": 12, + "vertexCount": 8 + }, + "name": "M10GN_ImageInfo", + "nodeTypes": [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeImageInfo", + "FunctionNodeCompare", + "GeometryNodeSetPosition" + ] + } + ], + "fixture": "tests/files/web/geometry_node_allowlist_evaluator.blend", + "fixtureSha256": "0cb3e33df570b436e32d783eef0a6c2daad04ca5bb4ccb4f0286b3d723ff7978", + "nodeCoverage": { + "FunctionNodeCompare": [ + "M10GN_SeparateIntCompare", + "M10GN_ValueMath", + "M10GN_ImageInfo" + ], + "FunctionNodeInputInt": [ + "M10GN_SeparateIntCompare", + "M10GN_SeparateIntCompare" + ], + "FunctionNodeInputVector": [ + "M10GN_InputVector" + ], + "GeometryNodeCollectionInfo": [ + "M10GN_CollectionRealize" + ], + "GeometryNodeImageInfo": [ + "M10GN_ImageInfo" + ], + "GeometryNodeJoinGeometry": [ + "M10GN_Join" + ], + "GeometryNodeObjectInfo": [ + "M10GN_ObjectInfo" + ], + "GeometryNodeRealizeInstances": [ + "M10GN_CollectionRealize" + ], + "GeometryNodeSeparateGeometry": [ + "M10GN_SeparateIntCompare" + ], + "GeometryNodeSetPosition": [ + "M10GN_SetPosition", + "M10GN_InputVector", + "M10GN_ValueMath", + "M10GN_ObjectInfo", + "M10GN_ImageInfo" + ], + "GeometryNodeStoreNamedAttribute": [ + "M10GN_StoreAttribute" + ], + "GeometryNodeTransform": [ + "M10GN_Transform", + "M10GN_Join" + ], + "NodeGroupInput": [ + "M10GN_Passthrough", + "M10GN_Transform", + "M10GN_SetPosition", + "M10GN_Join", + "M10GN_SeparateIntCompare", + "M10GN_CollectionRealize", + "M10GN_StoreAttribute", + "M10GN_InputVector", + "M10GN_ValueMath", + "M10GN_ObjectInfo", + "M10GN_ImageInfo" + ], + "NodeGroupOutput": [ + "M10GN_Passthrough", + "M10GN_Transform", + "M10GN_SetPosition", + "M10GN_Join", + "M10GN_SeparateIntCompare", + "M10GN_CollectionRealize", + "M10GN_StoreAttribute", + "M10GN_InputVector", + "M10GN_ValueMath", + "M10GN_ObjectInfo", + "M10GN_ImageInfo" + ], + "ShaderNodeMath": [ + "M10GN_ValueMath" + ], + "ShaderNodeValue": [ + "M10GN_ValueMath", + "M10GN_ValueMath" + ] + }, + "schemaVersion": 1, + "tolerance": { + "boundsError": 1e-05, + "maxAttributeError": 1e-06, + "maxPositionError": 1e-05, + "rmsPositionError": 1e-06 + } +} diff --git a/tests/golden/M10-07/shader-compile.json b/tests/golden/M10-07/shader-compile.json new file mode 100644 index 00000000..a2339035 --- /dev/null +++ b/tests/golden/M10-07/shader-compile.json @@ -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" +} diff --git a/tests/golden/M10-08/shader-compile-key.json b/tests/golden/M10-08/shader-compile-key.json new file mode 100644 index 00000000..f3737e2b --- /dev/null +++ b/tests/golden/M10-08/shader-compile-key.json @@ -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" +} diff --git a/tests/golden/M10-09/shader-pipeline.json b/tests/golden/M10-09/shader-pipeline.json new file mode 100644 index 00000000..cb955da3 --- /dev/null +++ b/tests/golden/M10-09/shader-pipeline.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 1, + "taskId": "M10-09", + "failureStatus": "BLOCKED", + "preservedPipeline": true, + "replacedOnSuccess": true, + "failureDoesNotDisposePrevious": true +} diff --git a/tests/golden/M10-10/shader-capability-block.json b/tests/golden/M10-10/shader-capability-block.json new file mode 100644 index 00000000..fc48ad10 --- /dev/null +++ b/tests/golden/M10-10/shader-capability-block.json @@ -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" +} diff --git a/tests/golden/M10-11/nla-evaluation.json b/tests/golden/M10-11/nla-evaluation.json new file mode 100644 index 00000000..97dc4b2c --- /dev/null +++ b/tests/golden/M10-11/nla-evaluation.json @@ -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 + } +} diff --git a/tests/golden/M10-13/physics-solver-probe.json b/tests/golden/M10-13/physics-solver-probe.json new file mode 100644 index 00000000..9a155367 --- /dev/null +++ b/tests/golden/M10-13/physics-solver-probe.json @@ -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" +} diff --git a/tests/golden/M10-14/physics-cache-family.json b/tests/golden/M10-14/physics-cache-family.json new file mode 100644 index 00000000..0d3c383b --- /dev/null +++ b/tests/golden/M10-14/physics-cache-family.json @@ -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" +} diff --git a/tests/golden/M10-15/domain-browser-gates.json b/tests/golden/M10-15/domain-browser-gates.json new file mode 100644 index 00000000..c55f1a06 --- /dev/null +++ b/tests/golden/M10-15/domain-browser-gates.json @@ -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" + } + } +} diff --git a/tests/golden/M11-01/lighting-field-parity.json b/tests/golden/M11-01/lighting-field-parity.json new file mode 100644 index 00000000..cbf25e3b --- /dev/null +++ b/tests/golden/M11-01/lighting-field-parity.json @@ -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" } + ] + } + ] +} diff --git a/tests/golden/M11-02/lighting-field-roundtrip.json b/tests/golden/M11-02/lighting-field-roundtrip.json new file mode 100644 index 00000000..47a27a51 --- /dev/null +++ b/tests/golden/M11-02/lighting-field-roundtrip.json @@ -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 +} diff --git a/tests/golden/M11-03/render-resource-budget.json b/tests/golden/M11-03/render-resource-budget.json new file mode 100644 index 00000000..b7f6d7bb --- /dev/null +++ b/tests/golden/M11-03/render-resource-budget.json @@ -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"] + } +} diff --git a/tests/golden/M11-04/blender-eevee-reference.png b/tests/golden/M11-04/blender-eevee-reference.png new file mode 100644 index 00000000..3eaf7f0d Binary files /dev/null and b/tests/golden/M11-04/blender-eevee-reference.png differ diff --git a/tests/golden/M11-04/manifest.json b/tests/golden/M11-04/manifest.json new file mode 100644 index 00000000..4b8b2ea3 --- /dev/null +++ b/tests/golden/M11-04/manifest.json @@ -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 + } +} diff --git a/tests/golden/M11-05/render-routing.json b/tests/golden/M11-05/render-routing.json new file mode 100644 index 00000000..238c5e3a --- /dev/null +++ b/tests/golden/M11-05/render-routing.json @@ -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" + } +} diff --git a/tests/golden/M11-06/server-render-job.json b/tests/golden/M11-06/server-render-job.json new file mode 100644 index 00000000..4f14344b --- /dev/null +++ b/tests/golden/M11-06/server-render-job.json @@ -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" + } +} diff --git a/tests/golden/M11-07/compositor-node-golden.json b/tests/golden/M11-07/compositor-node-golden.json new file mode 100644 index 00000000..1f3ae5b0 --- /dev/null +++ b/tests/golden/M11-07/compositor-node-golden.json @@ -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"] +} diff --git a/tests/golden/M11-08/compositor-unsupported-gate.json b/tests/golden/M11-08/compositor-unsupported-gate.json new file mode 100644 index 00000000..f423fd70 --- /dev/null +++ b/tests/golden/M11-08/compositor-unsupported-gate.json @@ -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 +} diff --git a/tests/golden/M11-09/sequencer-codec-probe.json b/tests/golden/M11-09/sequencer-codec-probe.json new file mode 100644 index 00000000..c02bdf09 --- /dev/null +++ b/tests/golden/M11-09/sequencer-codec-probe.json @@ -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" +} diff --git a/tests/golden/M11-10/sequencer-media-cache.json b/tests/golden/M11-10/sequencer-media-cache.json new file mode 100644 index 00000000..d0c4d9a8 --- /dev/null +++ b/tests/golden/M11-10/sequencer-media-cache.json @@ -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 +} diff --git a/tests/golden/M11-11/sequencer-media-revision.json b/tests/golden/M11-11/sequencer-media-revision.json new file mode 100644 index 00000000..205dc393 --- /dev/null +++ b/tests/golden/M11-11/sequencer-media-revision.json @@ -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"] +} diff --git a/tests/golden/M11-12/sequencer-final-export.json b/tests/golden/M11-12/sequencer-final-export.json new file mode 100644 index 00000000..81adc084 --- /dev/null +++ b/tests/golden/M11-12/sequencer-final-export.json @@ -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 + } +} diff --git a/tests/golden/M11-13/sequencer-audio-recovery.json b/tests/golden/M11-13/sequencer-audio-recovery.json new file mode 100644 index 00000000..727c1cf9 --- /dev/null +++ b/tests/golden/M11-13/sequencer-audio-recovery.json @@ -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"] +} diff --git a/tests/golden/M8-19/generated-smoke-x.rgba b/tests/golden/M8-19/generated-smoke-x.rgba new file mode 100644 index 00000000..6d948ef3 Binary files /dev/null and b/tests/golden/M8-19/generated-smoke-x.rgba differ diff --git a/tests/golden/M8-19/generated-smoke-y.rgba b/tests/golden/M8-19/generated-smoke-y.rgba new file mode 100644 index 00000000..6d948ef3 Binary files /dev/null and b/tests/golden/M8-19/generated-smoke-y.rgba differ diff --git a/tests/golden/M8-19/generated-smoke-z.rgba b/tests/golden/M8-19/generated-smoke-z.rgba new file mode 100644 index 00000000..6d948ef3 Binary files /dev/null and b/tests/golden/M8-19/generated-smoke-z.rgba differ diff --git a/tests/golden/M8-19/manifest.json b/tests/golden/M8-19/manifest.json new file mode 100644 index 00000000..69311155 --- /dev/null +++ b/tests/golden/M8-19/manifest.json @@ -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 + } + ] +} diff --git a/tests/golden/M9-05/curve-toggle-cyclic.json b/tests/golden/M9-05/curve-toggle-cyclic.json new file mode 100644 index 00000000..a3aab3aa --- /dev/null +++ b/tests/golden/M9-05/curve-toggle-cyclic.json @@ -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" + ] +} diff --git a/tests/golden/M9-08/grease-pencil-reorder.json b/tests/golden/M9-08/grease-pencil-reorder.json new file mode 100644 index 00000000..a26033e1 --- /dev/null +++ b/tests/golden/M9-08/grease-pencil-reorder.json @@ -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" + ] + } +} diff --git a/tests/golden/M9-12/weight-paint.json b/tests/golden/M9-12/weight-paint.json new file mode 100644 index 00000000..51457e50 --- /dev/null +++ b/tests/golden/M9-12/weight-paint.json @@ -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 +} diff --git a/tests/golden/M9-14/manifest.json b/tests/golden/M9-14/manifest.json new file mode 100644 index 00000000..e1134fdf --- /dev/null +++ b/tests/golden/M9-14/manifest.json @@ -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" + } +} diff --git a/tools/vdb/CMakeLists.txt b/tools/vdb/CMakeLists.txt index 29438380..b805a546 100644 --- a/tools/vdb/CMakeLists.txt +++ b/tools/vdb/CMakeLists.txt @@ -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 diff --git a/tools/vdb/generate-volume-golden.mjs b/tools/vdb/generate-volume-golden.mjs new file mode 100644 index 00000000..d26a6753 --- /dev/null +++ b/tools/vdb/generate-volume-golden.mjs @@ -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 }); +} diff --git a/tools/vdb/vdb_volume_golden.cc b/tools/vdb/vdb_volume_golden.cc new file mode 100644 index 00000000..899c8e0a --- /dev/null +++ b/tools/vdb/vdb_volume_golden.cc @@ -0,0 +1,179 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr int kImageSize = 64; +constexpr std::array kColor = {0.72f, 0.78f, 0.86f}; + +float sample_linear(const openvdb::FloatGrid::ConstAccessor &accessor, + const std::array &position) +{ + const std::array base = { + static_cast(std::floor(position[0])), + static_cast(std::floor(position[1])), + static_cast(std::floor(position[2])), + }; + const std::array fraction = { + position[0] - static_cast(base[0]), + position[1] - static_cast(base[1]), + position[2] - static_cast(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(std::lround(std::clamp(value, 0.0f, 1.0f) * 255.0f)); +} + +std::vector 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 plane_a = view_axis == 0 ? std::array{1, 2, 0} : + view_axis == 1 ? std::array{0, 2, 1} : + std::array{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(density.voxelSize()[ray_axis])); + const float phase = 1.0f / 12.5663706f; + const float source_scale = 0.5f + 8.0f * phase; + std::vector pixels(kImageSize * kImageSize * 4, 0); + + for (int y = 0; y < kImageSize; ++y) { + for (int x = 0; x < kImageSize; ++x) { + const std::array plane_min = {minimum[plane_a[0]], minimum[plane_a[1]]}; + const std::array plane_max = {maximum[plane_a[0]], maximum[plane_a[1]]}; + const std::array extent = { + static_cast(plane_max[0] - plane_min[0] + 1), + static_cast(plane_max[1] - plane_min[1] + 1), + }; + const std::array plane_position = { + static_cast(plane_min[0]) + + ((static_cast(x) + 0.5f) / static_cast(kImageSize)) * extent[0] - 0.5f, + static_cast(plane_min[1]) + + ((static_cast(y) + 0.5f) / static_cast(kImageSize)) * extent[1] - 0.5f, + }; + + float transmittance = 1.0f; + std::array radiance = {0.0f, 0.0f, 0.0f}; + for (int ray = ray_min; ray <= ray_max; ray += stride) { + std::array position{}; + position[plane_a[0]] = plane_position[0]; + position[plane_a[1]] = plane_position[1]; + position[ray_axis] = static_cast(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(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(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 &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(bytes.data()), static_cast(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(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 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; + } +} diff --git a/tools/web/check-compositor-node-golden.mjs b/tools/web/check-compositor-node-golden.mjs new file mode 100644 index 00000000..ac3e9bbd --- /dev/null +++ b/tools/web/check-compositor-node-golden.mjs @@ -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 }); +} diff --git a/tools/web/check-editing-soak-report.mjs b/tools/web/check-editing-soak-report.mjs new file mode 100644 index 00000000..727cb90f --- /dev/null +++ b/tools/web/check-editing-soak-report.mjs @@ -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`); diff --git a/tools/web/check-geometry-node-evaluator-golden.mjs b/tools/web/check-geometry-node-evaluator-golden.mjs new file mode 100644 index 00000000..4229ac00 --- /dev/null +++ b/tools/web/check-geometry-node-evaluator-golden.mjs @@ -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); +} diff --git a/tools/web/check-geometry-node-main-reader.mjs b/tools/web/check-geometry-node-main-reader.mjs new file mode 100644 index 00000000..d56bdaf0 --- /dev/null +++ b/tools/web/check-geometry-node-main-reader.mjs @@ -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"); diff --git a/tools/web/check-grease-pencil-roundtrip.mjs b/tools/web/check-grease-pencil-roundtrip.mjs index 1dd5ec79..8ce422bb 100644 --- a/tools/web/check-grease-pencil-roundtrip.mjs +++ b/tools/web/check-grease-pencil-roundtrip.mjs @@ -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"); diff --git a/tools/web/check-lighting-field-parity.mjs b/tools/web/check-lighting-field-parity.mjs new file mode 100644 index 00000000..e0d64b02 --- /dev/null +++ b/tools/web/check-lighting-field-parity.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const sourcePath = path.join(root, "web/protocol/scene-ir.ts"); +const source = ts.createSourceFile(sourcePath, fs.readFileSync(sourcePath, "utf8"), ts.ScriptTarget.Latest, true); +const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-01/lighting-field-parity.json"), "utf8")); + +function interfaceDeclaration(name) { + const declaration = source.statements.find((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === name); + assert.ok(declaration, `missing interface ${name}`); + return declaration; +} + +function leafTypePaths(type, prefix) { + if (!ts.isTypeLiteralNode(type)) return [prefix]; + return type.members.filter(ts.isPropertySignature).flatMap((member) => + leafTypePaths(member.type, `${prefix}.${member.name.getText(source)}`)); +} + +function interfaceLeaves(name, include = () => true) { + return interfaceDeclaration(name).members.filter((member) => + ts.isPropertySignature(member) && include(member.name.getText(source))).flatMap((member) => + leafTypePaths(member.type, member.name.getText(source))).sort(); +} + +const expected = new Map([ + ["CAMERA", interfaceLeaves("CameraIR")], + ["LIGHT", interfaceLeaves("LightIR")], + ["WORLD", interfaceLeaves("WorldIR")], + ["SCENE_COLOR_MANAGEMENT", interfaceLeaves("SceneIR", (name) => name === "renderEngine" || name === "colorManagement")], +]); +const stages = ["reader", "writer", "viewportMain", "viewportOffscreen"]; +const stageStates = new Set(["VERIFIED", "PARTIAL", "METADATA_ONLY", "BLOCKED", "NOT_APPLICABLE"]); +const parityStates = new Set(["COMPLETE", "PARTIAL", "BLOCKED"]); + +assert.equal(manifest.schemaVersion, 1); +assert.equal(manifest.task, "M11-01"); +assert.equal(manifest.blenderVersion, "5.2.0"); +assert.deepEqual(manifest.domains.map((domain) => domain.domain), [...expected.keys()]); + +let total = 0; +const parityCounts = { COMPLETE: 0, PARTIAL: 0, BLOCKED: 0 }; +for (const domain of manifest.domains) { + assert.deepEqual(domain.fields.map((field) => field.path).sort(), expected.get(domain.domain)); + assert.equal(new Set(domain.fields.map((field) => field.path)).size, domain.fields.length); + assert.ok(Array.isArray(domain.evidence) && domain.evidence.length >= 4); + for (const evidence of domain.evidence) assert.ok(fs.existsSync(path.join(root, evidence)), `${domain.domain} evidence is missing: ${evidence}`); + for (const field of domain.fields) { + for (const stage of stages) assert.ok(stageStates.has(field[stage]), `${domain.domain}.${field.path}.${stage} is invalid`); + assert.ok(parityStates.has(field.parity), `${domain.domain}.${field.path}.parity is invalid`); + if (field.parity === "COMPLETE") { + assert.ok(stages.every((stage) => field[stage] === "VERIFIED" || field[stage] === "NOT_APPLICABLE"), `${domain.domain}.${field.path} overclaims COMPLETE`); + } + parityCounts[field.parity] += 1; + total += 1; + } +} + +const requiredBlocks = [ + ["CAMERA", "orthoScale", "viewportMain"], + ["CAMERA", "depthOfField.enabled", "viewportMain"], + ["LIGHT", "areaSpread", "viewportMain"], + ["LIGHT", "sunAngle", "viewportOffscreen"], + ["WORLD", "environmentRotation", "reader"], + ["SCENE_COLOR_MANAGEMENT", "colorManagement.displayDevice", "viewportMain"], + ["SCENE_COLOR_MANAGEMENT", "colorManagement.gamma", "viewportOffscreen"], +]; +for (const [domainName, fieldPath, stage] of requiredBlocks) { + const field = manifest.domains.find((domain) => domain.domain === domainName)?.fields.find((candidate) => candidate.path === fieldPath); + assert.equal(field?.[stage], "BLOCKED", `${domainName}.${fieldPath}.${stage} must remain BLOCKED`); +} + +assert.equal(total, 60); +process.stdout.write(`lighting-field-parity-ok fields=${total} complete=${parityCounts.COMPLETE} partial=${parityCounts.PARTIAL} blocked=${parityCounts.BLOCKED}\n`); diff --git a/tools/web/check-lighting-roundtrip.mjs b/tools/web/check-lighting-roundtrip.mjs index 01a67e4d..940a3aae 100644 --- a/tools/web/check-lighting-roundtrip.mjs +++ b/tools/web/check-lighting-roundtrip.mjs @@ -115,6 +115,13 @@ function assertEdited(scene) { assert.equal(light.useTemperature, true); close(light.temperature, 5000, "light temperature"); assert.deepEqual(light.color, [0.25, 0.5, 0.75]); + close(light.radius, 0.3, "light radius"); + close(light.spotAngle, 1.1, "light spot angle"); + close(light.spotBlend, 0.25, "light spot blend"); + close(light.areaSize, 3, "light area size"); + close(light.areaSizeY, 2, "light area size y"); + close(light.areaSpread, 2.4, "light area spread"); + close(light.sunAngle, 0.1, "light sun angle"); assert.equal(world.mist.enabled, true); assert.equal(world.mist.type, "LINEAR"); close(world.mist.start, 2, "mist start"); @@ -151,7 +158,8 @@ cameraEdited = snapshot(engine, handle); assert.equal(cameraEdited.cameras.find((item) => item.id === before.camera.id).projection, "ORTHOGRAPHIC"); command(engine, handle, { type: "setLightProperties", dataId: before.light.id, properties: { color: [0.25, 0.5, 0.75], energy: 400, exposure: 1, castsShadow: false, - temperature: 5000, useTemperature: true, + temperature: 5000, useTemperature: true, radius: 0.3, spotAngle: 1.1, spotBlend: 0.25, + areaSize: 3, areaSizeY: 2, areaSpread: 2.4, sunAngle: 0.1, } }); assert.equal(snapshot(engine, handle).scenes.find((item) => item.id === before.definition.id).colorManagement.whiteBalanceStatus, "BLOCKED"); command(engine, handle, { type: "setWorldProperties", dataId: before.world.id, properties: { diff --git a/tools/web/check-nla-evaluation-golden.mjs b/tools/web/check-nla-evaluation-golden.mjs new file mode 100644 index 00000000..d8b01be4 --- /dev/null +++ b/tools/web/check-nla-evaluation-golden.mjs @@ -0,0 +1,119 @@ +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-11/nla-evaluation.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(crypto.createHash("sha256").update(fixture).digest("hex"), golden.fixtureSha256); +assert.equal(golden.schemaVersion, 1); + +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 setFrame(engine, handle, frame) { + const command = new TextEncoder().encode(JSON.stringify({ type: "setFrame", frame })); + const pointer = engine._malloc(command.byteLength); + try { + engine.HEAPU8.set(command, pointer); + assert.equal(engine._web_engine_apply_command(handle, pointer, command.byteLength), 0, + engine.UTF8ToString(engine._web_engine_last_error_message())); + } + finally { engine._free(pointer); } +} + +function assertClose(expected, actual, label) { + assert.equal(actual.length, expected.length, `${label} length`); + const maximum = Math.max(0, ...expected.map((value, index) => Math.abs(actual[index] - value))); + assert.ok(maximum <= golden.tolerance.maxMatrixError, `${label} max error ${maximum}`); +} + +function assertNlaSnapshot(snapshot) { + assert.equal(snapshot.nlaTracks?.length, golden.tracks.length, "NLA track count"); + const track = snapshot.nlaTracks[0]; + const expectedTrack = golden.tracks[0]; + assert.equal(track.schemaVersion, 1); + assert.equal(track.ownerId, `object:${golden.object}`); + assert.equal(track.name, expectedTrack.name); + assert.equal(track.muted, expectedTrack.muted); + assert.equal(track.solo, expectedTrack.solo); + assert.equal(track.strips.length, expectedTrack.strips.length); + expectedTrack.strips.forEach((expected, index) => { + const actual = track.strips[index]; + assert.equal(actual.id, expected.id); + assert.equal(actual.actionId, `action:${expected.action}:object:${golden.object}`); + for (const field of ["frameStart", "frameEnd", "actionFrameStart", "actionFrameEnd", "scale", "repeat", "blendIn", "blendOut", "influence"]) { + assert.equal(actual[field], expected[field], `${expected.id}.${field}`); + } + for (const field of ["blendMode", "extrapolation", "muted", "reverse"]) { + assert.equal(actual[field], expected[field], `${expected.id}.${field}`); + } + assert.equal(actual.stripType, "CLIP"); + assert.equal(actual.useTimeWarp, false); + }); + for (const expected of expectedTrack.strips) { + const action = snapshot.animations.find((candidate) => candidate.id === `action:${expected.action}:object:${golden.object}`); + assert.ok(action, `${expected.action} animation`); + assert.equal(action.targetId, `object:${golden.object}`); + assert.equal(action.frameStart, 1); + assert.equal(action.frameEnd, 11); + assert.ok(action.channels.some((channel) => channel.path.startsWith("location[")), `${expected.action} location channel`); + } +} + +const engine = await factory({ wasmBinary: wasmBinary.slice() }); +const handle = engine._web_engine_create(); +assert.ok(handle > 0); +try { + open(engine, handle, fixture); + const initial = output(engine, handle, engine._web_engine_get_scene_snapshot); + assertNlaSnapshot(initial); + const initialNla = JSON.stringify({ nlaTracks: initial.nlaTracks, animations: initial.animations }); + let maximumMatrixError = 0; + for (const expected of golden.frames) { + setFrame(engine, handle, expected.frame); + const report = output(engine, handle, engine._web_engine_evaluate_depsgraph); + assert.equal(report.engine, "BlenderDepsgraph"); + assert.equal(report.status, "EVALUATED"); + assert.equal(report.frame, expected.frame); + const mesh = report.meshes.find((candidate) => candidate.objectId === `object:${golden.object}`); + assert.ok(mesh, `evaluated ${golden.object} at frame ${expected.frame}`); + const errors = mesh.worldMatrix.map((value, index) => Math.abs(value - expected.worldMatrix[index])); + maximumMatrixError = Math.max(maximumMatrixError, ...errors); + assertClose(expected.worldMatrix, mesh.worldMatrix, `frame ${expected.frame}`); + const after = output(engine, handle, engine._web_engine_get_scene_snapshot); + assert.equal(JSON.stringify({ nlaTracks: after.nlaTracks, animations: after.animations }), initialNla, + `NLA read-only identity changed at frame ${expected.frame}`); + } + process.stdout.write(`nla-evaluation-ok tracks=${golden.tracks.length} strips=${golden.tracks[0].strips.length} frames=${golden.frames.length} max-matrix-error=${maximumMatrixError} read-only=passed\n`); +} +finally { + engine._web_engine_destroy(handle); +} diff --git a/tools/web/check-paint-roundtrip.mjs b/tools/web/check-paint-roundtrip.mjs index 11eff60b..37616802 100644 --- a/tools/web/check-paint-roundtrip.mjs +++ b/tools/web/check-paint-roundtrip.mjs @@ -151,14 +151,30 @@ close(vertexWeight(snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id assert.equal(engine._web_engine_redo(weightHandle), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); close(vertexWeight(snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId), 2, "WebPaintGroup"), 0.5 / 1.75, "redone normalized vertex 2 paint weight"); -reject(engine, weightHandle, { +apply(engine, weightHandle, { type: "setVertexWeights", objectId, vertexGroup: "WebPaintGroup", indices: [3], - values: [0.5], + values: [0.4], + limit: 2, + normalize: true, +}); +weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId); +close(vertexWeight(weightedMesh, 3, "WebPaintGroup"), 0.4 / 1.4, "limited normalized vertex 3 weight"); +apply(engine, weightHandle, { + type: "setVertexWeights", + objectId, + vertexGroup: "WebPaintGroup", + indices: [0], + values: [0.9], mirror: true, -}, "CAPABILITY_MISSING"); + mirrorAxis: 0, + mirrorTolerance: 1e-4, +}); +weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId); +close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.9, "mirrored vertex 0 weight"); +close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.9, "mirrored vertex 1 weight"); const savedWeights = output(engine, weightHandle, engine._web_engine_save_blend, true); engine._web_engine_destroy(weightHandle); @@ -166,8 +182,9 @@ const reopenedWeights = engine._web_engine_create(); open(engine, reopenedWeights, savedWeights); weightedMesh = snapshot(engine, reopenedWeights).meshes.find((mesh) => mesh.id === meshId); assertVertexGroup(weightedMesh, "WebPaintGroup"); -close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "reopened vertex 0 paint weight"); +close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.9, "reopened mirrored vertex 0 paint weight"); +close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.9, "reopened mirrored vertex 1 paint weight"); close(vertexWeight(weightedMesh, 2, "WebPaintGroup"), 0.5 / 1.75, "reopened normalized vertex 2 paint weight"); engine._web_engine_destroy(reopenedWeights); -process.stdout.write("paint-roundtrip-ok vertex-color=passed vertex-weight-normalize=passed mirror-gate=passed undo-redo=passed save-reopen=passed\n"); +process.stdout.write("paint-roundtrip-ok vertex-color=passed vertex-weight-normalize=passed vertex-weight-limit=passed vertex-weight-mirror=passed mirror-gate=passed undo-redo=passed save-reopen=passed\n"); diff --git a/tools/web/check-render-reference.mjs b/tools/web/check-render-reference.mjs new file mode 100644 index 00000000..be60c69c --- /dev/null +++ b/tools/web/check-render-reference.mjs @@ -0,0 +1,50 @@ +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 { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const manifestPath = path.join(root, "tests/golden/M11-04/manifest.json"); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +const fixture = path.join(root, manifest.source.fixture); +const reference = path.join(path.dirname(manifestPath), manifest.reference.file); +const generator = path.join(root, manifest.source.generator); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); + +assert.equal(manifest.schemaVersion, 1); +assert.equal(manifest.task, "M11-04"); +assert.equal(manifest.reference.colorSpace, "SRGB8"); +assert.equal(manifest.reference.alphaMode, "STRAIGHT"); +assert.equal(manifest.reference.width, 256); +assert.equal(manifest.reference.height, 256); +assert.equal(sha256(fixture), manifest.source.fixtureSha256); +assert.equal(sha256(generator), manifest.source.generatorSha256); +assert.equal(sha256(reference), manifest.reference.sha256); +assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m); + +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "m11-render-reference-")); +try { + const generatedFixture = path.join(temporaryRoot, "reference.blend"); + const generatedReference = path.join(temporaryRoot, "reference.png"); + execFileSync(blender, [ + "--background", + "--factory-startup", + "--python", + generator, + "--", + generatedFixture, + generatedReference, + reference, + ], { cwd: root, stdio: "pipe" }); + assert.ok(fs.statSync(generatedFixture).size > 0, "desktop reference fixture was not generated"); + assert.ok(fs.statSync(generatedReference).size > 0, "desktop reference render was not generated"); +} +finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); +} + +console.log(`render-reference-ok blender=5.2.0 fixture=${manifest.source.fixtureSha256} image=${manifest.reference.sha256}`); diff --git a/tools/web/check-server-render-job.mjs b/tools/web/check-server-render-job.mjs new file mode 100644 index 00000000..20e5e586 --- /dev/null +++ b/tools/web/check-server-render-job.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const execFileAsync = promisify(execFile); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const fixture = path.join(root, "tests/files/web/m11_render_reference.blend"); +const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); +const renderScript = path.join(root, "tools/web/render-server-job.py"); +const golden = JSON.parse(await fsp.readFile(path.join(root, "tests/golden/M11-06/server-render-job.json"), "utf8")); +const sourceBytes = await fsp.readFile(fixture); +const sourceArrayBuffer = sourceBytes.buffer.slice(sourceBytes.byteOffset, sourceBytes.byteOffset + sourceBytes.byteLength); +const buildSha256 = crypto.createHash("sha256").update(await fsp.readFile(blender)).digest("hex"); +const versionOutput = (await execFileAsync(blender, ["--version"], { cwd: root })).stdout; +const versionLine = versionOutput.split(/\r?\n/).find((line) => line.startsWith("Blender ")) ?? ""; +const buildVersion = versionLine.match(/^Blender\s+(5\.2\.[0-9]+)\b/)?.[1]; +assert.equal(buildVersion, "5.2.0", `unexpected Blender build: ${versionOutput}`); +assert.equal(golden.schemaVersion, 1); +assert.equal(golden.task, "M11-06"); +assert.equal(golden.blenderVersion, buildVersion); +assert.ok(fs.existsSync(fixture), "M11-04 source fixture is missing"); + +const requireFromWeb = createRequire(path.join(root, "web/package.json")); +const ts = requireFromWeb("typescript"); +const protocolSource = fs.readFileSync(path.join(root, "web/protocol/server-render-job.ts"), "utf8") + .replace('import type { ErrorCode } from "./error";\n', ""); +const transpiled = ts.transpileModule(protocolSource, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: "server-render-job.ts", + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const protocol = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`); + +const settings = { + renderEngine: "BLENDER_EEVEE", + frameStart: 1, + frameEnd: 1, + resolutionX: 256, + resolutionY: 256, + resolutionPercentage: 100, + samples: 1, + outputMime: "image/png", + transparent: false, +}; +const build = { version: buildVersion, buildSha256 }; +const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "m11-server-render-job-")); + +function json(response, status, value) { + const body = Buffer.from(`${JSON.stringify(value)}\n`); + response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", "Content-Length": body.length }); + response.end(body); +} + +const server = http.createServer(async (request, response) => { + if (request.method !== "POST" || request.url !== "/v1/render/jobs") return json(response, 404, { code: "NOT_FOUND" }); + const chunks = []; + let sourceByteLength = 0; + try { + for await (const chunk of request) { + chunks.push(chunk); + sourceByteLength += chunk.length; + if (sourceByteLength > 512 * 1024 * 1024) throw new Error("SERVER_RENDER_SOURCE_INVALID: upload exceeds budget"); + } + const body = Buffer.concat(chunks); + const requestHeader = request.headers["x-render-request"]; + if (typeof requestHeader !== "string") throw new Error("SERVER_RENDER_REQUEST_INVALID: missing request metadata"); + const renderRequest = await protocol.verifyServerRenderJobRequest(JSON.parse(requestHeader), body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)); + if (renderRequest.blenderBuild.version !== build.version || renderRequest.blenderBuild.buildSha256 !== build.buildSha256) { + throw Object.assign(new Error("SERVER_RENDER_BUILD_MISMATCH: requested build is not the executable serving this job"), { code: "SERVER_RENDER_BUILD_MISMATCH" }); + } + const fileStem = renderRequest.jobId.replace(/[^A-Za-z0-9_.-]/g, "_"); + const sourcePath = path.join(temporary, `${fileStem}.blend`); + const outputPath = path.join(temporary, `${fileStem}.png`); + const settingsPath = path.join(temporary, `${fileStem}.json`); + await fsp.writeFile(sourcePath, body, { flag: "wx", mode: 0o600 }); + await fsp.writeFile(settingsPath, `${JSON.stringify(renderRequest.settings)}\n`, { flag: "wx", mode: 0o600 }); + await execFileAsync(blender, ["--background", "--factory-startup", "--python", renderScript, "--", sourcePath, outputPath, settingsPath], { cwd: root, maxBuffer: 2 * 1024 * 1024 }); + const outputBytes = await fsp.readFile(outputPath); + const result = await protocol.createServerRenderJobResult(renderRequest, outputBytes.buffer.slice(outputBytes.byteOffset, outputBytes.byteOffset + outputBytes.byteLength)); + json(response, 200, result); + } catch (error) { + json(response, 400, { code: error?.code ?? "SERVER_RENDER_FAILED", message: error instanceof Error ? error.message : String(error) }); + } +}); + +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); +}); +const address = server.address(); +assert.ok(address && typeof address !== "string"); +const origin = `http://127.0.0.1:${address.port}`; + +try { + const renderRequest = await protocol.createServerRenderJobRequest(sourceArrayBuffer, build, settings, { jobId: "render:m11-06", sourceRevision: 11 }); + const response = await fetch(`${origin}/v1/render/jobs`, { + method: "POST", + headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(renderRequest) }, + body: sourceBytes, + duplex: "half", + }); + const responseText = await response.text(); + assert.equal(response.status, 200, responseText); + const result = JSON.parse(responseText); + const outputPath = path.join(temporary, "verified-output.png"); + const outputBytes = await fsp.readFile(path.join(temporary, "render_m11-06.png")); + await fsp.writeFile(outputPath, outputBytes); + await protocol.verifyServerRenderJobResult(result, renderRequest, outputBytes.buffer.slice(outputBytes.byteOffset, outputBytes.byteOffset + outputBytes.byteLength)); + assert.equal(result.sourceBlendSha256, renderRequest.sourceBlendSha256); + assert.equal(result.settingsSha256, renderRequest.settingsSha256); + assert.equal(result.sourceBlendSha256, golden.sourceBlendSha256); + assert.equal(result.settingsSha256, golden.settingsSha256); + assert.equal(result.outputMime, golden.output.mime); + assert.ok(result.outputByteLength >= golden.output.minimumByteLength); + assert.match(result.outputSha256, new RegExp(golden.output.sha256Pattern)); + + const tamperedSource = Buffer.from(sourceBytes); + tamperedSource[0] ^= 0xff; + const sourceFailure = await fetch(`${origin}/v1/render/jobs`, { + method: "POST", + headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(renderRequest) }, + body: tamperedSource, + duplex: "half", + }); + assert.equal(sourceFailure.status, 400); + assert.equal((await sourceFailure.json()).code, golden.tamperCodes.source); + + const wrongBuildRequest = await protocol.createServerRenderJobRequest(sourceArrayBuffer, { ...build, buildSha256: "b".repeat(64) }, settings, { jobId: "render:wrong-build", sourceRevision: 11 }); + const buildFailure = await fetch(`${origin}/v1/render/jobs`, { + method: "POST", + headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(wrongBuildRequest) }, + body: sourceBytes, + duplex: "half", + }); + assert.equal(buildFailure.status, 400); + assert.equal((await buildFailure.json()).code, golden.tamperCodes.build); + + const settingsFailureRequest = { ...renderRequest, settings: { ...renderRequest.settings, samples: 2 } }; + const settingsFailure = await fetch(`${origin}/v1/render/jobs`, { + method: "POST", + headers: { "Content-Type": "application/x-blend", "X-Render-Request": JSON.stringify(settingsFailureRequest) }, + body: sourceBytes, + duplex: "half", + }); + assert.equal(settingsFailure.status, 400); + assert.equal((await settingsFailure.json()).code, golden.tamperCodes.settings); + + const tamperedOutput = Buffer.from(outputBytes); + tamperedOutput[tamperedOutput.length - 1] ^= 0xff; + await assert.rejects( + protocol.verifyServerRenderJobResult(result, renderRequest, tamperedOutput.buffer.slice(tamperedOutput.byteOffset, tamperedOutput.byteOffset + tamperedOutput.byteLength)), + { code: golden.tamperCodes.output }, + ); + process.stdout.write(`server-render-job-ok blender=${buildVersion} source=${renderRequest.sourceBlendSha256} settings=${renderRequest.settingsSha256} output=${result.outputSha256} bytes=${result.outputByteLength} source-hash=1 build-hash=1 settings-hash=1 output-hash=1 tamper=4\n`); +} finally { + await new Promise((resolve) => server.close(resolve)); + await fsp.rm(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/check-weight-paint-golden.mjs b/tools/web/check-weight-paint-golden.mjs new file mode 100644 index 00000000..1ab4838b --- /dev/null +++ b/tools/web/check-weight-paint-golden.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +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/M9-12/weight-paint.json", root), "utf8")); +const fixture = fs.readFileSync(new URL(`tests/files/web/${golden.fixture}`, root)); +const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root)); + +function call(engine, handle, name, payload) { + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + const pointer = engine._malloc(bytes.byteLength); + try { + engine.HEAPU8.set(bytes, pointer); + assert.equal(engine._web_engine_apply_command(handle, pointer, bytes.byteLength), 0, + `${name}: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`); + } + finally { + engine._free(pointer); + } +} + +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); + 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); + } +} + +function save(engine, handle) { + const dataOut = engine._malloc(4); + const lengthOut = engine._malloc(4); + try { + assert.equal(engine._web_engine_save_blend(handle, dataOut, lengthOut), 0); + const pointer = engine.HEAPU32[dataOut >>> 2]; + const length = engine.HEAPU32[lengthOut >>> 2]; + const bytes = engine.HEAPU8.slice(pointer, pointer + length); + engine._web_engine_free_buffer(pointer); + return bytes; + } + finally { + engine._free(dataOut); + engine._free(lengthOut); + } +} + +function weightsByVertex(snapshotValue) { + const mesh = snapshotValue.meshes.find((item) => item.id === `mesh:${golden.mesh}`); + assert.ok(mesh?.skinWeights, "weight golden mesh has no skinWeights"); + return golden.steps[0].vertices.map((expectedVertex) => { + const result = {}; + for (const [groupIndex, groupName] of mesh.skinWeights.boneNames.entries()) { + const offset = expectedVertex.index * 4; + const slot = mesh.skinWeights.indices.slice(offset, offset + 4).findIndex((value) => value === groupIndex); + result[groupName] = slot < 0 ? 0 : mesh.skinWeights.weights[offset + slot]; + } + return result; + }); +} + +function compareStep(actualSnapshot, expected, label) { + const actual = weightsByVertex(actualSnapshot); + assert.deepEqual(actual.map((weights) => Object.keys(weights).sort()), expected.vertices.map(() => expected.groups.slice().sort()), `${label} group schema`); + expected.vertices.forEach((vertex, index) => { + for (const group of expected.groups) { + const error = Math.abs((actual[index][group] ?? 0) - (vertex.weights[group] ?? 0)); + assert.ok(error <= golden.tolerance, `${label} vertex=${vertex.index} group=${group} error=${error}`); + } + }); +} + +const engine = await factory({ wasmBinary: wasmBinary.slice() }); +const handle = engine._web_engine_create(); +assert.ok(handle > 0); +try { + open(engine, handle, fixture); + call(engine, handle, "initial", { type: "setVertexWeights", objectId: `object:${golden.object}`, vertexGroup: "WebPaintGroup", indices: [0, 1], values: [0.75, 0.25] }); + compareStep(snapshot(engine, handle), golden.steps[0], "initial"); + call(engine, handle, "normalize", { type: "setVertexWeights", objectId: `object:${golden.object}`, vertexGroup: "WebPaintGroup", indices: [2], values: [0.5], normalize: true }); + compareStep(snapshot(engine, handle), golden.steps[1], "normalize"); + call(engine, handle, "limit-normalize", { type: "setVertexWeights", objectId: `object:${golden.object}`, vertexGroup: "WebPaintGroup", indices: [3], values: [0.4], limit: 2, normalize: true }); + compareStep(snapshot(engine, handle), golden.steps[2], "limit-normalize"); + call(engine, handle, "mirror", { type: "setVertexWeights", objectId: `object:${golden.object}`, vertexGroup: "WebPaintGroup", indices: [0], values: [0.9], mirror: true, mirrorAxis: 0, mirrorTolerance: 1e-4 }); + compareStep(snapshot(engine, handle), golden.steps[3], "mirror"); + const saved = save(engine, handle); + const reopened = engine._web_engine_create(); + try { + open(engine, reopened, saved); + compareStep(snapshot(engine, reopened), golden.steps[3], "save-reopen"); + } + finally { + engine._web_engine_destroy(reopened); + } +} +finally { + engine._web_engine_destroy(handle); +} + +process.stdout.write(`weight-paint-golden-ok fixture=${golden.fixture} steps=${golden.steps.map((step) => step.name).join(",")} normalize=passed limit=passed mirror=passed save-reopen=passed\n`); diff --git a/tools/web/generate-curve-toggle-golden.py b/tools/web/generate-curve-toggle-golden.py new file mode 100644 index 00000000..0cd8049a --- /dev/null +++ b/tools/web/generate-curve-toggle-golden.py @@ -0,0 +1,51 @@ +import hashlib +import json +import os +import sys +import tempfile + +import bpy + + +def main(): + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) != 2: + raise SystemExit("usage: blender -b fixture.blend --python generate-curve-toggle-golden.py -- fixture.blend output.json") + fixture = os.path.abspath(arguments[0]) + output = os.path.abspath(arguments[1]) + curve = bpy.data.curves.get("WebCurveData") + if curve is None or len(curve.splines) < 1: + raise RuntimeError("WebCurveData fixture is missing its first spline") + before = [spline.use_cyclic_u for spline in curve.splines] + spline_types = [spline.type for spline in curve.splines] + spline_point_counts = [len(spline.bezier_points) if spline.type == "BEZIER" else len(spline.points) for spline in curve.splines] + curve.splines[0].use_cyclic_u = not curve.splines[0].use_cyclic_u + after = [spline.use_cyclic_u for spline in curve.splines] + with tempfile.TemporaryDirectory(prefix="m9-05-curve-toggle-") as temporary: + saved = os.path.join(temporary, "curve-toggle.blend") + bpy.ops.wm.save_as_mainfile(filepath=saved, check_existing=False) + bpy.ops.wm.open_mainfile(filepath=saved) + reopened = [spline.use_cyclic_u for spline in bpy.data.curves["WebCurveData"].splines] + with open(fixture, "rb") as source: + fixture_sha256 = hashlib.sha256(source.read()).hexdigest() + payload = { + "schemaVersion": 1, + "operator": "TOGGLE_CYCLIC", + "fixture": "tests/files/web/nonmesh_scene.blend", + "fixtureSha256": fixture_sha256, + "blenderVersion": ".".join(str(value) for value in bpy.app.version), + "curveName": "WebCurveData", + "splineIndex": 0, + "splineTypes": spline_types, + "splinePointCounts": spline_point_counts, + "beforeCyclicU": before, + "afterCyclicU": after, + "reopenedCyclicU": reopened, + } + with open(output, "w", encoding="utf-8") as destination: + json.dump(payload, destination, indent=2, sort_keys=True) + destination.write("\n") + + +if __name__ == "__main__": + main() diff --git a/tools/web/generate-geometry-node-evaluator-golden.py b/tools/web/generate-geometry-node-evaluator-golden.py new file mode 100644 index 00000000..316b4969 --- /dev/null +++ b/tools/web/generate-geometry-node-evaluator-golden.py @@ -0,0 +1,369 @@ +import json +import hashlib +import math +import os +import sys + +import bpy + + +ALLOWLIST = [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeTransform", + "GeometryNodeSetPosition", + "GeometryNodeJoinGeometry", + "GeometryNodeSeparateGeometry", + "GeometryNodeRealizeInstances", + "GeometryNodeStoreNamedAttribute", + "FunctionNodeInputInt", + "FunctionNodeInputVector", + "FunctionNodeCompare", + "ShaderNodeValue", + "ShaderNodeMath", + "GeometryNodeObjectInfo", + "GeometryNodeCollectionInfo", + "GeometryNodeImageInfo", +] + + +def reset_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.frame_start = 1 + scene.frame_end = 24 + scene.frame_set(1) + return scene + + +def mesh_object(name, vertices, faces, location=(0.0, 0.0, 0.0), collection=None): + mesh = bpy.data.meshes.new(f"{name}Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + (collection or bpy.context.collection).objects.link(obj) + obj.location = location + return obj + + +def cube_object(name, location=(0.0, 0.0, 0.0), collection=None): + return mesh_object( + name, + [ + (-1, -1, -1), + (1, -1, -1), + (1, 1, -1), + (-1, 1, -1), + (-1, -1, 1), + (1, -1, 1), + (1, 1, 1), + (-1, 1, 1), + ], + [ + (0, 1, 2, 3), + (4, 7, 6, 5), + (0, 4, 5, 1), + (1, 5, 6, 2), + (2, 6, 7, 3), + (4, 0, 3, 7), + ], + location, + collection, + ) + + +def tetra_object(name, location=(0.0, 0.0, 0.0), collection=None): + return mesh_object( + name, + [(0, 0, 1.5), (-1, -1, 0), (1, -1, 0), (0, 1, 0)], + [(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)], + location, + collection, + ) + + +def geometry_group(name): + group = bpy.data.node_groups.new(name, "GeometryNodeTree") + group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") + group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + group_input = group.nodes.new("NodeGroupInput") + group_output = group.nodes.new("NodeGroupOutput") + group_input.name = "Group Input" + group_output.name = "Group Output" + return group, group_input, group_output + + +def add_case(name, x, configure): + obj = cube_object(name, (x, 0, 0)) + group, group_input, group_output = geometry_group(f"{name}Graph") + configure(group, group_input, group_output) + modifier = obj.modifiers.new(f"{name} Nodes", "NODES") + modifier.node_group = group + return obj, group + + +def link_geometry(group, source, target): + group.links.new(source.outputs["Geometry"], target.inputs["Geometry"]) + + +def build_fixture(blend_path): + scene = reset_scene() + cases = [] + + def passthrough(group, group_input, group_output): + group.links.new(group_input.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_Passthrough", 0, passthrough)) + + def transform_case(group, group_input, group_output): + node = group.nodes.new("GeometryNodeTransform") + node.name = "Transform" + node.inputs["Translation"].default_value = (0.25, -0.5, 1.0) + node.inputs["Rotation"].default_value = (0.0, 0.0, math.radians(15.0)) + node.inputs["Scale"].default_value = (1.25, 0.75, 1.5) + link_geometry(group, group_input, node) + group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_Transform", 4, transform_case)) + + def set_position_case(group, group_input, group_output): + node = group.nodes.new("GeometryNodeSetPosition") + node.name = "Set Position" + node.inputs["Offset"].default_value = (0.5, 0.25, -0.75) + link_geometry(group, group_input, node) + group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_SetPosition", 8, set_position_case)) + + def join_case(group, group_input, group_output): + node = group.nodes.new("GeometryNodeJoinGeometry") + node.name = "Join Geometry" + translated = group.nodes.new("GeometryNodeTransform") + translated.name = "Join Branch Transform" + translated.inputs["Translation"].default_value = (3.0, 0.0, 0.0) + group.links.new(group_input.outputs["Geometry"], node.inputs["Geometry"]) + group.links.new(group_input.outputs["Geometry"], translated.inputs["Geometry"]) + group.links.new(translated.outputs["Geometry"], node.inputs["Geometry"]) + group.links.new(node.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_Join", 12, join_case)) + + def separate_case(group, group_input, group_output): + separate = group.nodes.new("GeometryNodeSeparateGeometry") + separate.name = "Separate Geometry" + separate.domain = "POINT" + integer_a = group.nodes.new("FunctionNodeInputInt") + integer_a.name = "Integer A" + integer_a.integer = 2 + integer_b = group.nodes.new("FunctionNodeInputInt") + integer_b.name = "Integer B" + integer_b.integer = 1 + compare = group.nodes.new("FunctionNodeCompare") + compare.name = "Compare" + compare.data_type = "INT" + compare.operation = "GREATER_THAN" + link_geometry(group, group_input, separate) + group.links.new(integer_a.outputs["Integer"], compare.inputs["A"]) + group.links.new(integer_b.outputs["Integer"], compare.inputs["B"]) + group.links.new(compare.outputs["Result"], separate.inputs["Selection"]) + group.links.new(separate.outputs["Selection"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_SeparateIntCompare", 16, separate_case)) + + external_collection = bpy.data.collections.new("M10GN_ExternalCollection") + scene.collection.children.link(external_collection) + tetra_object("M10GN_CollectionTetra", (-1.5, 0, 0), external_collection) + tetra_object("M10GN_CollectionTetraOffset", (1.5, 0, 0), external_collection) + + def collection_case(group, _group_input, group_output): + collection_info = group.nodes.new("GeometryNodeCollectionInfo") + collection_info.name = "Collection Info" + collection_info.inputs["Collection"].default_value = external_collection + collection_info.inputs["Separate Children"].default_value = True + realize = group.nodes.new("GeometryNodeRealizeInstances") + realize.name = "Realize Instances" + group.links.new(collection_info.outputs["Instances"], realize.inputs["Geometry"]) + group.links.new(realize.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_CollectionRealize", 20, collection_case)) + + def store_attribute_case(group, group_input, group_output): + store = group.nodes.new("GeometryNodeStoreNamedAttribute") + store.name = "Store Named Attribute" + store.data_type = "FLOAT" + store.domain = "POINT" + store.inputs["Name"].default_value = "m10_value" + store.inputs["Value"].default_value = 0.375 + link_geometry(group, group_input, store) + group.links.new(store.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_StoreAttribute", 24, store_attribute_case)) + + def vector_case(group, group_input, group_output): + vector = group.nodes.new("FunctionNodeInputVector") + vector.name = "Vector" + vector.vector = (0.125, 0.5, 1.25) + set_position = group.nodes.new("GeometryNodeSetPosition") + set_position.name = "Set Position" + link_geometry(group, group_input, set_position) + group.links.new(vector.outputs["Vector"], set_position.inputs["Offset"]) + group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_InputVector", 28, vector_case)) + + def value_math_case(group, group_input, group_output): + first = group.nodes.new("ShaderNodeValue") + first.name = "Value A" + first.outputs["Value"].default_value = 0.25 + second = group.nodes.new("ShaderNodeValue") + second.name = "Value B" + second.outputs["Value"].default_value = 0.5 + math_node = group.nodes.new("ShaderNodeMath") + math_node.name = "Math Add" + math_node.operation = "ADD" + compare = group.nodes.new("FunctionNodeCompare") + compare.name = "Compare" + compare.data_type = "FLOAT" + compare.operation = "GREATER_THAN" + compare.inputs["B"].default_value = 0.5 + set_position = group.nodes.new("GeometryNodeSetPosition") + set_position.name = "Set Position" + set_position.inputs["Offset"].default_value = (0.0, 0.0, 0.625) + link_geometry(group, group_input, set_position) + group.links.new(first.outputs["Value"], math_node.inputs[0]) + group.links.new(second.outputs["Value"], math_node.inputs[1]) + group.links.new(math_node.outputs["Value"], compare.inputs["A"]) + group.links.new(compare.outputs["Result"], set_position.inputs["Selection"]) + group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_ValueMath", 32, value_math_case)) + + object_target = tetra_object("M10GN_ObjectInfoTarget", (0.5, 0.25, 1.5)) + + def object_info_case(group, group_input, group_output): + object_info = group.nodes.new("GeometryNodeObjectInfo") + object_info.name = "Object Info" + object_info.inputs["Object"].default_value = object_target + set_position = group.nodes.new("GeometryNodeSetPosition") + set_position.name = "Set Position" + link_geometry(group, group_input, set_position) + group.links.new(object_info.outputs["Location"], set_position.inputs["Offset"]) + group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_ObjectInfo", 36, object_info_case)) + + image = bpy.data.images.new("M10GN_Image", width=4, height=2, alpha=True) + + def image_info_case(group, group_input, group_output): + image_info = group.nodes.new("GeometryNodeImageInfo") + image_info.name = "Image Info" + image_info.inputs["Image"].default_value = image + compare = group.nodes.new("FunctionNodeCompare") + compare.name = "Compare" + compare.data_type = "INT" + compare.operation = "GREATER_THAN" + compare.inputs["B"].default_value = 3 + set_position = group.nodes.new("GeometryNodeSetPosition") + set_position.name = "Set Position" + set_position.inputs["Offset"].default_value = (0.0, -0.75, 0.8) + link_geometry(group, group_input, set_position) + group.links.new(image_info.outputs["Width"], compare.inputs["A"]) + group.links.new(compare.outputs["Result"], set_position.inputs["Selection"]) + group.links.new(set_position.outputs["Geometry"], group_output.inputs["Geometry"]) + + cases.append(add_case("M10GN_ImageInfo", 40, image_info_case)) + + bpy.ops.wm.save_as_mainfile(filepath=blend_path, compress=True) + return [obj.name for obj, _group in cases] + + +def evaluated_mesh_record(obj, depsgraph): + evaluated = obj.evaluated_get(depsgraph) + mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) + try: + mesh.calc_loop_triangles() + positions = [component for vertex in mesh.vertices for component in vertex.co] + record = { + "object": obj.name, + "vertexCount": len(mesh.vertices), + "triangleCount": len(mesh.loop_triangles), + "positions": positions, + "indices": [index for triangle in mesh.loop_triangles for index in triangle.vertices], + "bounds": { + "min": [min(positions[axis::3]) for axis in range(3)] if positions else [0, 0, 0], + "max": [max(positions[axis::3]) for axis in range(3)] if positions else [0, 0, 0], + }, + "attributes": {}, + } + attribute = mesh.attributes.get("m10_value") + if attribute is not None: + record["attributes"]["m10_value"] = { + "domain": attribute.domain, + "dataType": attribute.data_type, + "values": [item.value for item in attribute.data], + } + return record + finally: + evaluated.to_mesh_clear() + + +def write_golden(blend_path, golden_path, case_names): + depsgraph = bpy.context.evaluated_depsgraph_get() + node_coverage = {} + cases = [] + for case_name in case_names: + obj = bpy.data.objects[case_name] + group = obj.modifiers[0].node_group + node_types = [node.bl_idname for node in group.nodes] + cases.append({ + "name": case_name, + "graph": group.name, + "nodeTypes": node_types, + "mesh": evaluated_mesh_record(obj, depsgraph), + }) + for node_type in node_types: + node_coverage.setdefault(node_type, []).append(case_name) + + missing = sorted(set(ALLOWLIST) - set(node_coverage)) + unexpected = sorted(set(node_coverage) - set(ALLOWLIST)) + if missing or unexpected: + raise RuntimeError(f"allowlist coverage mismatch missing={missing} unexpected={unexpected}") + + output = { + "schemaVersion": 1, + "blenderVersion": bpy.app.version_string, + "fixture": os.path.relpath( + blend_path, + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(golden_path)))), + ), + "fixtureSha256": hashlib.sha256(open(blend_path, "rb").read()).hexdigest(), + "allowlist": ALLOWLIST, + "nodeCoverage": node_coverage, + "tolerance": { + "maxPositionError": 1e-5, + "rmsPositionError": 1e-6, + "maxAttributeError": 1e-6, + "boundsError": 1e-5, + }, + "cases": cases, + } + os.makedirs(os.path.dirname(golden_path), exist_ok=True) + with open(golden_path, "w", encoding="utf-8") as handle: + json.dump(output, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def main(): + if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 2: + raise SystemExit( + "usage: blender -b --python generate-geometry-node-evaluator-golden.py -- fixture.blend golden.json" + ) + blend_path, golden_path = [os.path.abspath(path) for path in sys.argv[sys.argv.index("--") + 1:]] + os.makedirs(os.path.dirname(blend_path), exist_ok=True) + case_names = build_fixture(blend_path) + write_golden(blend_path, golden_path, case_names) + print(f"geometry-node-evaluator-generated fixture={blend_path} golden={golden_path} cases={len(case_names)}") + + +if __name__ == "__main__": + main() diff --git a/tools/web/generate-grease-pencil-reorder-golden.py b/tools/web/generate-grease-pencil-reorder-golden.py new file mode 100644 index 00000000..ed029d41 --- /dev/null +++ b/tools/web/generate-grease-pencil-reorder-golden.py @@ -0,0 +1,65 @@ +import hashlib +import json +import os +import sys +import tempfile + +import bpy + + +def layer_state(grease_pencil): + return { + "layerOrder": [layer.name for layer in grease_pencil.layers], + "framesByLayer": { + layer.name: sorted(frame.frame_number for frame in layer.frames) + for layer in grease_pencil.layers + }, + } + + +def main(): + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) != 2: + raise SystemExit("usage: blender -b fixture.blend --python generate-grease-pencil-reorder-golden.py -- fixture.blend output.json") + fixture = os.path.abspath(arguments[0]) + output = os.path.abspath(arguments[1]) + grease_pencil = bpy.data.grease_pencils.get("GreasePencilData") + if grease_pencil is None or len(grease_pencil.layers) != 1: + raise RuntimeError("GreasePencilData fixture must contain exactly one source layer") + + source = layer_state(grease_pencil) + layer = grease_pencil.layers.new("Web Drafts", set_active=True) + layer.frames.new(1) + before = layer_state(grease_pencil) + grease_pencil.layers.move(layer, "DOWN") + moved = layer.frames.move(1, 12) + if moved is None: + raise RuntimeError("Blender refused the Grease Pencil frame move") + after = layer_state(grease_pencil) + + with tempfile.TemporaryDirectory(prefix="m9-08-grease-pencil-reorder-") as temporary: + saved = os.path.join(temporary, "grease-pencil-reorder.blend") + bpy.ops.wm.save_as_mainfile(filepath=saved, check_existing=False) + bpy.ops.wm.open_mainfile(filepath=saved) + reopened = layer_state(bpy.data.grease_pencils["GreasePencilData"]) + + with open(fixture, "rb") as source_file: + fixture_sha256 = hashlib.sha256(source_file.read()).hexdigest() + payload = { + "schemaVersion": 1, + "fixture": "tests/files/web/modifier_grease_pencil_scene.blend", + "fixtureSha256": fixture_sha256, + "blenderVersion": ".".join(str(value) for value in bpy.app.version), + "greasePencilName": "GreasePencilData", + "source": source, + "beforeReorder": before, + "afterReorder": after, + "reopened": reopened, + } + with open(output, "w", encoding="utf-8") as destination: + json.dump(payload, destination, indent=2, sort_keys=True) + destination.write("\n") + + +if __name__ == "__main__": + main() diff --git a/tools/web/generate-m11-compositor-allowlist.py b/tools/web/generate-m11-compositor-allowlist.py new file mode 100644 index 00000000..904c7742 --- /dev/null +++ b/tools/web/generate-m11-compositor-allowlist.py @@ -0,0 +1,54 @@ +import pathlib +import sys + +import bpy + + +SCENES = ( + ("M11 Constant", (0.125, 0.25, 0.5, 0.75), ()), + ("M11 Exposure", (0.125, 0.25, 0.5, 0.75), (("EXPOSURE", 1.0),)), + ("M11 Invert", (0.125, 0.25, 0.5, 0.75), (("INVERT", None),)), + ("M11 Chain", (0.125, 0.25, 0.5, 0.75), (("EXPOSURE", 1.0), ("INVERT", None))), +) + + +def add_graph(scene, color_value, operations): + tree = bpy.data.node_groups.new(f"{scene.name} Tree", "CompositorNodeTree") + scene.compositing_node_group = tree + tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor") + color = tree.nodes.new("CompositorNodeRGB") + color.name = f"{scene.name} Constant" + color.outputs["Color"].default_value = color_value + previous = color.outputs["Color"] + for index, (operation, parameter) in enumerate(operations): + if operation == "EXPOSURE": + node = tree.nodes.new("CompositorNodeExposure") + node.inputs["Exposure"].default_value = parameter + else: + node = tree.nodes.new("CompositorNodeInvert") + node.name = f"{scene.name} {operation.title()} {index}" + tree.links.new(previous, node.inputs["Image" if operation == "EXPOSURE" else "Color"]) + previous = node.outputs["Image" if operation == "EXPOSURE" else "Color"] + output = tree.nodes.new("NodeGroupOutput") + output.name = f"{scene.name} Composite" + tree.links.new(previous, output.inputs["Image"]) + + +def main(output_path): + bpy.ops.wm.read_factory_settings(use_empty=True) + first = bpy.context.scene + for index, (name, color, operations) in enumerate(SCENES): + scene = first if index == 0 else bpy.data.scenes.new(name) + scene.name = name + add_graph(scene, color, operations) + output = pathlib.Path(output_path).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=True) + print(f"m11-compositor-allowlist fixture={output} scenes={len(SCENES)}") + + +if __name__ == "__main__": + arguments = sys.argv[sys.argv.index("--") + 1:] + if len(arguments) != 1: + raise SystemExit("usage: blender -b --python generate-m11-compositor-allowlist.py -- OUTPUT") + main(arguments[0]) diff --git a/tools/web/generate-m11-render-reference.py b/tools/web/generate-m11-render-reference.py new file mode 100644 index 00000000..e0046295 --- /dev/null +++ b/tools/web/generate-m11-render-reference.py @@ -0,0 +1,99 @@ +import math +import pathlib +import sys + +import bpy +from mathutils import Vector + + +def configure_scene() -> bpy.types.Scene: + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.name = "M11 Render Reference" + scene.render.engine = "BLENDER_EEVEE" + scene.render.resolution_x = 256 + scene.render.resolution_y = 256 + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGBA" + scene.render.image_settings.color_depth = "8" + scene.render.film_transparent = False + scene.render.use_file_extension = True + scene.render.dither_intensity = 0 + scene.eevee.taa_render_samples = 1 + scene.view_settings.look = "None" + scene.view_settings.exposure = 0 + scene.view_settings.gamma = 1 + + world = bpy.data.worlds.new("M11 Reference World") + world.use_nodes = True + background = world.node_tree.nodes.get("Background") + background.inputs["Color"].default_value = (0.0508760884, 0.0508760884, 0.0508760884, 1) + background.inputs["Strength"].default_value = 1 + scene.world = world + + bpy.ops.mesh.primitive_cube_add(size=3, location=(0, 0, 0)) + cube = bpy.context.object + cube.name = "M11 Reference Cube" + material = bpy.data.materials.new("M11 Reference Black") + material.use_nodes = True + principled = material.node_tree.nodes.get("Principled BSDF") + principled.inputs["Base Color"].default_value = (0, 0, 0, 1) + principled.inputs["Metallic"].default_value = 0 + principled.inputs["Roughness"].default_value = 1 + principled.inputs["Specular IOR Level"].default_value = 0 + cube.data.materials.append(material) + + bpy.ops.object.camera_add() + camera = bpy.context.object + camera.name = "M11 Reference Camera" + camera.data.name = "M11 Reference Camera" + yaw = -math.pi / 4 + pitch = 0.55 + distance = 7 + three_position = Vector(( + distance * math.cos(pitch) * math.cos(yaw), + distance * math.cos(pitch) * math.sin(yaw), + distance * math.sin(pitch), + )) + camera.location = (three_position.x, -three_position.z, three_position.y) + camera.rotation_euler = (-camera.location).to_track_quat("-Z", "Y").to_euler() + camera.data.type = "PERSP" + camera.data.lens = 50 + camera.data.sensor_width = 36 + camera.data.sensor_fit = "HORIZONTAL" + camera.data.clip_start = 0.1 + camera.data.clip_end = 1000 + scene.camera = camera + return scene + + +def main() -> None: + if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) not in (2, 3): + raise SystemExit("usage: blender --background --factory-startup --python generate-m11-render-reference.py -- FIXTURE OUTPUT [EXPECTED]") + arguments = sys.argv[sys.argv.index("--") + 1:] + fixture_arg, output_arg = arguments[:2] + fixture = pathlib.Path(fixture_arg).resolve() + output = pathlib.Path(output_arg).resolve() + fixture.parent.mkdir(parents=True, exist_ok=True) + output.parent.mkdir(parents=True, exist_ok=True) + scene = configure_scene() + bpy.ops.wm.save_as_mainfile(filepath=str(fixture), compress=True) + scene.render.filepath = str(output) + bpy.ops.render.render(write_still=True) + if len(arguments) == 3: + expected = pathlib.Path(arguments[2]).resolve() + actual_image = bpy.data.images.load(str(output), check_existing=False) + expected_image = bpy.data.images.load(str(expected), check_existing=False) + if actual_image.size[:] != expected_image.size[:]: + raise RuntimeError(f"reference dimensions differ: {actual_image.size[:]} != {expected_image.size[:]}") + actual_pixels = actual_image.pixels[:] + expected_pixels = expected_image.pixels[:] + maximum = max(abs(actual - reference) for actual, reference in zip(actual_pixels, expected_pixels)) + if maximum != 0: + raise RuntimeError(f"reference decoded pixels differ: max={maximum}") + print(f"m11-render-reference-pixels width={actual_image.size[0]} height={actual_image.size[1]} max=0") + print(f"m11-render-reference fixture={fixture} output={output}") + + +main() diff --git a/tools/web/generate-nla-evaluation-fixture.py b/tools/web/generate-nla-evaluation-fixture.py new file mode 100644 index 00000000..24c75f15 --- /dev/null +++ b/tools/web/generate-nla-evaluation-fixture.py @@ -0,0 +1,94 @@ +import sys + +import bpy + + +def mesh_object(name: str): + mesh = bpy.data.meshes.new(f"{name}Mesh") + mesh.from_pydata( + [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (0.0, 1.0, 0.0)], + [], + [(0, 1, 2)], + ) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + return obj + + +def location_action(name: str, axis: int): + action = bpy.data.actions.new(name) + slot = action.slots.new("OBJECT", "M10NlaTimeMapping") + layer = action.layers.new("M10 NLA Keys") + keyframe_strip = layer.strips.new(type="KEYFRAME") + channelbag = keyframe_strip.channelbags.new(slot) + curve = channelbag.fcurves.new(data_path="location", index=axis) + curve.keyframe_points.add(2) + curve.keyframe_points[0].co = (1.0, 0.0) + curve.keyframe_points[1].co = (11.0, 10.0) + for keyframe in curve.keyframe_points: + keyframe.interpolation = "LINEAR" + action.frame_start = 1.0 + action.frame_end = 11.0 + return action, slot + + +def configure_strip(strip, slot, *, frame_start, frame_end, scale, repeat, reverse): + strip.action_slot = slot + strip.action_frame_start = 1.0 + strip.action_frame_end = 11.0 + strip.frame_start = frame_start + strip.frame_end = frame_end + strip.scale = scale + strip.repeat = repeat + strip.blend_type = "REPLACE" + strip.extrapolation = "NOTHING" + strip.use_reverse = reverse + strip.influence = 1.0 + strip.blend_in = 0.0 + strip.blend_out = 0.0 + + +def main(output_path: str) -> None: + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.frame_start = 1 + scene.frame_end = 70 + + obj = mesh_object("M10_NLA_TimeMapping") + animation_data = obj.animation_data_create() + track = animation_data.nla_tracks.new() + track.name = "M10 Time Mapping" + + scaled_action, scaled_slot = location_action("M10_NLA_Scaled_X", 0) + scaled_strip = track.strips.new("M10 Scaled Clip", 20, scaled_action) + configure_strip( + scaled_strip, + scaled_slot, + frame_start=20.0, + frame_end=40.0, + scale=2.0, + repeat=1.0, + reverse=False, + ) + + reverse_repeat_action, reverse_repeat_slot = location_action("M10_NLA_ReverseRepeat_Y", 1) + reverse_repeat_strip = track.strips.new("M10 Reverse Repeat Clip", 45, reverse_repeat_action) + configure_strip( + reverse_repeat_strip, + reverse_repeat_slot, + frame_start=45.0, + frame_end=65.0, + scale=1.0, + repeat=2.0, + reverse=True, + ) + + scene.frame_set(1) + bpy.ops.wm.save_as_mainfile(filepath=output_path, compress=True) + + +if __name__ == "__main__": + if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 1: + raise SystemExit("usage: blender -b --python generate-nla-evaluation-fixture.py -- output.blend") + main(sys.argv[sys.argv.index("--") + 1]) diff --git a/tools/web/generate-nla-evaluation-golden.py b/tools/web/generate-nla-evaluation-golden.py new file mode 100644 index 00000000..06084a5f --- /dev/null +++ b/tools/web/generate-nla-evaluation-golden.py @@ -0,0 +1,77 @@ +import hashlib +import json +import pathlib +import sys + +import bpy + + +def strip_summary(strip): + return { + "id": strip.name, + "action": strip.action.name if strip.action is not None else None, + "frameStart": strip.frame_start, + "frameEnd": strip.frame_end, + "actionFrameStart": strip.action_frame_start, + "actionFrameEnd": strip.action_frame_end, + "scale": strip.scale, + "repeat": strip.repeat, + "blendIn": strip.blend_in, + "blendOut": strip.blend_out, + "influence": strip.influence, + "blendMode": strip.blend_type, + "extrapolation": strip.extrapolation, + "muted": strip.mute, + "reverse": strip.use_reverse, + } + + +def main(blend_path: str, output_path: str) -> None: + blend = pathlib.Path(blend_path).resolve() + bpy.ops.wm.open_mainfile(filepath=str(blend), load_ui=False) + + scene = bpy.context.scene + object_name = "M10_NLA_TimeMapping" + obj = bpy.data.objects.get(object_name) + if obj is None or obj.animation_data is None: + raise RuntimeError("M10 NLA fixture object or AnimData is missing") + + frames = [1, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70] + samples = [] + for frame in frames: + scene.frame_set(frame) + depsgraph = bpy.context.evaluated_depsgraph_get() + evaluated = obj.evaluated_get(depsgraph) + samples.append({ + "frame": frame, + "worldMatrix": [value for row in evaluated.matrix_world for value in row], + }) + + tracks = [] + for track in obj.animation_data.nla_tracks: + tracks.append({ + "name": track.name, + "muted": track.mute, + "solo": track.is_solo, + "strips": [strip_summary(strip) for strip in track.strips], + }) + + result = { + "schemaVersion": 1, + "fixture": f"tests/files/web/{blend.name}", + "fixtureSha256": hashlib.sha256(blend.read_bytes()).hexdigest(), + "blenderVersion": bpy.app.version_string, + "object": object_name, + "mesh": obj.data.name, + "tracks": tracks, + "frames": samples, + "tolerance": {"maxMatrixError": 1e-5}, + } + pathlib.Path(output_path).write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 2: + raise SystemExit("usage: blender -b --python generate-nla-evaluation-golden.py -- input.blend output.json") + arguments = sys.argv[sys.argv.index("--") + 1:] + main(arguments[0], arguments[1]) diff --git a/tools/web/generate-sequencer-codec-fixture.sh b/tools/web/generate-sequencer-codec-fixture.sh new file mode 100755 index 00000000..c912238a --- /dev/null +++ b/tools/web/generate-sequencer-codec-fixture.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 OUTPUT.mp4" >&2 + exit 2 +fi + +output=$1 +ffmpeg_bin=${FFMPEG_BIN:-ffmpeg} +mkdir -p "$(dirname "$output")" + +"$ffmpeg_bin" -v error \ + -f lavfi -i "color=c=red:s=16x16:r=2:d=1" \ + -an -c:v libx264 -profile:v baseline -level 3.0 -pix_fmt yuv420p \ + -movflags +faststart -map_metadata -1 -fflags +bitexact -flags:v +bitexact \ + -metadata creation_time=1970-01-01T00:00:00Z -y "$output" + +printf 'sequencer-codec-fixture path=%s bytes=%s\n' "$output" "$(wc -c < "$output")" diff --git a/tools/web/generate-weight-paint-golden.py b/tools/web/generate-weight-paint-golden.py new file mode 100644 index 00000000..58a6e885 --- /dev/null +++ b/tools/web/generate-weight-paint-golden.py @@ -0,0 +1,110 @@ +import json +import pathlib +import sys + +import bpy + + +def group_weight(vertex, group_index): + return next((item.weight for item in vertex.groups if item.group == group_index), 0.0) + + +def set_weight(obj, vertex_index, group_index, value): + group = obj.vertex_groups[group_index] + if value == 0.0: + try: + group.remove([vertex_index]) + except RuntimeError: + pass + else: + group.add([vertex_index], value, "REPLACE") + + +def normalize_vertex(obj, vertex_index): + vertex = obj.data.vertices[vertex_index] + total = sum(item.weight for item in vertex.groups) + if total > 0.0: + for item in list(vertex.groups): + set_weight(obj, vertex_index, item.group, item.weight / total) + + +def limit_vertex(obj, vertex_index, limit): + vertex = obj.data.vertices[vertex_index] + while len(vertex.groups) > limit: + remove = min(vertex.groups, key=lambda item: (item.weight, -item.group)) + obj.vertex_groups[remove.group].remove([vertex_index]) + + +def mirror_map(obj, axis, tolerance): + result = {} + for source in obj.data.vertices: + reflected = source.co.copy() + reflected[axis] = -reflected[axis] + candidate = min(obj.data.vertices, key=lambda item: ((item.co - reflected).length, item.index)) + if (candidate.co - reflected).length > tolerance: + raise RuntimeError("mesh is not mirror symmetric") + result[source.index] = candidate.index + if any(result[result[index]] != index for index in result): + raise RuntimeError("mesh mirror map is not reciprocal") + return result + + +def snapshot(obj): + groups = [group.name for group in obj.vertex_groups] + return { + "groups": groups, + "vertices": [ + {"index": vertex.index, "weights": { + groups[item.group]: round(item.weight, 9) for item in vertex.groups + }} + for vertex in obj.data.vertices + ], + } + + +def main(blend_path, output_path): + bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(blend_path).resolve()), load_ui=False) + obj = bpy.data.objects.get("RiggedShapeObject") + if obj is None or obj.type != "MESH": + raise RuntimeError("weight paint fixture object is missing") + group = obj.vertex_groups.get("WebPaintGroup") or obj.vertex_groups.new(name="WebPaintGroup") + + set_weight(obj, 0, group.index, 0.75) + set_weight(obj, 1, group.index, 0.25) + steps = [{"name": "initial", **snapshot(obj)}] + + set_weight(obj, 2, group.index, 0.5) + normalize_vertex(obj, 2) + steps.append({"name": "normalize", **snapshot(obj)}) + + set_weight(obj, 3, group.index, 0.4) + limit_vertex(obj, 3, 2) + normalize_vertex(obj, 3) + steps.append({"name": "limit-normalize", **snapshot(obj)}) + + mapping = mirror_map(obj, 0, 1e-4) + for source in (0, mapping[0]): + set_weight(obj, source, group.index, 0.9) + steps.append({"name": "mirror", **snapshot(obj)}) + + result = { + "schemaVersion": 1, + "fixture": pathlib.Path(blend_path).name, + "blenderVersion": bpy.app.version_string, + "object": obj.name, + "mesh": obj.data.name, + "operations": [ + {"name": "normalize", "vertex": 2}, + {"name": "limit-normalize", "vertex": 3, "limit": 2}, + {"name": "mirror", "vertices": [0, 1], "axis": 0, "tolerance": 1e-4}, + ], + "steps": steps, + "tolerance": 1e-6, + } + pathlib.Path(output_path).write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + if "--" not in sys.argv or len(sys.argv) <= sys.argv.index("--") + 2: + raise SystemExit("usage: blender -b --python generate-weight-paint-golden.py -- input.blend output.json") + main(*sys.argv[sys.argv.index("--") + 1:sys.argv.index("--") + 3]) diff --git a/tools/web/render-server-job.py b/tools/web/render-server-job.py new file mode 100644 index 00000000..16844383 --- /dev/null +++ b/tools/web/render-server-job.py @@ -0,0 +1,45 @@ +import json +import pathlib +import sys + +import bpy + + +def main() -> None: + if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 3: + raise SystemExit("usage: blender --background --python render-server-job.py -- SOURCE OUTPUT SETTINGS_JSON") + source_arg, output_arg, settings_arg = sys.argv[sys.argv.index("--") + 1:] + source = pathlib.Path(source_arg).resolve() + output = pathlib.Path(output_arg).resolve() + settings_path = pathlib.Path(settings_arg).resolve() + if not source.is_file() or source.stat().st_size < 1: + raise RuntimeError("server render source is missing") + bpy.ops.wm.open_mainfile(filepath=str(source), load_ui=False) + scene = bpy.context.scene + if scene is None: + raise RuntimeError("server render scene is missing") + settings = json.loads(settings_path.read_text(encoding="utf-8")) + engine = settings["renderEngine"] + scene.render.engine = "BLENDER_EEVEE" if engine == "BLENDER_EEVEE_NEXT" else engine + frame = int(settings["frameStart"]) + if frame != int(settings["frameEnd"]): + raise RuntimeError("server render schema 1 requires a still frame") + scene.render.resolution_x = int(settings["resolutionX"]) + scene.render.resolution_y = int(settings["resolutionY"]) + scene.render.resolution_percentage = int(settings["resolutionPercentage"]) + if scene.render.engine == "BLENDER_CYCLES": + scene.cycles.samples = int(settings["samples"]) + elif scene.render.engine == "BLENDER_EEVEE": + scene.eevee.taa_render_samples = int(settings["samples"]) + scene.render.image_settings.file_format = "PNG" if settings["outputMime"] == "image/png" else "OPEN_EXR" + scene.render.film_transparent = bool(settings["transparent"]) + scene.frame_set(frame) + scene.render.filepath = str(output) + scene.render.use_file_extension = True + bpy.ops.render.render(write_still=True) + if not output.is_file() or output.stat().st_size < 1: + raise RuntimeError("server render produced no output") + print(f"server-render-ok frame={frame} output={output} bytes={output.stat().st_size}") + + +main() diff --git a/web/app/public/engine-manifest.json b/web/app/public/engine-manifest.json index 67c8840d..3bc27f43 100644 --- a/web/app/public/engine-manifest.json +++ b/web/app/public/engine-manifest.json @@ -16,12 +16,12 @@ "js": { "fileName": "web_engine.js", "url": "/vendor/blender/single/web_engine.js", - "sha256": "eedb8cedeb2190fbece91d58cd5dd568356caf42191517aff2085911177157ca" + "sha256": "25aa51361aa8c95479873240c776d6213c1092dd480ee2a0036d2cbbd561dcad" }, "wasm": { "fileName": "web_engine.wasm", "url": "/vendor/blender/single/web_engine.wasm", - "sha256": "f079e2221b501eee7f0b8e3290c22f37210ba44ee7a392c7fa817fde16b9ee5e" + "sha256": "7f3a50f1d41b2ac0e6366e84caaabefc3cdfc47c055d5ea459abec654da6fcc0" } } }, @@ -36,17 +36,17 @@ "js": { "fileName": "web_engine.js", "url": "/vendor/blender/pthread/web_engine.js", - "sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837" + "sha256": "a64288eeb74fb0696d38d785348f0ba37e7ae716b5a341bf181f178f78e1324d" }, "wasm": { "fileName": "web_engine.wasm", "url": "/vendor/blender/pthread/web_engine.wasm", - "sha256": "a6bed7b410b15f0d5aca229d1e926bff861e3ff16f0d3d6abc1bc73bcaf5fc0b" + "sha256": "f2a26a9221b5d4d5e710463a89e2d93faf9b2d8a9076a455c5ce1a0e52d88b5f" }, "pthreadWorker": { "fileName": "web_engine.js", "url": "/vendor/blender/pthread/web_engine.js", - "sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837" + "sha256": "a64288eeb74fb0696d38d785348f0ba37e7ae716b5a341bf181f178f78e1324d" } } } diff --git a/web/app/public/vendor/blender/pthread/web_engine.js b/web/app/public/vendor/blender/pthread/web_engine.js index 17a821e0..fd7cbd4e 100644 --- a/web/app/public/vendor/blender/pthread/web_engine.js +++ b/web/app/public/vendor/blender/pthread/web_engine.js @@ -6,7 +6,7 @@ var Module = (() => { function(moduleArg = {}) { var moduleRtn; -function GROWABLE_HEAP_I8(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAP8}function GROWABLE_HEAP_U8(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPU8}function GROWABLE_HEAP_I32(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAP32}function GROWABLE_HEAP_U32(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPU32}function GROWABLE_HEAP_F64(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPF64}var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var ENVIRONMENT_IS_PTHREAD=ENVIRONMENT_IS_WORKER&&self.name?.startsWith("em-pthread");var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var wasmModule;var ABORT=false;var EXITSTATUS;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}if(ENVIRONMENT_IS_PTHREAD){var wasmPromiseResolve;var wasmPromiseReject;var initializedJS=false;function threadPrintErr(...args){var text=args.join(" ");console.error(text)}if(!Module["printErr"])err=threadPrintErr;function threadAlert(...args){var text=args.join(" ");postMessage({cmd:"alert",text,threadId:_pthread_self()})}self.alert=threadAlert;Module["instantiateWasm"]=(info,receiveInstance)=>new Promise((resolve,reject)=>{wasmPromiseResolve=module=>{var instance=new WebAssembly.Instance(module,getWasmImports());receiveInstance(instance);resolve()};wasmPromiseReject=reject});self.onunhandledrejection=e=>{throw e.reason||e};function handleMessage(e){try{var msgData=e["data"];var cmd=msgData.cmd;if(cmd==="load"){let messageQueue=[];self.onmessage=e=>messageQueue.push(e);self.startWorker=instance=>{postMessage({cmd:"loaded"});for(let msg of messageQueue){handleMessage(msg)}self.onmessage=handleMessage};for(const handler of msgData.handlers){if(!Module[handler]||Module[handler].proxy){Module[handler]=(...args)=>{postMessage({cmd:"callHandler",handler,args})};if(handler=="print")out=Module[handler];if(handler=="printErr")err=Module[handler]}}wasmMemory=msgData.wasmMemory;updateMemoryViews();wasmPromiseResolve(msgData.wasmModule)}else if(cmd==="run"){establishStackSpace(msgData.pthread_ptr);__emscripten_thread_init(msgData.pthread_ptr,0,0,1,0,0);PThread.receiveObjectTransfer(msgData);PThread.threadInitTLS();__emscripten_thread_mailbox_await(msgData.pthread_ptr);if(!initializedJS){initializedJS=true}try{invokeEntryPoint(msgData.start_routine,msgData.arg)}catch(ex){if(ex!="unwind"){throw ex}}}else if(msgData.target==="setimmediate"){}else if(cmd==="checkMailbox"){if(initializedJS){checkMailbox()}}else if(cmd){err(`worker: received unknown command ${cmd}`);err(msgData)}}catch(ex){__emscripten_thread_crashed();throw ex}}self.onmessage=handleMessage}if(!ENVIRONMENT_IS_PTHREAD){if(Module["wasmMemory"]){wasmMemory=Module["wasmMemory"]}else{var INITIAL_MEMORY=Module["INITIAL_MEMORY"]||16777216;wasmMemory=new WebAssembly.Memory({initial:INITIAL_MEMORY/65536,maximum:32768,shared:true})}updateMemoryViews()}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(ENVIRONMENT_IS_PTHREAD)return;callRuntimeCallbacks(__ATINIT__)}function postRun(){if(ENVIRONMENT_IS_PTHREAD)return;var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){assignWasmImports();return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;registerTLSInit(wasmExports["na"]);wasmTable=wasmExports["Z"];addOnInit(wasmExports["S"]);wasmModule=module;removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"],result["module"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var terminateWorker=worker=>{worker.terminate();worker.onmessage=e=>{}};var cleanupThread=pthread_ptr=>{var worker=PThread.pthreads[pthread_ptr];PThread.returnWorkerToPool(worker)};var spawnThread=threadParams=>{var worker=PThread.getNewWorker();if(!worker){return 6}PThread.runningWorkers.push(worker);PThread.pthreads[threadParams.pthread_ptr]=worker;worker.pthread_ptr=threadParams.pthread_ptr;var msg={cmd:"run",start_routine:threadParams.startRoutine,arg:threadParams.arg,pthread_ptr:threadParams.pthread_ptr};worker.postMessage(msg,threadParams.transferList);return 0};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var stackSave=()=>_emscripten_stack_get_current();var stackRestore=val=>__emscripten_stack_restore(val);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var proxyToMainThread=(funcIndex,emAsmAddr,sync,...callArgs)=>{var serializedNumCallArgs=callArgs.length;var sp=stackSave();var args=stackAlloc(serializedNumCallArgs*8);var b=args>>3;for(var i=0;i{if(e instanceof ExitStatus||e=="unwind"){return EXITSTATUS}quit_(1,e)};function exitOnMainThread(returnCode){if(ENVIRONMENT_IS_PTHREAD)return proxyToMainThread(1,0,0,returnCode);_exit(returnCode)}var exitJS=(status,implicit)=>{EXITSTATUS=status;if(ENVIRONMENT_IS_PTHREAD){exitOnMainThread(status);throw"unwind"}_proc_exit(status)};var _exit=exitJS;var PThread={unusedWorkers:[],runningWorkers:[],tlsInitFunctions:[],pthreads:{},init(){if(!ENVIRONMENT_IS_PTHREAD){PThread.initMainThread()}},initMainThread(){var pthreadPoolSize=1;while(pthreadPoolSize--){PThread.allocateUnusedWorker()}addOnPreRun(()=>{addRunDependency("loading-workers");PThread.loadWasmModuleToAllWorkers(()=>removeRunDependency("loading-workers"))})},terminateAllThreads:()=>{for(var worker of PThread.runningWorkers){terminateWorker(worker)}for(var worker of PThread.unusedWorkers){terminateWorker(worker)}PThread.unusedWorkers=[];PThread.runningWorkers=[];PThread.pthreads=[]},returnWorkerToPool:worker=>{var pthread_ptr=worker.pthread_ptr;delete PThread.pthreads[pthread_ptr];PThread.unusedWorkers.push(worker);PThread.runningWorkers.splice(PThread.runningWorkers.indexOf(worker),1);worker.pthread_ptr=0;__emscripten_thread_free_data(pthread_ptr)},receiveObjectTransfer(data){},threadInitTLS(){PThread.tlsInitFunctions.forEach(f=>f())},loadWasmModuleToWorker:worker=>new Promise(onFinishedLoading=>{worker.onmessage=e=>{var d=e["data"];var cmd=d.cmd;if(d.targetThread&&d.targetThread!=_pthread_self()){var targetWorker=PThread.pthreads[d.targetThread];if(targetWorker){targetWorker.postMessage(d,d.transferList)}else{err(`Internal error! Worker sent a message "${cmd}" to target pthread ${d.targetThread}, but that thread no longer exists!`)}return}if(cmd==="checkMailbox"){checkMailbox()}else if(cmd==="spawnThread"){spawnThread(d)}else if(cmd==="cleanupThread"){cleanupThread(d.thread)}else if(cmd==="loaded"){worker.loaded=true;onFinishedLoading(worker)}else if(cmd==="alert"){alert(`Thread ${d.threadId}: ${d.text}`)}else if(d.target==="setimmediate"){worker.postMessage(d)}else if(cmd==="callHandler"){Module[d.handler](...d.args)}else if(cmd){err(`worker sent an unknown command ${cmd}`)}};worker.onerror=e=>{var message="worker sent an error!";err(`${message} ${e.filename}:${e.lineno}: ${e.message}`);throw e};var handlers=[];var knownHandlers=["onExit","onAbort","print","printErr"];for(var handler of knownHandlers){if(Module.propertyIsEnumerable(handler)){handlers.push(handler)}}worker.postMessage({cmd:"load",handlers,wasmMemory,wasmModule})}),loadWasmModuleToAllWorkers(onMaybeReady){if(ENVIRONMENT_IS_PTHREAD){return onMaybeReady()}let pthreadPoolReady=Promise.all(PThread.unusedWorkers.map(PThread.loadWasmModuleToWorker));pthreadPoolReady.then(onMaybeReady)},allocateUnusedWorker(){var worker;var workerOptions={type:"module",name:"em-pthread"};worker=new Worker(new URL("web_engine.js",import.meta.url),workerOptions);PThread.unusedWorkers.push(worker)},getNewWorker(){if(PThread.unusedWorkers.length==0){PThread.allocateUnusedWorker();PThread.loadWasmModuleToWorker(PThread.unusedWorkers[0])}return PThread.unusedWorkers.pop()}};var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var establishStackSpace=pthread_ptr=>{updateMemoryViews();var stackHigh=GROWABLE_HEAP_U32()[pthread_ptr+52>>2];var stackSize=GROWABLE_HEAP_U32()[pthread_ptr+56>>2];var stackLow=stackHigh-stackSize;_emscripten_stack_set_limits(stackHigh,stackLow);stackRestore(stackHigh)};var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var invokeEntryPoint=(ptr,arg)=>{runtimeKeepaliveCounter=0;noExitRuntime=0;var result=getWasmTableEntry(ptr)(arg);function finish(result){if(keepRuntimeAlive()){EXITSTATUS=result}else{__emscripten_thread_exit(result)}}finish(result)};var noExitRuntime=Module["noExitRuntime"]||true;var registerTLSInit=tlsInitFunc=>PThread.tlsInitFunctions.push(tlsInitFunc);var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){GROWABLE_HEAP_U32()[this.ptr+4>>2]=type}get_type(){return GROWABLE_HEAP_U32()[this.ptr+4>>2]}set_destructor(destructor){GROWABLE_HEAP_U32()[this.ptr+8>>2]=destructor}get_destructor(){return GROWABLE_HEAP_U32()[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;GROWABLE_HEAP_I8()[this.ptr+12]=caught}get_caught(){return GROWABLE_HEAP_I8()[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;GROWABLE_HEAP_I8()[this.ptr+13]=rethrown}get_rethrown(){return GROWABLE_HEAP_I8()[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){GROWABLE_HEAP_U32()[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return GROWABLE_HEAP_U32()[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}throw exceptionLast};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;throw exceptionLast};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>{abort("")};var __emscripten_init_main_thread_js=tb=>{__emscripten_thread_init(tb,!ENVIRONMENT_IS_WORKER,1,!ENVIRONMENT_IS_WEB,65536,false);PThread.threadInitTLS()};var maybeExit=()=>{if(!keepRuntimeAlive()){try{if(ENVIRONMENT_IS_PTHREAD)__emscripten_thread_exit(EXITSTATUS);else _exit(EXITSTATUS)}catch(e){handleException(e)}}};var callUserCallback=func=>{if(ABORT){return}try{func();maybeExit()}catch(e){handleException(e)}};var __emscripten_thread_mailbox_await=pthread_ptr=>{if(typeof Atomics.waitAsync==="function"){var wait=Atomics.waitAsync(GROWABLE_HEAP_I32(),pthread_ptr>>2,pthread_ptr);wait.value.then(checkMailbox);var waitingAsync=pthread_ptr+128;Atomics.store(GROWABLE_HEAP_I32(),waitingAsync>>2,1)}};var checkMailbox=()=>{var pthread_ptr=_pthread_self();if(pthread_ptr){__emscripten_thread_mailbox_await(pthread_ptr);callUserCallback(__emscripten_check_mailbox)}};var __emscripten_notify_mailbox_postmessage=(targetThread,currThreadId)=>{if(targetThread==currThreadId){setTimeout(checkMailbox)}else if(ENVIRONMENT_IS_PTHREAD){postMessage({targetThread,cmd:"checkMailbox"})}else{var worker=PThread.pthreads[targetThread];if(!worker){return}worker.postMessage({cmd:"checkMailbox"})}};var proxiedJSCallArgs=[];var __emscripten_receive_on_main_thread_js=(funcIndex,emAsmAddr,callingThread,numCallArgs,args)=>{proxiedJSCallArgs.length=numCallArgs;var b=args>>3;for(var i=0;i{if(!ENVIRONMENT_IS_PTHREAD)cleanupThread(thread);else postMessage({cmd:"cleanupThread",thread})};var __emscripten_thread_set_strongref=thread=>{};var warnOnce=text=>{warnOnce.shown||={};if(!warnOnce.shown[text]){warnOnce.shown[text]=1;err(text)}};var _emscripten_check_blocking_allowed=()=>{};var runtimeKeepalivePush=()=>{runtimeKeepaliveCounter+=1};var _emscripten_exit_with_live_runtime=()=>{runtimeKeepalivePush();throw"unwind"};var _emscripten_get_now=()=>performance.timeOrigin+performance.now();var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=GROWABLE_HEAP_U8().length;requestedSize>>>=0;if(requestedSize<=oldSize){return false}var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var _llvm_eh_typeid_for=type=>type;var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{GROWABLE_HEAP_I8().set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,GROWABLE_HEAP_U8(),outPtr,maxBytesToWrite);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.buffer instanceof SharedArrayBuffer?heapOrArray.slice(idx,endPtr):heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(GROWABLE_HEAP_U8(),ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};PThread.init();var proxiedFunctionTable=[_proc_exit,exitOnMainThread];var wasmImports;function assignWasmImports(){wasmImports={s:___cxa_begin_catch,x:___cxa_end_catch,b:___cxa_find_matching_catch_2,c:___cxa_find_matching_catch_3,L:___cxa_rethrow,u:___cxa_throw,h:___resumeException,C:__abort_js,I:__emscripten_init_main_thread_js,E:__emscripten_notify_mailbox_postmessage,J:__emscripten_receive_on_main_thread_js,B:__emscripten_thread_cleanup,H:__emscripten_thread_mailbox_await,G:__emscripten_thread_set_strongref,K:_emscripten_check_blocking_allowed,F:_emscripten_exit_with_live_runtime,v:_emscripten_get_now,D:_emscripten_resize_heap,A:_exit,q:invoke_fiii,j:invoke_ii,e:invoke_iii,g:invoke_iiii,P:invoke_iiiiffifffffffi,k:invoke_iiiii,O:invoke_iiiiifi,m:invoke_iiiiii,r:invoke_iiiiiii,Q:invoke_iiiiiiifii,N:invoke_iiiiiiii,w:invoke_iiiiiiiii,R:invoke_iiiiiiiiii,z:invoke_jiii,p:invoke_v,n:invoke_vi,d:invoke_vii,i:invoke_viii,f:invoke_viiii,l:invoke_viiiii,o:invoke_viiiiii,M:invoke_viiiiiii,y:invoke_viiij,t:_llvm_eh_typeid_for,a:wasmMemory}}var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["S"])();var _web_engine_create=Module["_web_engine_create"]=()=>(_web_engine_create=Module["_web_engine_create"]=wasmExports["T"])();var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=a0=>(_web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=wasmExports["U"])(a0);var _web_engine_destroy=Module["_web_engine_destroy"]=a0=>(_web_engine_destroy=Module["_web_engine_destroy"]=wasmExports["V"])(a0);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=()=>(_web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=wasmExports["W"])();var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=()=>(_web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=wasmExports["X"])();var _web_engine_open_blend=Module["_web_engine_open_blend"]=(a0,a1,a2)=>(_web_engine_open_blend=Module["_web_engine_open_blend"]=wasmExports["Y"])(a0,a1,a2);var _web_engine_apply_command=Module["_web_engine_apply_command"]=(a0,a1,a2)=>(_web_engine_apply_command=Module["_web_engine_apply_command"]=wasmExports["_"])(a0,a1,a2);var _web_engine_undo=Module["_web_engine_undo"]=a0=>(_web_engine_undo=Module["_web_engine_undo"]=wasmExports["$"])(a0);var _web_engine_redo=Module["_web_engine_redo"]=a0=>(_web_engine_redo=Module["_web_engine_redo"]=wasmExports["aa"])(a0);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=(a0,a1,a2)=>(_web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=wasmExports["ba"])(a0,a1,a2);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=(a0,a1,a2)=>(_web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=wasmExports["ca"])(a0,a1,a2);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=(a0,a1,a2)=>(_web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=wasmExports["da"])(a0,a1,a2);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=(a0,a1,a2)=>(_web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=wasmExports["ea"])(a0,a1,a2);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=(a0,a1,a2,a3,a4)=>(_web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=wasmExports["fa"])(a0,a1,a2,a3,a4);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=(a0,a1,a2)=>(_web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=wasmExports["ga"])(a0,a1,a2);var _web_engine_save_blend=Module["_web_engine_save_blend"]=(a0,a1,a2)=>(_web_engine_save_blend=Module["_web_engine_save_blend"]=wasmExports["ha"])(a0,a1,a2);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["ia"])(a0);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=a0=>(_web_engine_free_buffer=Module["_web_engine_free_buffer"]=wasmExports["ja"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["ka"])(a0);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=()=>(_web_engine_last_error_code=Module["_web_engine_last_error_code"]=wasmExports["la"])();var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=()=>(_web_engine_last_error_message=Module["_web_engine_last_error_message"]=wasmExports["ma"])();var __emscripten_tls_init=()=>(__emscripten_tls_init=wasmExports["na"])();var _pthread_self=()=>(_pthread_self=wasmExports["oa"])();var __emscripten_thread_init=(a0,a1,a2,a3,a4,a5)=>(__emscripten_thread_init=wasmExports["pa"])(a0,a1,a2,a3,a4,a5);var __emscripten_thread_crashed=()=>(__emscripten_thread_crashed=wasmExports["qa"])();var __emscripten_run_on_main_thread_js=(a0,a1,a2,a3,a4)=>(__emscripten_run_on_main_thread_js=wasmExports["ra"])(a0,a1,a2,a3,a4);var __emscripten_thread_free_data=a0=>(__emscripten_thread_free_data=wasmExports["sa"])(a0);var __emscripten_thread_exit=a0=>(__emscripten_thread_exit=wasmExports["ta"])(a0);var __emscripten_check_mailbox=()=>(__emscripten_check_mailbox=wasmExports["ua"])();var _setThrew=(a0,a1)=>(_setThrew=wasmExports["va"])(a0,a1);var __emscripten_tempret_set=a0=>(__emscripten_tempret_set=wasmExports["wa"])(a0);var _emscripten_stack_set_limits=(a0,a1)=>(_emscripten_stack_set_limits=wasmExports["xa"])(a0,a1);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["ya"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["za"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["Aa"])();var ___cxa_increment_exception_refcount=a0=>(___cxa_increment_exception_refcount=wasmExports["Ba"])(a0);var ___cxa_decrement_exception_refcount=a0=>(___cxa_decrement_exception_refcount=wasmExports["Ca"])(a0);var ___cxa_can_catch=(a0,a1,a2)=>(___cxa_can_catch=wasmExports["Da"])(a0,a1,a2);var ___cxa_get_exception_ptr=a0=>(___cxa_get_exception_ptr=wasmExports["Ea"])(a0);var dynCall_jiii=Module["dynCall_jiii"]=(a0,a1,a2,a3)=>(dynCall_jiii=Module["dynCall_jiii"]=wasmExports["Fa"])(a0,a1,a2,a3);var dynCall_viiij=Module["dynCall_viiij"]=(a0,a1,a2,a3,a4,a5)=>(dynCall_viiij=Module["dynCall_viiij"]=wasmExports["Ga"])(a0,a1,a2,a3,a4,a5);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["PThread"]=PThread;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(ENVIRONMENT_IS_PTHREAD){readyPromiseResolve(Module);initRuntime();startWorker(Module);return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise; +function GROWABLE_HEAP_I8(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAP8}function GROWABLE_HEAP_U8(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPU8}function GROWABLE_HEAP_I32(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAP32}function GROWABLE_HEAP_U32(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPU32}function GROWABLE_HEAP_F64(){if(wasmMemory.buffer!=HEAP8.buffer){updateMemoryViews()}return HEAPF64}var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var ENVIRONMENT_IS_PTHREAD=ENVIRONMENT_IS_WORKER&&self.name?.startsWith("em-pthread");var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var wasmModule;var ABORT=false;var EXITSTATUS;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}if(ENVIRONMENT_IS_PTHREAD){var wasmPromiseResolve;var wasmPromiseReject;var initializedJS=false;function threadPrintErr(...args){var text=args.join(" ");console.error(text)}if(!Module["printErr"])err=threadPrintErr;function threadAlert(...args){var text=args.join(" ");postMessage({cmd:"alert",text,threadId:_pthread_self()})}self.alert=threadAlert;Module["instantiateWasm"]=(info,receiveInstance)=>new Promise((resolve,reject)=>{wasmPromiseResolve=module=>{var instance=new WebAssembly.Instance(module,getWasmImports());receiveInstance(instance);resolve()};wasmPromiseReject=reject});self.onunhandledrejection=e=>{throw e.reason||e};function handleMessage(e){try{var msgData=e["data"];var cmd=msgData.cmd;if(cmd==="load"){let messageQueue=[];self.onmessage=e=>messageQueue.push(e);self.startWorker=instance=>{postMessage({cmd:"loaded"});for(let msg of messageQueue){handleMessage(msg)}self.onmessage=handleMessage};for(const handler of msgData.handlers){if(!Module[handler]||Module[handler].proxy){Module[handler]=(...args)=>{postMessage({cmd:"callHandler",handler,args})};if(handler=="print")out=Module[handler];if(handler=="printErr")err=Module[handler]}}wasmMemory=msgData.wasmMemory;updateMemoryViews();wasmPromiseResolve(msgData.wasmModule)}else if(cmd==="run"){establishStackSpace(msgData.pthread_ptr);__emscripten_thread_init(msgData.pthread_ptr,0,0,1,0,0);PThread.receiveObjectTransfer(msgData);PThread.threadInitTLS();__emscripten_thread_mailbox_await(msgData.pthread_ptr);if(!initializedJS){initializedJS=true}try{invokeEntryPoint(msgData.start_routine,msgData.arg)}catch(ex){if(ex!="unwind"){throw ex}}}else if(msgData.target==="setimmediate"){}else if(cmd==="checkMailbox"){if(initializedJS){checkMailbox()}}else if(cmd){err(`worker: received unknown command ${cmd}`);err(msgData)}}catch(ex){__emscripten_thread_crashed();throw ex}}self.onmessage=handleMessage}if(!ENVIRONMENT_IS_PTHREAD){if(Module["wasmMemory"]){wasmMemory=Module["wasmMemory"]}else{var INITIAL_MEMORY=Module["INITIAL_MEMORY"]||16777216;wasmMemory=new WebAssembly.Memory({initial:INITIAL_MEMORY/65536,maximum:32768,shared:true})}updateMemoryViews()}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(ENVIRONMENT_IS_PTHREAD)return;callRuntimeCallbacks(__ATINIT__)}function postRun(){if(ENVIRONMENT_IS_PTHREAD)return;var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){assignWasmImports();return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;registerTLSInit(wasmExports["oa"]);wasmTable=wasmExports["_"];addOnInit(wasmExports["T"]);wasmModule=module;removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"],result["module"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var terminateWorker=worker=>{worker.terminate();worker.onmessage=e=>{}};var cleanupThread=pthread_ptr=>{var worker=PThread.pthreads[pthread_ptr];PThread.returnWorkerToPool(worker)};var spawnThread=threadParams=>{var worker=PThread.getNewWorker();if(!worker){return 6}PThread.runningWorkers.push(worker);PThread.pthreads[threadParams.pthread_ptr]=worker;worker.pthread_ptr=threadParams.pthread_ptr;var msg={cmd:"run",start_routine:threadParams.startRoutine,arg:threadParams.arg,pthread_ptr:threadParams.pthread_ptr};worker.postMessage(msg,threadParams.transferList);return 0};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var stackSave=()=>_emscripten_stack_get_current();var stackRestore=val=>__emscripten_stack_restore(val);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var proxyToMainThread=(funcIndex,emAsmAddr,sync,...callArgs)=>{var serializedNumCallArgs=callArgs.length;var sp=stackSave();var args=stackAlloc(serializedNumCallArgs*8);var b=args>>3;for(var i=0;i{if(e instanceof ExitStatus||e=="unwind"){return EXITSTATUS}quit_(1,e)};function exitOnMainThread(returnCode){if(ENVIRONMENT_IS_PTHREAD)return proxyToMainThread(1,0,0,returnCode);_exit(returnCode)}var exitJS=(status,implicit)=>{EXITSTATUS=status;if(ENVIRONMENT_IS_PTHREAD){exitOnMainThread(status);throw"unwind"}_proc_exit(status)};var _exit=exitJS;var PThread={unusedWorkers:[],runningWorkers:[],tlsInitFunctions:[],pthreads:{},init(){if(!ENVIRONMENT_IS_PTHREAD){PThread.initMainThread()}},initMainThread(){var pthreadPoolSize=1;while(pthreadPoolSize--){PThread.allocateUnusedWorker()}addOnPreRun(()=>{addRunDependency("loading-workers");PThread.loadWasmModuleToAllWorkers(()=>removeRunDependency("loading-workers"))})},terminateAllThreads:()=>{for(var worker of PThread.runningWorkers){terminateWorker(worker)}for(var worker of PThread.unusedWorkers){terminateWorker(worker)}PThread.unusedWorkers=[];PThread.runningWorkers=[];PThread.pthreads=[]},returnWorkerToPool:worker=>{var pthread_ptr=worker.pthread_ptr;delete PThread.pthreads[pthread_ptr];PThread.unusedWorkers.push(worker);PThread.runningWorkers.splice(PThread.runningWorkers.indexOf(worker),1);worker.pthread_ptr=0;__emscripten_thread_free_data(pthread_ptr)},receiveObjectTransfer(data){},threadInitTLS(){PThread.tlsInitFunctions.forEach(f=>f())},loadWasmModuleToWorker:worker=>new Promise(onFinishedLoading=>{worker.onmessage=e=>{var d=e["data"];var cmd=d.cmd;if(d.targetThread&&d.targetThread!=_pthread_self()){var targetWorker=PThread.pthreads[d.targetThread];if(targetWorker){targetWorker.postMessage(d,d.transferList)}else{err(`Internal error! Worker sent a message "${cmd}" to target pthread ${d.targetThread}, but that thread no longer exists!`)}return}if(cmd==="checkMailbox"){checkMailbox()}else if(cmd==="spawnThread"){spawnThread(d)}else if(cmd==="cleanupThread"){cleanupThread(d.thread)}else if(cmd==="loaded"){worker.loaded=true;onFinishedLoading(worker)}else if(cmd==="alert"){alert(`Thread ${d.threadId}: ${d.text}`)}else if(d.target==="setimmediate"){worker.postMessage(d)}else if(cmd==="callHandler"){Module[d.handler](...d.args)}else if(cmd){err(`worker sent an unknown command ${cmd}`)}};worker.onerror=e=>{var message="worker sent an error!";err(`${message} ${e.filename}:${e.lineno}: ${e.message}`);throw e};var handlers=[];var knownHandlers=["onExit","onAbort","print","printErr"];for(var handler of knownHandlers){if(Module.propertyIsEnumerable(handler)){handlers.push(handler)}}worker.postMessage({cmd:"load",handlers,wasmMemory,wasmModule})}),loadWasmModuleToAllWorkers(onMaybeReady){if(ENVIRONMENT_IS_PTHREAD){return onMaybeReady()}let pthreadPoolReady=Promise.all(PThread.unusedWorkers.map(PThread.loadWasmModuleToWorker));pthreadPoolReady.then(onMaybeReady)},allocateUnusedWorker(){var worker;var workerOptions={type:"module",name:"em-pthread"};worker=new Worker(new URL("web_engine.js",import.meta.url),workerOptions);PThread.unusedWorkers.push(worker)},getNewWorker(){if(PThread.unusedWorkers.length==0){PThread.allocateUnusedWorker();PThread.loadWasmModuleToWorker(PThread.unusedWorkers[0])}return PThread.unusedWorkers.pop()}};var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var establishStackSpace=pthread_ptr=>{updateMemoryViews();var stackHigh=GROWABLE_HEAP_U32()[pthread_ptr+52>>2];var stackSize=GROWABLE_HEAP_U32()[pthread_ptr+56>>2];var stackLow=stackHigh-stackSize;_emscripten_stack_set_limits(stackHigh,stackLow);stackRestore(stackHigh)};var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var invokeEntryPoint=(ptr,arg)=>{runtimeKeepaliveCounter=0;noExitRuntime=0;var result=getWasmTableEntry(ptr)(arg);function finish(result){if(keepRuntimeAlive()){EXITSTATUS=result}else{__emscripten_thread_exit(result)}}finish(result)};var noExitRuntime=Module["noExitRuntime"]||true;var registerTLSInit=tlsInitFunc=>PThread.tlsInitFunctions.push(tlsInitFunc);var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){GROWABLE_HEAP_U32()[this.ptr+4>>2]=type}get_type(){return GROWABLE_HEAP_U32()[this.ptr+4>>2]}set_destructor(destructor){GROWABLE_HEAP_U32()[this.ptr+8>>2]=destructor}get_destructor(){return GROWABLE_HEAP_U32()[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;GROWABLE_HEAP_I8()[this.ptr+12]=caught}get_caught(){return GROWABLE_HEAP_I8()[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;GROWABLE_HEAP_I8()[this.ptr+13]=rethrown}get_rethrown(){return GROWABLE_HEAP_I8()[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){GROWABLE_HEAP_U32()[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return GROWABLE_HEAP_U32()[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}throw exceptionLast};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;throw exceptionLast};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>{abort("")};var __emscripten_init_main_thread_js=tb=>{__emscripten_thread_init(tb,!ENVIRONMENT_IS_WORKER,1,!ENVIRONMENT_IS_WEB,65536,false);PThread.threadInitTLS()};var maybeExit=()=>{if(!keepRuntimeAlive()){try{if(ENVIRONMENT_IS_PTHREAD)__emscripten_thread_exit(EXITSTATUS);else _exit(EXITSTATUS)}catch(e){handleException(e)}}};var callUserCallback=func=>{if(ABORT){return}try{func();maybeExit()}catch(e){handleException(e)}};var __emscripten_thread_mailbox_await=pthread_ptr=>{if(typeof Atomics.waitAsync==="function"){var wait=Atomics.waitAsync(GROWABLE_HEAP_I32(),pthread_ptr>>2,pthread_ptr);wait.value.then(checkMailbox);var waitingAsync=pthread_ptr+128;Atomics.store(GROWABLE_HEAP_I32(),waitingAsync>>2,1)}};var checkMailbox=()=>{var pthread_ptr=_pthread_self();if(pthread_ptr){__emscripten_thread_mailbox_await(pthread_ptr);callUserCallback(__emscripten_check_mailbox)}};var __emscripten_notify_mailbox_postmessage=(targetThread,currThreadId)=>{if(targetThread==currThreadId){setTimeout(checkMailbox)}else if(ENVIRONMENT_IS_PTHREAD){postMessage({targetThread,cmd:"checkMailbox"})}else{var worker=PThread.pthreads[targetThread];if(!worker){return}worker.postMessage({cmd:"checkMailbox"})}};var proxiedJSCallArgs=[];var __emscripten_receive_on_main_thread_js=(funcIndex,emAsmAddr,callingThread,numCallArgs,args)=>{proxiedJSCallArgs.length=numCallArgs;var b=args>>3;for(var i=0;i{if(!ENVIRONMENT_IS_PTHREAD)cleanupThread(thread);else postMessage({cmd:"cleanupThread",thread})};var __emscripten_thread_set_strongref=thread=>{};var warnOnce=text=>{warnOnce.shown||={};if(!warnOnce.shown[text]){warnOnce.shown[text]=1;err(text)}};var _emscripten_check_blocking_allowed=()=>{};var runtimeKeepalivePush=()=>{runtimeKeepaliveCounter+=1};var _emscripten_exit_with_live_runtime=()=>{runtimeKeepalivePush();throw"unwind"};var _emscripten_get_now=()=>performance.timeOrigin+performance.now();var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=GROWABLE_HEAP_U8().length;requestedSize>>>=0;if(requestedSize<=oldSize){return false}var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var _llvm_eh_typeid_for=type=>type;var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{GROWABLE_HEAP_I8().set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,GROWABLE_HEAP_U8(),outPtr,maxBytesToWrite);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.buffer instanceof SharedArrayBuffer?heapOrArray.slice(idx,endPtr):heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(GROWABLE_HEAP_U8(),ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};PThread.init();var proxiedFunctionTable=[_proc_exit,exitOnMainThread];var wasmImports;function assignWasmImports(){wasmImports={s:___cxa_begin_catch,w:___cxa_end_catch,b:___cxa_find_matching_catch_2,c:___cxa_find_matching_catch_3,M:___cxa_rethrow,u:___cxa_throw,h:___resumeException,D:__abort_js,J:__emscripten_init_main_thread_js,F:__emscripten_notify_mailbox_postmessage,K:__emscripten_receive_on_main_thread_js,C:__emscripten_thread_cleanup,I:__emscripten_thread_mailbox_await,H:__emscripten_thread_set_strongref,L:_emscripten_check_blocking_allowed,G:_emscripten_exit_with_live_runtime,v:_emscripten_get_now,E:_emscripten_resize_heap,B:_exit,o:invoke_fiii,j:invoke_ii,e:invoke_iii,g:invoke_iiii,Q:invoke_iiiiffifffffffi,k:invoke_iiiii,P:invoke_iiiiifi,m:invoke_iiiiii,r:invoke_iiiiiii,R:invoke_iiiiiiifii,y:invoke_iiiiiiii,x:invoke_iiiiiiiii,S:invoke_iiiiiiiiii,O:invoke_iiiiiiiiiifi,A:invoke_jiii,q:invoke_v,n:invoke_vi,d:invoke_vii,i:invoke_viii,f:invoke_viiii,l:invoke_viiiii,p:invoke_viiiiii,N:invoke_viiiiiii,z:invoke_viiij,t:_llvm_eh_typeid_for,a:wasmMemory}}var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["T"])();var _web_engine_create=Module["_web_engine_create"]=()=>(_web_engine_create=Module["_web_engine_create"]=wasmExports["U"])();var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=a0=>(_web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=wasmExports["V"])(a0);var _web_engine_destroy=Module["_web_engine_destroy"]=a0=>(_web_engine_destroy=Module["_web_engine_destroy"]=wasmExports["W"])(a0);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=()=>(_web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=wasmExports["X"])();var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=()=>(_web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=wasmExports["Y"])();var _web_engine_open_blend=Module["_web_engine_open_blend"]=(a0,a1,a2)=>(_web_engine_open_blend=Module["_web_engine_open_blend"]=wasmExports["Z"])(a0,a1,a2);var _web_engine_apply_command=Module["_web_engine_apply_command"]=(a0,a1,a2)=>(_web_engine_apply_command=Module["_web_engine_apply_command"]=wasmExports["$"])(a0,a1,a2);var _web_engine_undo=Module["_web_engine_undo"]=a0=>(_web_engine_undo=Module["_web_engine_undo"]=wasmExports["aa"])(a0);var _web_engine_redo=Module["_web_engine_redo"]=a0=>(_web_engine_redo=Module["_web_engine_redo"]=wasmExports["ba"])(a0);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=(a0,a1,a2)=>(_web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=wasmExports["ca"])(a0,a1,a2);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=(a0,a1,a2)=>(_web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=wasmExports["da"])(a0,a1,a2);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=(a0,a1,a2)=>(_web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=wasmExports["ea"])(a0,a1,a2);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=(a0,a1,a2)=>(_web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=wasmExports["fa"])(a0,a1,a2);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=(a0,a1,a2,a3,a4)=>(_web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=wasmExports["ga"])(a0,a1,a2,a3,a4);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=(a0,a1,a2)=>(_web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=wasmExports["ha"])(a0,a1,a2);var _web_engine_save_blend=Module["_web_engine_save_blend"]=(a0,a1,a2)=>(_web_engine_save_blend=Module["_web_engine_save_blend"]=wasmExports["ia"])(a0,a1,a2);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["ja"])(a0);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=a0=>(_web_engine_free_buffer=Module["_web_engine_free_buffer"]=wasmExports["ka"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["la"])(a0);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=()=>(_web_engine_last_error_code=Module["_web_engine_last_error_code"]=wasmExports["ma"])();var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=()=>(_web_engine_last_error_message=Module["_web_engine_last_error_message"]=wasmExports["na"])();var __emscripten_tls_init=()=>(__emscripten_tls_init=wasmExports["oa"])();var _pthread_self=()=>(_pthread_self=wasmExports["pa"])();var __emscripten_thread_init=(a0,a1,a2,a3,a4,a5)=>(__emscripten_thread_init=wasmExports["qa"])(a0,a1,a2,a3,a4,a5);var __emscripten_thread_crashed=()=>(__emscripten_thread_crashed=wasmExports["ra"])();var __emscripten_run_on_main_thread_js=(a0,a1,a2,a3,a4)=>(__emscripten_run_on_main_thread_js=wasmExports["sa"])(a0,a1,a2,a3,a4);var __emscripten_thread_free_data=a0=>(__emscripten_thread_free_data=wasmExports["ta"])(a0);var __emscripten_thread_exit=a0=>(__emscripten_thread_exit=wasmExports["ua"])(a0);var __emscripten_check_mailbox=()=>(__emscripten_check_mailbox=wasmExports["va"])();var _setThrew=(a0,a1)=>(_setThrew=wasmExports["wa"])(a0,a1);var __emscripten_tempret_set=a0=>(__emscripten_tempret_set=wasmExports["xa"])(a0);var _emscripten_stack_set_limits=(a0,a1)=>(_emscripten_stack_set_limits=wasmExports["ya"])(a0,a1);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["za"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["Aa"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["Ba"])();var ___cxa_increment_exception_refcount=a0=>(___cxa_increment_exception_refcount=wasmExports["Ca"])(a0);var ___cxa_decrement_exception_refcount=a0=>(___cxa_decrement_exception_refcount=wasmExports["Da"])(a0);var ___cxa_can_catch=(a0,a1,a2)=>(___cxa_can_catch=wasmExports["Ea"])(a0,a1,a2);var ___cxa_get_exception_ptr=a0=>(___cxa_get_exception_ptr=wasmExports["Fa"])(a0);var dynCall_jiii=Module["dynCall_jiii"]=(a0,a1,a2,a3)=>(dynCall_jiii=Module["dynCall_jiii"]=wasmExports["Ga"])(a0,a1,a2,a3);var dynCall_viiij=Module["dynCall_viiij"]=(a0,a1,a2,a3,a4,a5)=>(dynCall_viiij=Module["dynCall_viiij"]=wasmExports["Ha"])(a0,a1,a2,a3,a4,a5);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["PThread"]=PThread;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(ENVIRONMENT_IS_PTHREAD){readyPromiseResolve(Module);initRuntime();startWorker(Module);return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise; return moduleRtn; diff --git a/web/app/public/vendor/blender/pthread/web_engine.wasm b/web/app/public/vendor/blender/pthread/web_engine.wasm index ab66ed0d..4337c2f0 100644 Binary files a/web/app/public/vendor/blender/pthread/web_engine.wasm and b/web/app/public/vendor/blender/pthread/web_engine.wasm differ diff --git a/web/app/public/vendor/blender/single/web_engine.js b/web/app/public/vendor/blender/single/web_engine.js index dd482cba..283630ad 100644 --- a/web/app/public/vendor/blender/single/web_engine.js +++ b/web/app/public/vendor/blender/single/web_engine.js @@ -6,7 +6,7 @@ var Module = (() => { function(moduleArg = {}) { var moduleRtn; -var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["I"];updateMemoryViews();wasmTable=wasmExports["Q"];addOnInit(wasmExports["J"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}throw exceptionLast};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;throw exceptionLast};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>{abort("")};var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var _llvm_eh_typeid_for=type=>type;var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};var wasmImports={r:___cxa_begin_catch,v:___cxa_end_catch,a:___cxa_find_matching_catch_2,b:___cxa_find_matching_catch_3,B:___cxa_rethrow,t:___cxa_throw,g:___resumeException,y:__abort_js,A:__emscripten_memcpy_js,z:_emscripten_resize_heap,p:invoke_fiii,i:invoke_ii,d:invoke_iii,f:invoke_iiii,F:invoke_iiiiffifffffffi,j:invoke_iiiii,E:invoke_iiiiifi,l:invoke_iiiiii,q:invoke_iiiiiii,G:invoke_iiiiiiifii,D:invoke_iiiiiiii,u:invoke_iiiiiiiii,H:invoke_iiiiiiiiii,x:invoke_jiii,o:invoke_v,m:invoke_vi,c:invoke_vii,h:invoke_viii,e:invoke_viiii,k:invoke_viiiii,n:invoke_viiiiii,C:invoke_viiiiiii,w:invoke_viiij,s:_llvm_eh_typeid_for};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["J"])();var _web_engine_create=Module["_web_engine_create"]=()=>(_web_engine_create=Module["_web_engine_create"]=wasmExports["K"])();var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=a0=>(_web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=wasmExports["L"])(a0);var _web_engine_destroy=Module["_web_engine_destroy"]=a0=>(_web_engine_destroy=Module["_web_engine_destroy"]=wasmExports["M"])(a0);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=()=>(_web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=wasmExports["N"])();var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=()=>(_web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=wasmExports["O"])();var _web_engine_open_blend=Module["_web_engine_open_blend"]=(a0,a1,a2)=>(_web_engine_open_blend=Module["_web_engine_open_blend"]=wasmExports["P"])(a0,a1,a2);var _web_engine_apply_command=Module["_web_engine_apply_command"]=(a0,a1,a2)=>(_web_engine_apply_command=Module["_web_engine_apply_command"]=wasmExports["R"])(a0,a1,a2);var _web_engine_undo=Module["_web_engine_undo"]=a0=>(_web_engine_undo=Module["_web_engine_undo"]=wasmExports["S"])(a0);var _web_engine_redo=Module["_web_engine_redo"]=a0=>(_web_engine_redo=Module["_web_engine_redo"]=wasmExports["T"])(a0);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=(a0,a1,a2)=>(_web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=wasmExports["U"])(a0,a1,a2);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=(a0,a1,a2)=>(_web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=wasmExports["V"])(a0,a1,a2);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=(a0,a1,a2)=>(_web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=wasmExports["W"])(a0,a1,a2);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=(a0,a1,a2)=>(_web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=wasmExports["X"])(a0,a1,a2);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=(a0,a1,a2,a3,a4)=>(_web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=wasmExports["Y"])(a0,a1,a2,a3,a4);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=(a0,a1,a2)=>(_web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=wasmExports["Z"])(a0,a1,a2);var _web_engine_save_blend=Module["_web_engine_save_blend"]=(a0,a1,a2)=>(_web_engine_save_blend=Module["_web_engine_save_blend"]=wasmExports["_"])(a0,a1,a2);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["$"])(a0);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=a0=>(_web_engine_free_buffer=Module["_web_engine_free_buffer"]=wasmExports["aa"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["ba"])(a0);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=()=>(_web_engine_last_error_code=Module["_web_engine_last_error_code"]=wasmExports["ca"])();var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=()=>(_web_engine_last_error_message=Module["_web_engine_last_error_message"]=wasmExports["da"])();var _setThrew=(a0,a1)=>(_setThrew=wasmExports["ea"])(a0,a1);var __emscripten_tempret_set=a0=>(__emscripten_tempret_set=wasmExports["fa"])(a0);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["ga"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["ha"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["ia"])();var ___cxa_increment_exception_refcount=a0=>(___cxa_increment_exception_refcount=wasmExports["ja"])(a0);var ___cxa_decrement_exception_refcount=a0=>(___cxa_decrement_exception_refcount=wasmExports["ka"])(a0);var ___cxa_can_catch=(a0,a1,a2)=>(___cxa_can_catch=wasmExports["la"])(a0,a1,a2);var ___cxa_get_exception_ptr=a0=>(___cxa_get_exception_ptr=wasmExports["ma"])(a0);var dynCall_jiii=Module["dynCall_jiii"]=(a0,a1,a2,a3)=>(dynCall_jiii=Module["dynCall_jiii"]=wasmExports["na"])(a0,a1,a2,a3);var dynCall_viiij=Module["dynCall_viiij"]=(a0,a1,a2,a3,a4,a5)=>(dynCall_viiij=Module["dynCall_viiij"]=wasmExports["oa"])(a0,a1,a2,a3,a4,a5);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise; +var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["J"];updateMemoryViews();wasmTable=wasmExports["R"];addOnInit(wasmExports["K"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}throw exceptionLast};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;throw exceptionLast};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>{abort("")};var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var _llvm_eh_typeid_for=type=>type;var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};var wasmImports={r:___cxa_begin_catch,u:___cxa_end_catch,a:___cxa_find_matching_catch_2,b:___cxa_find_matching_catch_3,C:___cxa_rethrow,t:___cxa_throw,g:___resumeException,z:__abort_js,B:__emscripten_memcpy_js,A:_emscripten_resize_heap,n:invoke_fiii,i:invoke_ii,d:invoke_iii,f:invoke_iiii,G:invoke_iiiiffifffffffi,j:invoke_iiiii,F:invoke_iiiiifi,l:invoke_iiiiii,q:invoke_iiiiiii,H:invoke_iiiiiiifii,w:invoke_iiiiiiii,v:invoke_iiiiiiiii,I:invoke_iiiiiiiiii,E:invoke_iiiiiiiiiifi,y:invoke_jiii,p:invoke_v,m:invoke_vi,c:invoke_vii,h:invoke_viii,e:invoke_viiii,k:invoke_viiiii,o:invoke_viiiiii,D:invoke_viiiiiii,x:invoke_viiij,s:_llvm_eh_typeid_for};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["K"])();var _web_engine_create=Module["_web_engine_create"]=()=>(_web_engine_create=Module["_web_engine_create"]=wasmExports["L"])();var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=a0=>(_web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=wasmExports["M"])(a0);var _web_engine_destroy=Module["_web_engine_destroy"]=a0=>(_web_engine_destroy=Module["_web_engine_destroy"]=wasmExports["N"])(a0);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=()=>(_web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=wasmExports["O"])();var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=()=>(_web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=wasmExports["P"])();var _web_engine_open_blend=Module["_web_engine_open_blend"]=(a0,a1,a2)=>(_web_engine_open_blend=Module["_web_engine_open_blend"]=wasmExports["Q"])(a0,a1,a2);var _web_engine_apply_command=Module["_web_engine_apply_command"]=(a0,a1,a2)=>(_web_engine_apply_command=Module["_web_engine_apply_command"]=wasmExports["S"])(a0,a1,a2);var _web_engine_undo=Module["_web_engine_undo"]=a0=>(_web_engine_undo=Module["_web_engine_undo"]=wasmExports["T"])(a0);var _web_engine_redo=Module["_web_engine_redo"]=a0=>(_web_engine_redo=Module["_web_engine_redo"]=wasmExports["U"])(a0);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=(a0,a1,a2)=>(_web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=wasmExports["V"])(a0,a1,a2);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=(a0,a1,a2)=>(_web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=wasmExports["W"])(a0,a1,a2);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=(a0,a1,a2)=>(_web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=wasmExports["X"])(a0,a1,a2);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=(a0,a1,a2)=>(_web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=wasmExports["Y"])(a0,a1,a2);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=(a0,a1,a2,a3,a4)=>(_web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=wasmExports["Z"])(a0,a1,a2,a3,a4);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=(a0,a1,a2)=>(_web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=wasmExports["_"])(a0,a1,a2);var _web_engine_save_blend=Module["_web_engine_save_blend"]=(a0,a1,a2)=>(_web_engine_save_blend=Module["_web_engine_save_blend"]=wasmExports["$"])(a0,a1,a2);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["aa"])(a0);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=a0=>(_web_engine_free_buffer=Module["_web_engine_free_buffer"]=wasmExports["ba"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["ca"])(a0);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=()=>(_web_engine_last_error_code=Module["_web_engine_last_error_code"]=wasmExports["da"])();var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=()=>(_web_engine_last_error_message=Module["_web_engine_last_error_message"]=wasmExports["ea"])();var _setThrew=(a0,a1)=>(_setThrew=wasmExports["fa"])(a0,a1);var __emscripten_tempret_set=a0=>(__emscripten_tempret_set=wasmExports["ga"])(a0);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["ha"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["ia"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["ja"])();var ___cxa_increment_exception_refcount=a0=>(___cxa_increment_exception_refcount=wasmExports["ka"])(a0);var ___cxa_decrement_exception_refcount=a0=>(___cxa_decrement_exception_refcount=wasmExports["la"])(a0);var ___cxa_can_catch=(a0,a1,a2)=>(___cxa_can_catch=wasmExports["ma"])(a0,a1,a2);var ___cxa_get_exception_ptr=a0=>(___cxa_get_exception_ptr=wasmExports["na"])(a0);var dynCall_jiii=Module["dynCall_jiii"]=(a0,a1,a2,a3)=>(dynCall_jiii=Module["dynCall_jiii"]=wasmExports["oa"])(a0,a1,a2,a3);var dynCall_viiij=Module["dynCall_viiij"]=(a0,a1,a2,a3,a4,a5)=>(dynCall_viiij=Module["dynCall_viiij"]=wasmExports["pa"])(a0,a1,a2,a3,a4,a5);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise; return moduleRtn; diff --git a/web/app/public/vendor/blender/single/web_engine.wasm b/web/app/public/vendor/blender/single/web_engine.wasm index 3cf74860..a2b81752 100644 Binary files a/web/app/public/vendor/blender/single/web_engine.wasm and b/web/app/public/vendor/blender/single/web_engine.wasm differ diff --git a/web/app/public/vendor/blender/web_engine.js b/web/app/public/vendor/blender/web_engine.js index 25c22ee2..8616e241 100644 --- a/web/app/public/vendor/blender/web_engine.js +++ b/web/app/public/vendor/blender/web_engine.js @@ -6,7 +6,7 @@ var Module = (() => { async function(moduleArg = {}) { var moduleRtn; -var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});["_malloc","_free","_web_engine_create","_web_engine_destroy","_web_engine_get_memory_stats","_web_engine_get_live_handles","_web_engine_get_allocated_bytes","_web_engine_open_blend","_web_engine_apply_command","_web_engine_undo","_web_engine_redo","_web_engine_get_scene_snapshot","_web_engine_get_scene_metadata","_web_engine_get_scene_geometry","_web_engine_get_scene_delta","_web_engine_get_packed_asset","_web_engine_evaluate_depsgraph","_web_engine_save_blend","_web_engine_free_buffer","_web_engine_last_error_code","_web_engine_last_error_message","_web_engine_decimate_apply","_memory","___indirect_function_table","___set_stack_limits","onRuntimeInitialized"].forEach(prop=>{if(!Object.getOwnPropertyDescriptor(readyPromise,prop)){Object.defineProperty(readyPromise,prop,{get:()=>abort("You are getting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js"),set:()=>abort("You are setting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js")})}});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");let dirname=import.meta.url;if(dirname.startsWith("data:")){dirname="/"}var require=createRequire(dirname)}var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){if(typeof process=="undefined"||!process.release||process.release.name!=="node")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");var nodeVersion=process.versions.node;var numericVersion=nodeVersion.split(".").slice(0,3);numericVersion=numericVersion[0]*1e4+numericVersion[1]*100+numericVersion[2].split("-")[0]*1;if(numericVersion<16e4){throw new Error("This emscripten-generated code requires node v16.0.0 (detected v"+nodeVersion+")")}var fs=require("fs");var nodePath=require("path");if(!import.meta.url.startsWith("data:")){scriptDirectory=nodePath.dirname(require("url").fileURLToPath(import.meta.url))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);var ret=fs.readFileSync(filename);assert(ret.buffer);return ret};readAsync=(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return new Promise((resolve,reject)=>{fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)reject(err);else resolve(binary?data.buffer:data)})})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_SHELL){if(typeof process=="object"&&typeof require==="function"||typeof window=="object"||typeof importScripts=="function")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)")}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}if(!(typeof window=="object"||typeof importScripts=="function"))throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>{assert(!isFileURI(url),"readAsync does not work with file:// URLs");return fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}}else{throw new Error("environment detection error")}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;checkIncomingModuleAPI();if(Module["arguments"])arguments_=Module["arguments"];legacyModuleProp("arguments","arguments_");if(Module["thisProgram"])thisProgram=Module["thisProgram"];legacyModuleProp("thisProgram","thisProgram");assert(typeof Module["memoryInitializerPrefixURL"]=="undefined","Module.memoryInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["pthreadMainPrefixURL"]=="undefined","Module.pthreadMainPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["cdInitializerPrefixURL"]=="undefined","Module.cdInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["filePackagePrefixURL"]=="undefined","Module.filePackagePrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["read"]=="undefined","Module.read option was removed");assert(typeof Module["readAsync"]=="undefined","Module.readAsync option was removed (modify readAsync in JS)");assert(typeof Module["readBinary"]=="undefined","Module.readBinary option was removed (modify readBinary in JS)");assert(typeof Module["setWindowTitle"]=="undefined","Module.setWindowTitle option was removed (modify emscripten_set_window_title in JS)");assert(typeof Module["TOTAL_MEMORY"]=="undefined","Module.TOTAL_MEMORY has been renamed Module.INITIAL_MEMORY");legacyModuleProp("asm","wasmExports");legacyModuleProp("readAsync","readAsync");legacyModuleProp("readBinary","readBinary");legacyModuleProp("setWindowTitle","setWindowTitle");assert(!ENVIRONMENT_IS_SHELL,"shell environment detected but not enabled at build time. Add `shell` to `-sENVIRONMENT` to enable.");var wasmBinary=Module["wasmBinary"];legacyModuleProp("wasmBinary","wasmBinary");if(typeof WebAssembly!="object"){err("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort("Assertion failed"+(text?": "+text:""))}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}assert(!Module["STACK_SIZE"],"STACK_SIZE can no longer be set at runtime. Use -sSTACK_SIZE at link time");assert(typeof Int32Array!="undefined"&&typeof Float64Array!=="undefined"&&Int32Array.prototype.subarray!=undefined&&Int32Array.prototype.set!=undefined,"JS engine does not provide full typed array support");assert(!Module["wasmMemory"],"Use of `wasmMemory` detected. Use -sIMPORTED_MEMORY to define wasmMemory externally");assert(!Module["INITIAL_MEMORY"],"Detected runtime INITIAL_MEMORY setting. Use -sIMPORTED_MEMORY to define wasmMemory dynamically");function writeStackCookie(){var max=_emscripten_stack_get_end();assert((max&3)==0);if(max==0){max+=4}HEAPU32[max>>2]=34821223;checkInt32(34821223);HEAPU32[max+4>>2]=2310721022;checkInt32(2310721022);HEAPU32[0>>2]=1668509029;checkInt32(1668509029)}function checkStackCookie(){if(ABORT)return;var max=_emscripten_stack_get_end();if(max==0){max+=4}var cookie1=HEAPU32[max>>2];var cookie2=HEAPU32[max+4>>2];if(cookie1!=34821223||cookie2!=2310721022){abort(`Stack overflow! Stack cookie has been overwritten at ${ptrToString(max)}, expected hex dwords 0x89BACDFE and 0x2135467, but received ${ptrToString(cookie2)} ${ptrToString(cookie1)}`)}if(HEAPU32[0>>2]!=1668509029){abort("Runtime error: The application has corrupted its heap memory area (address zero)!")}}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){assert(!runtimeInitialized);runtimeInitialized=true;checkStackCookie();setStackLimits();if(!Module["noFSInit"]&&!FS.initialized)FS.init();FS.ignorePermissions=false;TTY.init();callRuntimeCallbacks(__ATINIT__)}function postRun(){checkStackCookie();var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}assert(Math.imul,"This browser does not support Math.imul(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.fround,"This browser does not support Math.fround(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.clz32,"This browser does not support Math.clz32(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.trunc,"This browser does not support Math.trunc(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;var runDependencyTracking={};function getUniqueRunDependency(id){var orig=id;while(1){if(!runDependencyTracking[id])return id;id=orig+Math.random()}}function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies);if(id){assert(!runDependencyTracking[id]);runDependencyTracking[id]=1;if(runDependencyWatcher===null&&typeof setInterval!="undefined"){runDependencyWatcher=setInterval(()=>{if(ABORT){clearInterval(runDependencyWatcher);runDependencyWatcher=null;return}var shown=false;for(var dep in runDependencyTracking){if(!shown){shown=true;err("still waiting on run dependencies:")}err(`dependency: ${dep}`)}if(shown){err("(end of list)")}},1e4)}}else{err("warning: run dependency added without ID")}}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(id){assert(runDependencyTracking[id]);delete runDependencyTracking[id]}else{err("warning: run dependency removed without ID")}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);var isFileURI=filename=>filename.startsWith("file://");function createExportWrapper(name,nargs){return(...args)=>{assert(runtimeInitialized,`native function \`${name}\` called before runtime initialization`);var f=wasmExports[name];assert(f,`exported native function \`${name}\` not found`);assert(args.length<=nargs,`native function \`${name}\` called with ${args.length} args but expects ${nargs}`);return f(...args)}}function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);if(isFileURI(wasmBinaryFile)){err(`warning: Loading from a file URI (${wasmBinaryFile}) is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing`)}abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&!ENVIRONMENT_IS_NODE&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["memory"];assert(wasmMemory,"memory not found in wasm exports");updateMemoryViews();wasmTable=wasmExports["__indirect_function_table"];assert(wasmTable,"table not found in wasm exports");addOnInit(wasmExports["__wasm_call_ctors"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");var trueModule=Module;function receiveInstantiationResult(result){assert(Module===trueModule,"the Module object should not be replaced during async compilation - perhaps the order of HTML elements is wrong?");trueModule=null;receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var tempDouble;var tempI64;(()=>{var h16=new Int16Array(1);var h8=new Int8Array(h16.buffer);h16[0]=25459;if(h8[0]!==115||h8[1]!==99)throw"Runtime error: expected the system to be little-endian! (Run with -sSUPPORT_BIG_ENDIAN to bypass)"})();if(Module["ENVIRONMENT"]){throw new Error("Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option (for example, -sENVIRONMENT=web or -sENVIRONMENT=node)")}function legacyModuleProp(prop,newName,incoming=true){if(!Object.getOwnPropertyDescriptor(Module,prop)){Object.defineProperty(Module,prop,{configurable:true,get(){let extra=incoming?" (the initial value can be provided on Module, but after startup the value is only looked for on a local variable of that name)":"";abort(`\`Module.${prop}\` has been replaced by \`${newName}\``+extra)}})}}function ignoredModuleProp(prop){if(Object.getOwnPropertyDescriptor(Module,prop)){abort(`\`Module.${prop}\` was supplied but \`${prop}\` not included in INCOMING_MODULE_JS_API`)}}function isExportedByForceFilesystem(name){return name==="FS_createPath"||name==="FS_createDataFile"||name==="FS_createPreloadedFile"||name==="FS_unlink"||name==="addRunDependency"||name==="FS_createLazyFile"||name==="FS_createDevice"||name==="removeRunDependency"}function hookGlobalSymbolAccess(sym,func){if(typeof globalThis!="undefined"&&!Object.getOwnPropertyDescriptor(globalThis,sym)){Object.defineProperty(globalThis,sym,{configurable:true,get(){func();return undefined}})}}function missingGlobal(sym,msg){hookGlobalSymbolAccess(sym,()=>{warnOnce(`\`${sym}\` is not longer defined by emscripten. ${msg}`)})}missingGlobal("buffer","Please use HEAP8.buffer or wasmMemory.buffer");missingGlobal("asm","Please use wasmExports instead");function missingLibrarySymbol(sym){hookGlobalSymbolAccess(sym,()=>{var msg=`\`${sym}\` is a library symbol and not included by default; add it to your library.js __deps or to DEFAULT_LIBRARY_FUNCS_TO_INCLUDE on the command line`;var librarySymbol=sym;if(!librarySymbol.startsWith("_")){librarySymbol="$"+sym}msg+=` (e.g. -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE='${librarySymbol}')`;if(isExportedByForceFilesystem(sym)){msg+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you"}warnOnce(msg)});unexportedRuntimeSymbol(sym)}function unexportedRuntimeSymbol(sym){if(!Object.getOwnPropertyDescriptor(Module,sym)){Object.defineProperty(Module,sym,{configurable:true,get(){var msg=`'${sym}' was not exported. add it to EXPORTED_RUNTIME_METHODS (see the Emscripten FAQ)`;if(isExportedByForceFilesystem(sym)){msg+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you"}abort(msg)}})}}var MAX_UINT8=2**8-1;var MAX_UINT16=2**16-1;var MAX_UINT32=2**32-1;var MAX_UINT53=2**53-1;var MAX_UINT64=2**64-1;var MIN_INT8=-(2**(8-1));var MIN_INT16=-(2**(16-1));var MIN_INT32=-(2**(32-1));var MIN_INT53=-(2**(53-1));var MIN_INT64=-(2**(64-1));function checkInt(value,bits,min,max){assert(Number.isInteger(Number(value)),`attempt to write non-integer (${value}) into integer heap`);assert(value<=max,`value (${value}) too large to write as ${bits}-bit value`);assert(value>=min,`value (${value}) too small to write as ${bits}-bit value`)}var checkInt8=value=>checkInt(value,8,MIN_INT8,MAX_UINT8);var checkInt16=value=>checkInt(value,16,MIN_INT16,MAX_UINT16);var checkInt32=value=>checkInt(value,32,MIN_INT32,MAX_UINT32);var checkInt64=value=>checkInt(value,64,MIN_INT64,MAX_UINT64);function dbg(...args){console.warn(...args)}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var ptrToString=ptr=>{assert(typeof ptr==="number");ptr>>>=0;return"0x"+ptr.toString(16).padStart(8,"0")};var setStackLimits=()=>{var stackLow=_emscripten_stack_get_base();var stackHigh=_emscripten_stack_get_end();___set_stack_limits(stackLow,stackHigh)};var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var warnOnce=text=>{warnOnce.shown||={};if(!warnOnce.shown[text]){warnOnce.shown[text]=1;if(ENVIRONMENT_IS_NODE)text="warning: "+text;err(text)}};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>{assert(typeof ptr=="number",`UTF8ToString expects a number (got ${typeof ptr})`);return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""};var ___assert_fail=(condition,filename,line,func)=>{abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])};var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}assert(wasmTable.get(funcPtr)==func,"JavaScript-side Wasm function table mirror is out of date!");return func};var ___call_sighandler=(fp,sig)=>getWasmTableEntry(fp)(sig);var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);assert(exceptionCaught.length>0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught;checkInt8(caught)}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown;checkInt8(rethrown)}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var ___handle_stack_overflow=requested=>{var base=_emscripten_stack_get_base();var end=_emscripten_stack_get_end();abort(`stack overflow (Attempt to set SP to ${ptrToString(requested)}`+`, with stack limits [${ptrToString(end)} - ${ptrToString(base)}`+"]). If you require more stack space build with -sSTACK_SIZE=")};var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path==="/")return"/";path=PATH.normalize(path);path=path.replace(/\/$/,"");var lastSlash=path.lastIndexOf("/");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:(...paths)=>PATH.normalize(paths.join("/")),join2:(l,r)=>PATH.normalize(l+"/"+r)};var initRandomFill=()=>{if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else if(ENVIRONMENT_IS_NODE){try{var crypto_module=require("crypto");var randomFillSync=crypto_module["randomFillSync"];if(randomFillSync){return view=>crypto_module["randomFillSync"](view)}var randomBytes=crypto_module["randomBytes"];return view=>(view.set(randomBytes(view.byteLength)),view)}catch(e){}}abort("no cryptographic support found for randomDevice. consider polyfilling it if you want to use something insecure like Math.random(), e.g. put this in a --pre-js: var crypto = { getRandomValues: (array) => { for (var i = 0; i < array.length; i++) array[i] = (Math.random()*256)|0 } };")};var randomFill=view=>(randomFill=initRandomFill())(view);var PATH_FS={resolve:(...args)=>{var resolvedPath="",resolvedAbsolute=false;for(var i=args.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?args[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{assert(typeof str==="string",`stringToUTF8Array expects a string (got ${typeof str})`);if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;if(u>1114111)warnOnce("Invalid Unicode code point "+ptrToString(u)+" encountered when serializing a JS string to a UTF-8 string in wasm memory! (Valid unicode code points should be in range 0-0x10FFFF).");heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var FS_stdin_getChar=()=>{if(!FS_stdin_getChar_buffer.length){var result=null;if(ENVIRONMENT_IS_NODE){var BUFSIZE=256;var buf=Buffer.alloc(BUFSIZE);var bytesRead=0;var fd=process.stdin.fd;try{bytesRead=fs.readSync(fd,buf,0,BUFSIZE)}catch(e){if(e.toString().includes("EOF"))bytesRead=0;else throw e}if(bytesRead>0){result=buf.slice(0,bytesRead).toString("utf-8")}}else if(typeof window!="undefined"&&typeof window.prompt=="function"){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else{}if(!result){return null}FS_stdin_getChar_buffer=intArrayFromString(result,true)}return FS_stdin_getChar_buffer.shift()};var TTY={ttys:[],init(){},shutdown(){},register(dev,ops){TTY.ttys[dev]={input:[],output:[],ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close(stream){stream.tty.ops.fsync(stream.tty)},fsync(stream){stream.tty.ops.fsync(stream.tty)},read(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output));tty.output=[]}},ioctl_tcgets(tty){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(tty,optional_actions,data){return 0},ioctl_tiocgwinsz(tty){return[24,80]}},default_tty1_ops:{put_char(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output));tty.output=[]}}}};var zeroMemory=(address,size)=>{HEAPU8.fill(0,address,address+size)};var alignMemory=(size,alignment)=>{assert(alignment,"alignment argument is required");return Math.ceil(size/alignment)*alignment};var mmapAlloc=size=>{size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(ptr)zeroMemory(ptr,size);return ptr};var MEMFS={ops_table:null,mount(mount){return MEMFS.createNode(null,"/",16384|511,0)},createNode(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}MEMFS.ops_table||={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}};var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup(parent,name){throw FS.genericErrors[44]},mknod(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp},unlink(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir(node){var entries=[".",".."];for(var key of Object.keys(node.contents)){entries.push(key)}return entries},symlink(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);assert(size>=0);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i0||position+length{var dep=!noRunDep?getUniqueRunDependency(`al ${url}`):"";readAsync(url).then(arrayBuffer=>{assert(arrayBuffer,`Loading data file "${url}" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},err=>{if(onerror){onerror()}else{throw`Loading data file "${url}" failed.`}});if(dep)addRunDependency(dep)};var FS_createDataFile=(parent,name,fileData,canRead,canWrite,canOwn)=>{FS.createDataFile(parent,name,fileData,canRead,canWrite,canOwn)};var preloadPlugins=Module["preloadPlugins"]||[];var FS_handledByPreloadPlugin=(byteArray,fullname,finish,onerror)=>{if(typeof Browser!="undefined")Browser.init();var handled=false;preloadPlugins.forEach(plugin=>{if(handled)return;if(plugin["canHandle"](fullname)){plugin["handle"](byteArray,fullname,finish,onerror);handled=true}});return handled};var FS_createPreloadedFile=(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish)=>{var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){preFinish?.();if(!dontCreateFile){FS_createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}onload?.();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{onerror?.();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url=="string"){asyncLoad(url,processData,onerror)}else{processData(url)}};var FS_modeStringToFlags=str=>{var flagModes={r:0,"r+":2,w:512|64|1,"w+":512|64|2,a:1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags};var FS_getMode=(canRead,canWrite)=>{var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode};var strError=errno=>UTF8ToString(_strerror(errno));var ERRNO_CODES={EPERM:63,ENOENT:44,ESRCH:71,EINTR:27,EIO:29,ENXIO:60,E2BIG:1,ENOEXEC:45,EBADF:8,ECHILD:12,EAGAIN:6,EWOULDBLOCK:6,ENOMEM:48,EACCES:2,EFAULT:21,ENOTBLK:105,EBUSY:10,EEXIST:20,EXDEV:75,ENODEV:43,ENOTDIR:54,EISDIR:31,EINVAL:28,ENFILE:41,EMFILE:33,ENOTTY:59,ETXTBSY:74,EFBIG:22,ENOSPC:51,ESPIPE:70,EROFS:69,EMLINK:34,EPIPE:64,EDOM:18,ERANGE:68,ENOMSG:49,EIDRM:24,ECHRNG:106,EL2NSYNC:156,EL3HLT:107,EL3RST:108,ELNRNG:109,EUNATCH:110,ENOCSI:111,EL2HLT:112,EDEADLK:16,ENOLCK:46,EBADE:113,EBADR:114,EXFULL:115,ENOANO:104,EBADRQC:103,EBADSLT:102,EDEADLOCK:16,EBFONT:101,ENOSTR:100,ENODATA:116,ETIME:117,ENOSR:118,ENONET:119,ENOPKG:120,EREMOTE:121,ENOLINK:47,EADV:122,ESRMNT:123,ECOMM:124,EPROTO:65,EMULTIHOP:36,EDOTDOT:125,EBADMSG:9,ENOTUNIQ:126,EBADFD:127,EREMCHG:128,ELIBACC:129,ELIBBAD:130,ELIBSCN:131,ELIBMAX:132,ELIBEXEC:133,ENOSYS:52,ENOTEMPTY:55,ENAMETOOLONG:37,ELOOP:32,EOPNOTSUPP:138,EPFNOSUPPORT:139,ECONNRESET:15,ENOBUFS:42,EAFNOSUPPORT:5,EPROTOTYPE:67,ENOTSOCK:57,ENOPROTOOPT:50,ESHUTDOWN:140,ECONNREFUSED:14,EADDRINUSE:3,ECONNABORTED:13,ENETUNREACH:40,ENETDOWN:38,ETIMEDOUT:73,EHOSTDOWN:142,EHOSTUNREACH:23,EINPROGRESS:26,EALREADY:7,EDESTADDRREQ:17,EMSGSIZE:35,EPROTONOSUPPORT:66,ESOCKTNOSUPPORT:137,EADDRNOTAVAIL:4,ENETRESET:39,EISCONN:30,ENOTCONN:53,ETOOMANYREFS:141,EUSERS:136,EDQUOT:19,ESTALE:72,ENOTSUP:138,ENOMEDIUM:148,EILSEQ:25,EOVERFLOW:61,ECANCELED:11,ENOTRECOVERABLE:56,EOWNERDEAD:62,ESTRPIPE:135};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,ErrnoError:class extends Error{constructor(errno){super(runtimeInitialized?strError(errno):"");this.name="ErrnoError";this.errno=errno;for(var key in ERRNO_CODES){if(ERRNO_CODES[key]===errno){this.code=key;break}}}},genericErrors:{},filesystems:null,syncFSRequests:0,readFiles:{},FSStream:class{constructor(){this.shared={}}get object(){return this.node}set object(val){this.node=val}get isRead(){return(this.flags&2097155)!==1}get isWrite(){return(this.flags&2097155)!==0}get isAppend(){return this.flags&1024}get flags(){return this.shared.flags}set flags(val){this.shared.flags=val}get position(){return this.shared.position}set position(val){this.shared.position=val}},FSNode:class{constructor(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev;this.readMode=292|73;this.writeMode=146}get read(){return(this.mode&this.readMode)===this.readMode}set read(val){val?this.mode|=this.readMode:this.mode&=~this.readMode}get write(){return(this.mode&this.writeMode)===this.writeMode}set write(val){val?this.mode|=this.writeMode:this.mode&=~this.writeMode}get isFolder(){return FS.isDir(this.mode)}get isDevice(){return FS.isChrdev(this.mode)}},lookupPath(path,opts={}){path=PATH_FS.resolve(path);if(!path)return{path:"",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath(node){var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!=="/"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName(parentid,name){var hash=0;for(var i=0;i>>0)%FS.nameTable.length},hashAddNode(node){var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode(node){var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode(parent,name){var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode(parent,name,mode,rdev){assert(typeof parent=="object");var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode(node){FS.hashRemoveNode(node)},isRoot(node){return node===node.parent},isMountpoint(node){return!!node.mounted},isFile(mode){return(mode&61440)===32768},isDir(mode){return(mode&61440)===16384},isLink(mode){return(mode&61440)===40960},isChrdev(mode){return(mode&61440)===8192},isBlkdev(mode){return(mode&61440)===24576},isFIFO(mode){return(mode&61440)===4096},isSocket(mode){return(mode&49152)===49152},flagsToPermissionString(flag){var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions(node,perms){if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}else if(perms.includes("w")&&!(node.mode&146)){return 2}else if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup(dir){if(!FS.isDir(dir.mode))return 54;var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate(dir,name){try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete(dir,name,isdir){var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen(node,flags){if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!=="r"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd(){for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStreamChecked(fd){var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}return stream},getStream:fd=>FS.streams[fd],createStream(stream,fd=-1){assert(fd>=-1);stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream(fd){FS.streams[fd]=null},dupStream(origStream,fd=-1){var stream=FS.createStream(origStream,fd);stream.stream_ops?.dup?.(stream);return stream},chrdev_stream_ops:{open(stream){var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;stream.stream_ops.open?.(stream)},llseek(){throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice(dev,ops){FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts(mount){var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push(...m.mounts)}return mounts},syncfs(populate,callback){if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){assert(FS.syncFSRequests>0);FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount(type,opts,mountpoint){if(typeof type=="string"){throw type}var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type,opts,mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount(mountpoint){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);assert(idx!==-1);node.mount.mounts.splice(idx,1)},lookup(parent,name){return parent.node_ops.lookup(parent,name)},mknod(path,mode,dev){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name==="."||name===".."){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create(path,mode){mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir(path,mode){mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree(path,mode){var dirs=path.split("/");var d="";for(var i=0;i=0);if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write(stream,buffer,offset,length,position,canOwn){assert(offset>=0);if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate(stream,offset,length){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap(stream,length,position,prot,flags){if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}if(!length){throw new FS.ErrnoError(28)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync(stream,buffer,offset,length,mmapFlags){assert(offset>=0);if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},ioctl(stream,cmd,arg){if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile(path,opts={}){opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){throw new Error(`Invalid encoding type "${opts.encoding}"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){ret=UTF8ArrayToString(buf)}else if(opts.encoding==="binary"){ret=buf}FS.close(stream);return ret},writeFile(path,data,opts={}){opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir(path){var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories(){FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices(){FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories(){FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount(){var node=FS.createNode(proc_self,"fd",16384|511,73);node.node_ops={lookup(parent,name){var fd=+name;var stream=FS.getStreamChecked(fd);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},"/proc/self/fd")},createStandardStreams(input,output,error){if(input){FS.createDevice("/dev","stdin",input)}else{FS.symlink("/dev/tty","/dev/stdin")}if(output){FS.createDevice("/dev","stdout",null,output)}else{FS.symlink("/dev/tty","/dev/stdout")}if(error){FS.createDevice("/dev","stderr",null,error)}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1);assert(stdin.fd===0,`invalid handle for stdin (${stdin.fd})`);assert(stdout.fd===1,`invalid handle for stdout (${stdout.fd})`);assert(stderr.fd===2,`invalid handle for stderr (${stderr.fd})`)},staticInit(){[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack=""});FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={MEMFS}},init(input,output,error){assert(!FS.initialized,"FS.init was previously called. If you want to initialize later with custom parameters, remove any earlier calls (note that one is automatically added to the generated code)");FS.initialized=true;input??=Module["stdin"];output??=Module["stdout"];error??=Module["stderr"];FS.createStandardStreams(input,output,error)},quit(){FS.initialized=false;_fflush(0);for(var i=0;ithis.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]}setDataGetter(getter){this.getter=getter}cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)throw new Error("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")throw new Error("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true}get length(){if(!this.lengthKnown){this.cacheLength()}return this._length}get chunkSize(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}if(typeof XMLHttpRequest!="undefined"){if(!ENVIRONMENT_IS_WORKER)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var lazyArray=new LazyUint8Array;var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=(...args)=>{FS.forceLoadFile(node);return fn(...args)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);assert(size>=0);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr,allocated:true}};node.stream_ops=stream_ops;return node},absolutePath(){abort("FS.absolutePath has been removed; use PATH_FS.resolve instead")},createFolder(){abort("FS.createFolder has been removed; use FS.mkdir instead")},createLink(){abort("FS.createLink has been removed; use FS.symlink instead")},joinPath(){abort("FS.joinPath has been removed; use PATH.join instead")},mmapAlloc(){abort("FS.mmapAlloc has been replaced by the top level function mmapAlloc")},standardizePath(){abort("FS.standardizePath has been removed; use PATH.normalize instead")}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat(func,path,buf){var stat=func(path);HEAP32[buf>>2]=stat.dev;checkInt32(stat.dev);HEAP32[buf+4>>2]=stat.mode;checkInt32(stat.mode);HEAPU32[buf+8>>2]=stat.nlink;checkInt32(stat.nlink);HEAP32[buf+12>>2]=stat.uid;checkInt32(stat.uid);HEAP32[buf+16>>2]=stat.gid;checkInt32(stat.gid);HEAP32[buf+20>>2]=stat.rdev;checkInt32(stat.rdev);tempI64=[stat.size>>>0,(tempDouble=stat.size,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+24>>2]=tempI64[0],HEAP32[buf+28>>2]=tempI64[1];checkInt64(stat.size);HEAP32[buf+32>>2]=4096;checkInt32(4096);HEAP32[buf+36>>2]=stat.blocks;checkInt32(stat.blocks);var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();tempI64=[Math.floor(atime/1e3)>>>0,(tempDouble=Math.floor(atime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+40>>2]=tempI64[0],HEAP32[buf+44>>2]=tempI64[1];checkInt64(Math.floor(atime/1e3));HEAPU32[buf+48>>2]=atime%1e3*1e3*1e3;checkInt32(atime%1e3*1e3*1e3);tempI64=[Math.floor(mtime/1e3)>>>0,(tempDouble=Math.floor(mtime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+56>>2]=tempI64[0],HEAP32[buf+60>>2]=tempI64[1];checkInt64(Math.floor(mtime/1e3));HEAPU32[buf+64>>2]=mtime%1e3*1e3*1e3;checkInt32(mtime%1e3*1e3*1e3);tempI64=[Math.floor(ctime/1e3)>>>0,(tempDouble=Math.floor(ctime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+72>>2]=tempI64[0],HEAP32[buf+76>>2]=tempI64[1];checkInt64(Math.floor(ctime/1e3));HEAPU32[buf+80>>2]=ctime%1e3*1e3*1e3;checkInt32(ctime%1e3*1e3*1e3);tempI64=[stat.ino>>>0,(tempDouble=stat.ino,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+88>>2]=tempI64[0],HEAP32[buf+92>>2]=tempI64[1];checkInt64(stat.ino);return 0},doMsync(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},getStreamFromFD(fd){var stream=FS.getStreamChecked(fd);return stream},varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);assert(flags===0||flags==512);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function syscallGetVarargI(){assert(SYSCALLS.varargs!=undefined);var ret=HEAP32[+SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret}var syscallGetVarargP=syscallGetVarargI;function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=syscallGetVarargI();if(arg<0){return-28}while(FS.streams[arg]){arg++}var newStream;newStream=FS.dupStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=syscallGetVarargI();stream.flags|=arg;return 0}case 12:{var arg=syscallGetVarargP();var offset=0;HEAP16[arg+offset>>1]=2;checkInt16(2);return 0}case 13:case 14:return 0}return-28}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var stringToUTF8=(str,outPtr,maxBytesToWrite)=>{assert(typeof maxBytesToWrite=="number","stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!");return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)};function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);stream.getdents||=FS.readdir(stream.path);var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx>>0,(tempDouble=id,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[dirp+pos>>2]=tempI64[0],HEAP32[dirp+pos+4>>2]=tempI64[1];checkInt64(id);tempI64=[(idx+1)*struct_size>>>0,(tempDouble=(idx+1)*struct_size,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[dirp+pos+8>>2]=tempI64[0],HEAP32[dirp+pos+12>>2]=tempI64[1];checkInt64((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;checkInt16(280);HEAP8[dirp+pos+18]=type;checkInt8(type);stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:{if(!stream.tty)return-59;return 0}case 21505:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcgets){var termios=stream.tty.ops.ioctl_tcgets(stream);var argp=syscallGetVarargP();HEAP32[argp>>2]=termios.c_iflag||0;checkInt32(termios.c_iflag||0);HEAP32[argp+4>>2]=termios.c_oflag||0;checkInt32(termios.c_oflag||0);HEAP32[argp+8>>2]=termios.c_cflag||0;checkInt32(termios.c_cflag||0);HEAP32[argp+12>>2]=termios.c_lflag||0;checkInt32(termios.c_lflag||0);for(var i=0;i<32;i++){HEAP8[argp+i+17]=termios.c_cc[i]||0;checkInt8(termios.c_cc[i]||0)}return 0}return 0}case 21510:case 21511:case 21512:{if(!stream.tty)return-59;return 0}case 21506:case 21507:case 21508:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcsets){var argp=syscallGetVarargP();var c_iflag=HEAP32[argp>>2];var c_oflag=HEAP32[argp+4>>2];var c_cflag=HEAP32[argp+8>>2];var c_lflag=HEAP32[argp+12>>2];var c_cc=[];for(var i=0;i<32;i++){c_cc.push(HEAP8[argp+i+17])}return stream.tty.ops.ioctl_tcsets(stream.tty,op,{c_iflag,c_oflag,c_cflag,c_lflag,c_cc})}return 0}case 21519:{if(!stream.tty)return-59;var argp=syscallGetVarargP();HEAP32[argp>>2]=0;checkInt32(0);return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=syscallGetVarargP();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tiocgwinsz){var winsize=stream.tty.ops.ioctl_tiocgwinsz(stream.tty);var argp=syscallGetVarargP();HEAP16[argp>>1]=winsize[0];checkInt16(winsize[0]);HEAP16[argp+2>>1]=winsize[1];checkInt16(winsize[1])}return 0}case 21524:{if(!stream.tty)return-59;return 0}case 21515:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]==="/")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;assert(!flags,`unknown flags in __syscall_newfstatat: ${flags}`);path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?syscallGetVarargI():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort("Invalid flags passed to unlinkat")}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __abort_js=()=>{abort("native code called abort()")};var nowIsMonotonic=1;var __emscripten_get_now_is_monotonic=()=>nowIsMonotonic;var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var __emscripten_runtime_keepalive_clear=()=>{noExitRuntime=false;runtimeKeepaliveCounter=0};var __emscripten_throw_longjmp=()=>{throw Infinity};var convertI32PairToI53Checked=(lo,hi)=>{assert(lo==lo>>>0||lo==(lo|0));assert(hi===(hi|0));return hi+2097152>>>0<4194305-!!lo?(lo>>>0)+hi*4294967296:NaN};function __mmap_js(len,prot,flags,fd,offset_low,offset_high,allocated,addr){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,offset,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;checkInt32(res.allocated);HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset_low,offset_high){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __tzset_js=(timezone,daylight,std_name,dst_name)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;checkInt32(stdTimezoneOffset*60);HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);checkInt32(Number(winterOffset!=summerOffset));var extractZone=timezoneOffset=>{var sign=timezoneOffset>=0?"-":"+";var absOffset=Math.abs(timezoneOffset);var hours=String(Math.floor(absOffset/60)).padStart(2,"0");var minutes=String(absOffset%60).padStart(2,"0");return`UTC${sign}${hours}${minutes}`};var winterName=extractZone(winterOffset);var summerName=extractZone(summerOffset);assert(winterName);assert(summerName);assert(lengthBytesUTF8(winterName)<=16,`timezone name truncated to fit in TZNAME_MAX (${winterName})`);assert(lengthBytesUTF8(summerName)<=16,`timezone name truncated to fit in TZNAME_MAX (${summerName})`);if(summerOffsetDate.now();var getHeapMax=()=>2147483648;var _emscripten_get_heap_max=()=>getHeapMax();var _emscripten_get_now=()=>performance.now();var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){err(`growMemory: Attempted to grow heap from ${b.byteLength} bytes to ${size} bytes, but got error: ${e}`)}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;assert(requestedSize>oldSize);var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){err(`Cannot enlarge memory, requested ${requestedSize} bytes, but the limit is ${maxHeapSize} bytes!`);return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var t0=_emscripten_get_now();var replacement=growMemory(newSize);var t1=_emscripten_get_now();dbg(`Heap resize call from ${oldSize} to ${newSize} took ${t1-t0} msecs. Success: ${!!replacement}`);if(replacement){return true}}err(`Failed to grow the heap from ${oldSize} bytes to ${newSize} bytes, not enough memory!`);return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:lang,_:getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var stringToAscii=(str,buffer)=>{for(var i=0;i{var bufSize=0;getEnvStrings().forEach((string,i)=>{var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;checkInt32(ptr);stringToAscii(string,ptr);bufSize+=string.length+1});return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;checkInt32(strings.length);var bufSize=0;strings.forEach(string=>bufSize+=string.length+1);HEAPU32[penviron_buf_size>>2]=bufSize;checkInt32(bufSize);return 0};function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf]=type;checkInt8(type);HEAP16[pbuf+2>>1]=flags;checkInt16(flags);tempI64=[rightsBase>>>0,(tempDouble=rightsBase,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[pbuf+8>>2]=tempI64[0],HEAP32[pbuf+12>>2]=tempI64[1];checkInt64(rightsBase);tempI64=[rightsInheriting>>>0,(tempDouble=rightsInheriting,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[pbuf+16>>2]=tempI64[0],HEAP32[pbuf+20>>2]=tempI64[1];checkInt64(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doReadv=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;checkInt32(num);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_seek(fd,offset_low,offset_high,whence,newOffset){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);tempI64=[stream.position>>>0,(tempDouble=stream.position,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[newOffset>>2]=tempI64[0],HEAP32[newOffset+4>>2]=tempI64[1];checkInt64(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doWritev=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;checkInt32(num);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var _getentropy=(buffer,size)=>{randomFill(HEAPU8.subarray(buffer,buffer+size));return 0};var _llvm_eh_typeid_for=type=>type;var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var getCFunc=ident=>{var func=Module["_"+ident];assert(func,"Cannot call unknown function "+ident+", make sure it is exported");return func};var writeArrayToMemory=(array,buffer)=>{assert(array.length>=0,"writeArrayToMemory array must have a length (should be an array or typed array)");HEAP8.set(array,buffer)};var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;assert(returnType!=="array",'Return type should not be "array".');if(args){for(var i=0;i(...args)=>ccall(ident,returnType,argTypes,args,opts);FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();function checkIncomingModuleAPI(){ignoredModuleProp("fetchSettings")}var wasmImports={__assert_fail:___assert_fail,__call_sighandler:___call_sighandler,__cxa_begin_catch:___cxa_begin_catch,__cxa_end_catch:___cxa_end_catch,__cxa_find_matching_catch_2:___cxa_find_matching_catch_2,__cxa_find_matching_catch_3:___cxa_find_matching_catch_3,__cxa_rethrow:___cxa_rethrow,__cxa_throw:___cxa_throw,__handle_stack_overflow:___handle_stack_overflow,__resumeException:___resumeException,__syscall_faccessat:___syscall_faccessat,__syscall_fcntl64:___syscall_fcntl64,__syscall_fstat64:___syscall_fstat64,__syscall_getdents64:___syscall_getdents64,__syscall_ioctl:___syscall_ioctl,__syscall_lstat64:___syscall_lstat64,__syscall_mkdirat:___syscall_mkdirat,__syscall_newfstatat:___syscall_newfstatat,__syscall_openat:___syscall_openat,__syscall_rmdir:___syscall_rmdir,__syscall_stat64:___syscall_stat64,__syscall_unlinkat:___syscall_unlinkat,_abort_js:__abort_js,_emscripten_get_now_is_monotonic:__emscripten_get_now_is_monotonic,_emscripten_memcpy_js:__emscripten_memcpy_js,_emscripten_runtime_keepalive_clear:__emscripten_runtime_keepalive_clear,_emscripten_throw_longjmp:__emscripten_throw_longjmp,_mmap_js:__mmap_js,_munmap_js:__munmap_js,_tzset_js:__tzset_js,emscripten_date_now:_emscripten_date_now,emscripten_get_heap_max:_emscripten_get_heap_max,emscripten_get_now:_emscripten_get_now,emscripten_resize_heap:_emscripten_resize_heap,environ_get:_environ_get,environ_sizes_get:_environ_sizes_get,fd_close:_fd_close,fd_fdstat_get:_fd_fdstat_get,fd_read:_fd_read,fd_seek:_fd_seek,fd_write:_fd_write,getentropy:_getentropy,invoke_d,invoke_diii,invoke_fi,invoke_fif,invoke_fifff,invoke_fii,invoke_fiii,invoke_fiiii,invoke_fiiiiii,invoke_fiiiiiii,invoke_fij,invoke_i,invoke_idiii,invoke_ii,invoke_iif,invoke_iifi,invoke_iifii,invoke_iifiiiii,invoke_iii,invoke_iiid,invoke_iiidii,invoke_iiif,invoke_iiiffi,invoke_iiii,invoke_iiiidi,invoke_iiiiffifffffffi,invoke_iiiifiii,invoke_iiiii,invoke_iiiiif,invoke_iiiiifi,invoke_iiiiii,invoke_iiiiiifi,invoke_iiiiiififiiiififiiiiiiiiiiiiiiiiiiii,invoke_iiiiiii,invoke_iiiiiiifii,invoke_iiiiiiii,invoke_iiiiiiiii,invoke_iiiiiiiiiffi,invoke_iiiiiiiiii,invoke_iiiiiiiiiii,invoke_iiiiiiiiiiii,invoke_iiiiiiiiiiiif,invoke_iiiiiiiiiiiiiii,invoke_iiiiiiij,invoke_iiiiiij,invoke_iiiiij,invoke_iiiij,invoke_iiij,invoke_iiiji,invoke_iiijii,invoke_iiijiii,invoke_iij,invoke_iijiii,invoke_iijj,invoke_iijji,invoke_iijjiii,invoke_ij,invoke_ijjiii,invoke_j,invoke_ji,invoke_jii,invoke_jiii,invoke_jiji,invoke_v,invoke_vdiii,invoke_vi,invoke_vid,invoke_vif,invoke_vififiif,invoke_vifii,invoke_vifiiifiiiiiiiiiiiiifiiii,invoke_vii,invoke_viid,invoke_viif,invoke_viiff,invoke_viifi,invoke_viifiii,invoke_viii,invoke_viiid,invoke_viiif,invoke_viiiffii,invoke_viiiffiiii,invoke_viiifi,invoke_viiii,invoke_viiiif,invoke_viiiii,invoke_viiiiif,invoke_viiiiii,invoke_viiiiiid,invoke_viiiiiif,invoke_viiiiiifi,invoke_viiiiiifif,invoke_viiiiiifii,invoke_viiiiiii,invoke_viiiiiiidiiii,invoke_viiiiiiii,invoke_viiiiiiiif,invoke_viiiiiiiii,invoke_viiiiiiiiii,invoke_viiiiiiiiiidii,invoke_viiiiiiiiiii,invoke_viiiiiiiiiiii,invoke_viiiiiiiiiiiiii,invoke_viiiiiiiiiiiiiiii,invoke_viiiiij,invoke_viiiiiji,invoke_viiiiji,invoke_viiiijiiifi,invoke_viiij,invoke_viiiji,invoke_viiijii,invoke_viiijjji,invoke_viij,invoke_viiji,invoke_viijii,invoke_viijj,invoke_vij,invoke_viji,invoke_vijif,invoke_vijii,invoke_vijj,invoke_vjjiii,llvm_eh_typeid_for:_llvm_eh_typeid_for,proc_exit:_proc_exit};var wasmExports=createWasm();var ___wasm_call_ctors=createExportWrapper("__wasm_call_ctors",0);var _web_engine_create=Module["_web_engine_create"]=createExportWrapper("web_engine_create",0);var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=createExportWrapper("web_engine_get_memory_stats",1);var _web_engine_destroy=Module["_web_engine_destroy"]=createExportWrapper("web_engine_destroy",1);var _fflush=createExportWrapper("fflush",1);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=createExportWrapper("web_engine_get_live_handles",0);var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=createExportWrapper("web_engine_get_allocated_bytes",0);var _web_engine_open_blend=Module["_web_engine_open_blend"]=createExportWrapper("web_engine_open_blend",3);var _web_engine_apply_command=Module["_web_engine_apply_command"]=createExportWrapper("web_engine_apply_command",3);var _web_engine_decimate_apply=Module["_web_engine_decimate_apply"]=createExportWrapper("web_engine_decimate_apply",35);var _web_engine_undo=Module["_web_engine_undo"]=createExportWrapper("web_engine_undo",1);var _web_engine_redo=Module["_web_engine_redo"]=createExportWrapper("web_engine_redo",1);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=createExportWrapper("web_engine_get_scene_snapshot",3);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=createExportWrapper("web_engine_get_scene_metadata",3);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=createExportWrapper("web_engine_get_scene_geometry",3);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=createExportWrapper("web_engine_get_scene_delta",3);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=createExportWrapper("web_engine_get_packed_asset",5);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=createExportWrapper("web_engine_evaluate_depsgraph",3);var _web_engine_save_blend=Module["_web_engine_save_blend"]=createExportWrapper("web_engine_save_blend",3);var _malloc=Module["_malloc"]=createExportWrapper("malloc",1);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=createExportWrapper("web_engine_free_buffer",1);var _free=Module["_free"]=createExportWrapper("free",1);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=createExportWrapper("web_engine_last_error_code",0);var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=createExportWrapper("web_engine_last_error_message",0);var _strerror=createExportWrapper("strerror",1);var _emscripten_builtin_memalign=createExportWrapper("emscripten_builtin_memalign",2);var _setThrew=createExportWrapper("setThrew",2);var __emscripten_tempret_set=createExportWrapper("_emscripten_tempret_set",1);var __emscripten_tempret_get=createExportWrapper("_emscripten_tempret_get",0);var _emscripten_stack_init=()=>(_emscripten_stack_init=wasmExports["emscripten_stack_init"])();var _emscripten_stack_get_free=()=>(_emscripten_stack_get_free=wasmExports["emscripten_stack_get_free"])();var _emscripten_stack_get_base=()=>(_emscripten_stack_get_base=wasmExports["emscripten_stack_get_base"])();var _emscripten_stack_get_end=()=>(_emscripten_stack_get_end=wasmExports["emscripten_stack_get_end"])();var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["_emscripten_stack_restore"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["_emscripten_stack_alloc"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["emscripten_stack_get_current"])();var ___cxa_decrement_exception_refcount=createExportWrapper("__cxa_decrement_exception_refcount",1);var ___cxa_increment_exception_refcount=createExportWrapper("__cxa_increment_exception_refcount",1);var ___cxa_can_catch=createExportWrapper("__cxa_can_catch",3);var ___cxa_get_exception_ptr=createExportWrapper("__cxa_get_exception_ptr",1);var ___set_stack_limits=Module["___set_stack_limits"]=createExportWrapper("__set_stack_limits",2);var dynCall_viij=Module["dynCall_viij"]=createExportWrapper("dynCall_viij",5);var dynCall_viiji=Module["dynCall_viiji"]=createExportWrapper("dynCall_viiji",6);var dynCall_viijii=Module["dynCall_viijii"]=createExportWrapper("dynCall_viijii",7);var dynCall_vij=Module["dynCall_vij"]=createExportWrapper("dynCall_vij",4);var dynCall_viiiijiiifi=Module["dynCall_viiiijiiifi"]=createExportWrapper("dynCall_viiiijiiifi",12);var dynCall_viiijjji=Module["dynCall_viiijjji"]=createExportWrapper("dynCall_viiijjji",11);var dynCall_viiiiji=Module["dynCall_viiiiji"]=createExportWrapper("dynCall_viiiiji",8);var dynCall_viiiji=Module["dynCall_viiiji"]=createExportWrapper("dynCall_viiiji",7);var dynCall_ij=Module["dynCall_ij"]=createExportWrapper("dynCall_ij",3);var dynCall_viji=Module["dynCall_viji"]=createExportWrapper("dynCall_viji",5);var dynCall_iij=Module["dynCall_iij"]=createExportWrapper("dynCall_iij",4);var dynCall_fij=Module["dynCall_fij"]=createExportWrapper("dynCall_fij",4);var dynCall_vijf=Module["dynCall_vijf"]=createExportWrapper("dynCall_vijf",5);var dynCall_jiii=Module["dynCall_jiii"]=createExportWrapper("dynCall_jiii",4);var dynCall_viiij=Module["dynCall_viiij"]=createExportWrapper("dynCall_viiij",6);var dynCall_iiijii=Module["dynCall_iiijii"]=createExportWrapper("dynCall_iiijii",7);var dynCall_iiijiii=Module["dynCall_iiijiii"]=createExportWrapper("dynCall_iiijiii",8);var dynCall_iiij=Module["dynCall_iiij"]=createExportWrapper("dynCall_iiij",5);var dynCall_vjjiii=Module["dynCall_vjjiii"]=createExportWrapper("dynCall_vjjiii",8);var dynCall_ijjiii=Module["dynCall_ijjiii"]=createExportWrapper("dynCall_ijjiii",8);var dynCall_iijiii=Module["dynCall_iijiii"]=createExportWrapper("dynCall_iijiii",7);var dynCall_iijjiii=Module["dynCall_iijjiii"]=createExportWrapper("dynCall_iijjiii",9);var dynCall_iiiji=Module["dynCall_iiiji"]=createExportWrapper("dynCall_iiiji",6);var dynCall_jii=Module["dynCall_jii"]=createExportWrapper("dynCall_jii",3);var dynCall_iijj=Module["dynCall_iijj"]=createExportWrapper("dynCall_iijj",6);var dynCall_ji=Module["dynCall_ji"]=createExportWrapper("dynCall_ji",2);var dynCall_vijii=Module["dynCall_vijii"]=createExportWrapper("dynCall_vijii",6);var dynCall_vijif=Module["dynCall_vijif"]=createExportWrapper("dynCall_vijif",6);var dynCall_viiiiij=Module["dynCall_viiiiij"]=createExportWrapper("dynCall_viiiiij",8);var dynCall_viiiiiji=Module["dynCall_viiiiiji"]=createExportWrapper("dynCall_viiiiiji",9);var dynCall_iiiij=Module["dynCall_iiiij"]=createExportWrapper("dynCall_iiiij",6);var dynCall_vijj=Module["dynCall_vijj"]=createExportWrapper("dynCall_vijj",6);var dynCall_iijji=Module["dynCall_iijji"]=createExportWrapper("dynCall_iijji",7);var dynCall_j=Module["dynCall_j"]=createExportWrapper("dynCall_j",1);var dynCall_iiiiiiij=Module["dynCall_iiiiiiij"]=createExportWrapper("dynCall_iiiiiiij",9);var dynCall_viijj=Module["dynCall_viijj"]=createExportWrapper("dynCall_viijj",7);var dynCall_iiiiij=Module["dynCall_iiiiij"]=createExportWrapper("dynCall_iiiiij",7);var dynCall_viiijii=Module["dynCall_viiijii"]=createExportWrapper("dynCall_viiijii",8);var dynCall_jij=Module["dynCall_jij"]=createExportWrapper("dynCall_jij",4);var dynCall_vijji=Module["dynCall_vijji"]=createExportWrapper("dynCall_vijji",7);var dynCall_iiiiiij=Module["dynCall_iiiiiij"]=createExportWrapper("dynCall_iiiiiij",8);var dynCall_jiji=Module["dynCall_jiji"]=createExportWrapper("dynCall_jiji",5);var dynCall_iiiiijj=Module["dynCall_iiiiijj"]=createExportWrapper("dynCall_iiiiijj",9);var dynCall_iiiiiijj=Module["dynCall_iiiiiijj"]=createExportWrapper("dynCall_iiiiiijj",10);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vif(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fi(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiif(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_diii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiffi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiififiiiififiiiiiiiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vififiif(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vifii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viif(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viifi(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiif(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fif(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifi(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vdiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_idiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viifiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiif(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiifiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_d(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fifff(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiifi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiifi(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vid(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiff(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iif(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiffiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiif(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vifiiifiiiiiiiiiiiiifiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiiif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiid(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiidiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiidii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiidi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viid(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiidii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiid(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiffii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiid(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viij(index,a1,a2,a3,a4){var sp=stackSave();try{dynCall_viij(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2,a3){var sp=stackSave();try{return dynCall_iij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ij(index,a1,a2){var sp=stackSave();try{return dynCall_ij(index,a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fij(index,a1,a2,a3){var sp=stackSave();try{return dynCall_fij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiji(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiji(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viijii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viijii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vij(index,a1,a2,a3){var sp=stackSave();try{dynCall_vij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiijiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{dynCall_viiiijiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiijjji(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{dynCall_viiijjji(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiji(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiiiji(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiji(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viiiji(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiijii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iiijii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiijiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_iiijiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiij(index,a1,a2,a3,a4){var sp=stackSave();try{return dynCall_iiij(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viji(index,a1,a2,a3,a4){var sp=stackSave();try{dynCall_viji(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijii(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiji(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iiiji(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iijiii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijjiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return dynCall_iijjiii(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vjjiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_vjjiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ijjiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_ijjiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iijj(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiij(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiiiij(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijif(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiji(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{dynCall_viiiiiji(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijji(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iijji(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijj(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viijj(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viijj(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ji(index,a1){var sp=stackSave();try{return dynCall_ji(index,a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiij(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iiiiij(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iiiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_j(index){var sp=stackSave();try{return dynCall_j(index)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiij(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return dynCall_iiiiiiij(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiijii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiijii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jii(index,a1,a2){var sp=stackSave();try{return dynCall_jii(index,a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiij(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_iiiiiij(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiji(index,a1,a2,a3,a4){var sp=stackSave();try{return dynCall_jiji(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["UTF8ToString"]=UTF8ToString;var missingLibrarySymbols=["writeI53ToI64","writeI53ToI64Clamped","writeI53ToI64Signaling","writeI53ToU64Clamped","writeI53ToU64Signaling","readI53FromI64","readI53FromU64","convertI32PairToI53","convertU32PairToI53","getTempRet0","exitJS","inetPton4","inetNtop4","inetPton6","inetNtop6","readSockaddr","writeSockaddr","emscriptenLog","readEmAsmArgs","jstoi_q","listenOnce","autoResumeAudioContext","dynCallLegacy","getDynCaller","dynCall","handleException","runtimeKeepalivePush","runtimeKeepalivePop","callUserCallback","maybeExit","asmjsMangle","HandleAllocator","getNativeTypeSize","STACK_SIZE","STACK_ALIGN","POINTER_SIZE","ASSERTIONS","uleb128Encode","sigToWasmTypes","generateFuncType","convertJsFunctionToWasm","getEmptyTableSlot","updateTableMap","getFunctionAddress","addFunction","removeFunction","reallyNegative","unSign","strLen","reSign","formatString","intArrayToString","AsciiToString","UTF16ToString","stringToUTF16","lengthBytesUTF16","UTF32ToString","stringToUTF32","lengthBytesUTF32","stringToNewUTF8","registerKeyEventCallback","maybeCStringToJsString","findEventTarget","getBoundingClientRect","fillMouseEventData","registerMouseEventCallback","registerWheelEventCallback","registerUiEventCallback","registerFocusEventCallback","fillDeviceOrientationEventData","registerDeviceOrientationEventCallback","fillDeviceMotionEventData","registerDeviceMotionEventCallback","screenOrientation","fillOrientationChangeEventData","registerOrientationChangeEventCallback","fillFullscreenChangeEventData","registerFullscreenChangeEventCallback","JSEvents_requestFullscreen","JSEvents_resizeCanvasForFullscreen","registerRestoreOldStyle","hideEverythingExceptGivenElement","restoreHiddenElements","setLetterbox","softFullscreenResizeWebGLRenderTarget","doRequestFullscreen","fillPointerlockChangeEventData","registerPointerlockChangeEventCallback","registerPointerlockErrorEventCallback","requestPointerLock","fillVisibilityChangeEventData","registerVisibilityChangeEventCallback","registerTouchEventCallback","fillGamepadEventData","registerGamepadEventCallback","registerBeforeUnloadEventCallback","fillBatteryEventData","battery","registerBatteryEventCallback","setCanvasElementSize","getCanvasElementSize","jsStackTrace","getCallstack","convertPCtoSourceLocation","checkWasiClock","wasiRightsToMuslOFlags","wasiOFlagsToMuslOFlags","createDyncallWrapper","safeSetTimeout","setImmediateWrapped","clearImmediateWrapped","polyfillSetImmediate","registerPostMainLoop","registerPreMainLoop","getPromise","makePromise","idsToPromises","makePromiseCallback","Browser_asyncPrepareDataCounter","safeRequestAnimationFrame","isLeapYear","ydayFromDate","arraySum","addDays","getSocketFromFD","getSocketAddress","FS_unlink","FS_mkdirTree","_setNetworkCallback","heapObjectForWebGLType","toTypedArrayIndex","webgl_enable_ANGLE_instanced_arrays","webgl_enable_OES_vertex_array_object","webgl_enable_WEBGL_draw_buffers","webgl_enable_WEBGL_multi_draw","webgl_enable_EXT_polygon_offset_clamp","webgl_enable_EXT_clip_control","webgl_enable_WEBGL_polygon_mode","emscriptenWebGLGet","computeUnpackAlignedImageSize","colorChannelsInGlTextureFormat","emscriptenWebGLGetTexPixelData","emscriptenWebGLGetUniform","webglGetUniformLocation","webglPrepareUniformLocationsBeforeFirstUse","webglGetLeftBracePos","emscriptenWebGLGetVertexAttrib","__glGetActiveAttribOrUniform","writeGLArray","registerWebGlEventCallback","runAndAbortIfError","ALLOC_NORMAL","ALLOC_STACK","allocate","writeStringToMemory","writeAsciiToMemory","setErrNo","demangle","stackTrace"];missingLibrarySymbols.forEach(missingLibrarySymbol);var unexportedSymbols=["run","addOnPreRun","addOnInit","addOnPreMain","addOnExit","addOnPostRun","addRunDependency","removeRunDependency","out","err","callMain","abort","wasmMemory","wasmExports","writeStackCookie","checkStackCookie","convertI32PairToI53Checked","stackSave","stackRestore","stackAlloc","setTempRet0","ptrToString","zeroMemory","getHeapMax","growMemory","ENV","setStackLimits","ERRNO_CODES","strError","DNS","Protocols","Sockets","initRandomFill","randomFill","timers","warnOnce","readEmAsmArgsArray","jstoi_s","getExecutableName","keepRuntimeAlive","asyncLoad","alignMemory","mmapAlloc","wasmTable","noExitRuntime","getCFunc","freeTableIndexes","functionsInTableMap","setValue","getValue","PATH","PATH_FS","UTF8Decoder","UTF8ArrayToString","stringToUTF8Array","stringToUTF8","lengthBytesUTF8","intArrayFromString","stringToAscii","UTF16Decoder","stringToUTF8OnStack","writeArrayToMemory","JSEvents","specialHTMLTargets","findCanvasEventTarget","currentFullscreenStrategy","restoreOldWindowedStyle","UNWIND_CACHE","ExitStatus","getEnvStrings","doReadv","doWritev","promiseMap","uncaughtExceptionCount","exceptionLast","exceptionCaught","ExceptionInfo","findMatchingCatch","Browser","getPreloadedImageData__data","wget","MONTH_DAYS_REGULAR","MONTH_DAYS_LEAP","MONTH_DAYS_REGULAR_CUMULATIVE","MONTH_DAYS_LEAP_CUMULATIVE","SYSCALLS","preloadPlugins","FS_createPreloadedFile","FS_modeStringToFlags","FS_getMode","FS_stdin_getChar_buffer","FS_stdin_getChar","FS_createPath","FS_createDevice","FS_readFile","FS","FS_createDataFile","FS_createLazyFile","MEMFS","TTY","PIPEFS","SOCKFS","tempFixedLengthArray","miniTempWebGLFloatBuffers","miniTempWebGLIntBuffers","GL","AL","GLUT","EGL","GLEW","IDBStore","SDL","SDL_gfx","allocateUTF8","allocateUTF8OnStack","print","printErr"];unexportedSymbols.forEach(unexportedRuntimeSymbol);var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function stackCheckInit(){_emscripten_stack_init();writeStackCookie()}function run(){if(runDependencies>0){return}stackCheckInit();if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();assert(!Module["_main"],'compiled without a main, but one is present. if you added it from JS, use Module["onRuntimeInitialized"]');postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}checkStackCookie()}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise;for(const prop of Object.keys(Module)){if(!(prop in moduleArg)){Object.defineProperty(moduleArg,prop,{configurable:true,get(){abort(`Access to module property ('${prop}') is no longer possible via the module constructor argument; Instead, use the result of the module constructor.`)}})}} +var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});["_malloc","_free","_web_engine_create","_web_engine_destroy","_web_engine_get_memory_stats","_web_engine_get_live_handles","_web_engine_get_allocated_bytes","_web_engine_open_blend","_web_engine_apply_command","_web_engine_undo","_web_engine_redo","_web_engine_get_scene_snapshot","_web_engine_get_scene_metadata","_web_engine_get_scene_geometry","_web_engine_get_scene_delta","_web_engine_get_packed_asset","_web_engine_evaluate_depsgraph","_web_engine_save_blend","_web_engine_free_buffer","_web_engine_last_error_code","_web_engine_last_error_message","_web_engine_decimate_apply","_memory","___indirect_function_table","___set_stack_limits","onRuntimeInitialized"].forEach(prop=>{if(!Object.getOwnPropertyDescriptor(readyPromise,prop)){Object.defineProperty(readyPromise,prop,{get:()=>abort("You are getting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js"),set:()=>abort("You are setting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js")})}});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");let dirname=import.meta.url;if(dirname.startsWith("data:")){dirname="/"}var require=createRequire(dirname)}var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){if(typeof process=="undefined"||!process.release||process.release.name!=="node")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");var nodeVersion=process.versions.node;var numericVersion=nodeVersion.split(".").slice(0,3);numericVersion=numericVersion[0]*1e4+numericVersion[1]*100+numericVersion[2].split("-")[0]*1;if(numericVersion<16e4){throw new Error("This emscripten-generated code requires node v16.0.0 (detected v"+nodeVersion+")")}var fs=require("fs");var nodePath=require("path");if(!import.meta.url.startsWith("data:")){scriptDirectory=nodePath.dirname(require("url").fileURLToPath(import.meta.url))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);var ret=fs.readFileSync(filename);assert(ret.buffer);return ret};readAsync=(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return new Promise((resolve,reject)=>{fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)reject(err);else resolve(binary?data.buffer:data)})})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_SHELL){if(typeof process=="object"&&typeof require==="function"||typeof window=="object"||typeof importScripts=="function")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)")}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}if(!(typeof window=="object"||typeof importScripts=="function"))throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>{assert(!isFileURI(url),"readAsync does not work with file:// URLs");return fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}}else{throw new Error("environment detection error")}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;checkIncomingModuleAPI();if(Module["arguments"])arguments_=Module["arguments"];legacyModuleProp("arguments","arguments_");if(Module["thisProgram"])thisProgram=Module["thisProgram"];legacyModuleProp("thisProgram","thisProgram");assert(typeof Module["memoryInitializerPrefixURL"]=="undefined","Module.memoryInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["pthreadMainPrefixURL"]=="undefined","Module.pthreadMainPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["cdInitializerPrefixURL"]=="undefined","Module.cdInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["filePackagePrefixURL"]=="undefined","Module.filePackagePrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["read"]=="undefined","Module.read option was removed");assert(typeof Module["readAsync"]=="undefined","Module.readAsync option was removed (modify readAsync in JS)");assert(typeof Module["readBinary"]=="undefined","Module.readBinary option was removed (modify readBinary in JS)");assert(typeof Module["setWindowTitle"]=="undefined","Module.setWindowTitle option was removed (modify emscripten_set_window_title in JS)");assert(typeof Module["TOTAL_MEMORY"]=="undefined","Module.TOTAL_MEMORY has been renamed Module.INITIAL_MEMORY");legacyModuleProp("asm","wasmExports");legacyModuleProp("readAsync","readAsync");legacyModuleProp("readBinary","readBinary");legacyModuleProp("setWindowTitle","setWindowTitle");assert(!ENVIRONMENT_IS_SHELL,"shell environment detected but not enabled at build time. Add `shell` to `-sENVIRONMENT` to enable.");var wasmBinary=Module["wasmBinary"];legacyModuleProp("wasmBinary","wasmBinary");if(typeof WebAssembly!="object"){err("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort("Assertion failed"+(text?": "+text:""))}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}assert(!Module["STACK_SIZE"],"STACK_SIZE can no longer be set at runtime. Use -sSTACK_SIZE at link time");assert(typeof Int32Array!="undefined"&&typeof Float64Array!=="undefined"&&Int32Array.prototype.subarray!=undefined&&Int32Array.prototype.set!=undefined,"JS engine does not provide full typed array support");assert(!Module["wasmMemory"],"Use of `wasmMemory` detected. Use -sIMPORTED_MEMORY to define wasmMemory externally");assert(!Module["INITIAL_MEMORY"],"Detected runtime INITIAL_MEMORY setting. Use -sIMPORTED_MEMORY to define wasmMemory dynamically");function writeStackCookie(){var max=_emscripten_stack_get_end();assert((max&3)==0);if(max==0){max+=4}HEAPU32[max>>2]=34821223;checkInt32(34821223);HEAPU32[max+4>>2]=2310721022;checkInt32(2310721022);HEAPU32[0>>2]=1668509029;checkInt32(1668509029)}function checkStackCookie(){if(ABORT)return;var max=_emscripten_stack_get_end();if(max==0){max+=4}var cookie1=HEAPU32[max>>2];var cookie2=HEAPU32[max+4>>2];if(cookie1!=34821223||cookie2!=2310721022){abort(`Stack overflow! Stack cookie has been overwritten at ${ptrToString(max)}, expected hex dwords 0x89BACDFE and 0x2135467, but received ${ptrToString(cookie2)} ${ptrToString(cookie1)}`)}if(HEAPU32[0>>2]!=1668509029){abort("Runtime error: The application has corrupted its heap memory area (address zero)!")}}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){assert(!runtimeInitialized);runtimeInitialized=true;checkStackCookie();setStackLimits();if(!Module["noFSInit"]&&!FS.initialized)FS.init();FS.ignorePermissions=false;TTY.init();callRuntimeCallbacks(__ATINIT__)}function postRun(){checkStackCookie();var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}assert(Math.imul,"This browser does not support Math.imul(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.fround,"This browser does not support Math.fround(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.clz32,"This browser does not support Math.clz32(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.trunc,"This browser does not support Math.trunc(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;var runDependencyTracking={};function getUniqueRunDependency(id){var orig=id;while(1){if(!runDependencyTracking[id])return id;id=orig+Math.random()}}function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies);if(id){assert(!runDependencyTracking[id]);runDependencyTracking[id]=1;if(runDependencyWatcher===null&&typeof setInterval!="undefined"){runDependencyWatcher=setInterval(()=>{if(ABORT){clearInterval(runDependencyWatcher);runDependencyWatcher=null;return}var shown=false;for(var dep in runDependencyTracking){if(!shown){shown=true;err("still waiting on run dependencies:")}err(`dependency: ${dep}`)}if(shown){err("(end of list)")}},1e4)}}else{err("warning: run dependency added without ID")}}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(id){assert(runDependencyTracking[id]);delete runDependencyTracking[id]}else{err("warning: run dependency removed without ID")}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);var isFileURI=filename=>filename.startsWith("file://");function createExportWrapper(name,nargs){return(...args)=>{assert(runtimeInitialized,`native function \`${name}\` called before runtime initialization`);var f=wasmExports[name];assert(f,`exported native function \`${name}\` not found`);assert(args.length<=nargs,`native function \`${name}\` called with ${args.length} args but expects ${nargs}`);return f(...args)}}function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);if(isFileURI(wasmBinaryFile)){err(`warning: Loading from a file URI (${wasmBinaryFile}) is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing`)}abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&!ENVIRONMENT_IS_NODE&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["memory"];assert(wasmMemory,"memory not found in wasm exports");updateMemoryViews();wasmTable=wasmExports["__indirect_function_table"];assert(wasmTable,"table not found in wasm exports");addOnInit(wasmExports["__wasm_call_ctors"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");var trueModule=Module;function receiveInstantiationResult(result){assert(Module===trueModule,"the Module object should not be replaced during async compilation - perhaps the order of HTML elements is wrong?");trueModule=null;receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var tempDouble;var tempI64;(()=>{var h16=new Int16Array(1);var h8=new Int8Array(h16.buffer);h16[0]=25459;if(h8[0]!==115||h8[1]!==99)throw"Runtime error: expected the system to be little-endian! (Run with -sSUPPORT_BIG_ENDIAN to bypass)"})();if(Module["ENVIRONMENT"]){throw new Error("Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option (for example, -sENVIRONMENT=web or -sENVIRONMENT=node)")}function legacyModuleProp(prop,newName,incoming=true){if(!Object.getOwnPropertyDescriptor(Module,prop)){Object.defineProperty(Module,prop,{configurable:true,get(){let extra=incoming?" (the initial value can be provided on Module, but after startup the value is only looked for on a local variable of that name)":"";abort(`\`Module.${prop}\` has been replaced by \`${newName}\``+extra)}})}}function ignoredModuleProp(prop){if(Object.getOwnPropertyDescriptor(Module,prop)){abort(`\`Module.${prop}\` was supplied but \`${prop}\` not included in INCOMING_MODULE_JS_API`)}}function isExportedByForceFilesystem(name){return name==="FS_createPath"||name==="FS_createDataFile"||name==="FS_createPreloadedFile"||name==="FS_unlink"||name==="addRunDependency"||name==="FS_createLazyFile"||name==="FS_createDevice"||name==="removeRunDependency"}function hookGlobalSymbolAccess(sym,func){if(typeof globalThis!="undefined"&&!Object.getOwnPropertyDescriptor(globalThis,sym)){Object.defineProperty(globalThis,sym,{configurable:true,get(){func();return undefined}})}}function missingGlobal(sym,msg){hookGlobalSymbolAccess(sym,()=>{warnOnce(`\`${sym}\` is not longer defined by emscripten. ${msg}`)})}missingGlobal("buffer","Please use HEAP8.buffer or wasmMemory.buffer");missingGlobal("asm","Please use wasmExports instead");function missingLibrarySymbol(sym){hookGlobalSymbolAccess(sym,()=>{var msg=`\`${sym}\` is a library symbol and not included by default; add it to your library.js __deps or to DEFAULT_LIBRARY_FUNCS_TO_INCLUDE on the command line`;var librarySymbol=sym;if(!librarySymbol.startsWith("_")){librarySymbol="$"+sym}msg+=` (e.g. -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE='${librarySymbol}')`;if(isExportedByForceFilesystem(sym)){msg+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you"}warnOnce(msg)});unexportedRuntimeSymbol(sym)}function unexportedRuntimeSymbol(sym){if(!Object.getOwnPropertyDescriptor(Module,sym)){Object.defineProperty(Module,sym,{configurable:true,get(){var msg=`'${sym}' was not exported. add it to EXPORTED_RUNTIME_METHODS (see the Emscripten FAQ)`;if(isExportedByForceFilesystem(sym)){msg+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you"}abort(msg)}})}}var MAX_UINT8=2**8-1;var MAX_UINT16=2**16-1;var MAX_UINT32=2**32-1;var MAX_UINT53=2**53-1;var MAX_UINT64=2**64-1;var MIN_INT8=-(2**(8-1));var MIN_INT16=-(2**(16-1));var MIN_INT32=-(2**(32-1));var MIN_INT53=-(2**(53-1));var MIN_INT64=-(2**(64-1));function checkInt(value,bits,min,max){assert(Number.isInteger(Number(value)),`attempt to write non-integer (${value}) into integer heap`);assert(value<=max,`value (${value}) too large to write as ${bits}-bit value`);assert(value>=min,`value (${value}) too small to write as ${bits}-bit value`)}var checkInt8=value=>checkInt(value,8,MIN_INT8,MAX_UINT8);var checkInt16=value=>checkInt(value,16,MIN_INT16,MAX_UINT16);var checkInt32=value=>checkInt(value,32,MIN_INT32,MAX_UINT32);var checkInt64=value=>checkInt(value,64,MIN_INT64,MAX_UINT64);function dbg(...args){console.warn(...args)}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var ptrToString=ptr=>{assert(typeof ptr==="number");ptr>>>=0;return"0x"+ptr.toString(16).padStart(8,"0")};var setStackLimits=()=>{var stackLow=_emscripten_stack_get_base();var stackHigh=_emscripten_stack_get_end();___set_stack_limits(stackLow,stackHigh)};var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var warnOnce=text=>{warnOnce.shown||={};if(!warnOnce.shown[text]){warnOnce.shown[text]=1;if(ENVIRONMENT_IS_NODE)text="warning: "+text;err(text)}};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>{assert(typeof ptr=="number",`UTF8ToString expects a number (got ${typeof ptr})`);return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""};var ___assert_fail=(condition,filename,line,func)=>{abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])};var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}assert(wasmTable.get(funcPtr)==func,"JavaScript-side Wasm function table mirror is out of date!");return func};var ___call_sighandler=(fp,sig)=>getWasmTableEntry(fp)(sig);var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);assert(exceptionCaught.length>0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught;checkInt8(caught)}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown;checkInt8(rethrown)}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var ___handle_stack_overflow=requested=>{var base=_emscripten_stack_get_base();var end=_emscripten_stack_get_end();abort(`stack overflow (Attempt to set SP to ${ptrToString(requested)}`+`, with stack limits [${ptrToString(end)} - ${ptrToString(base)}`+"]). If you require more stack space build with -sSTACK_SIZE=")};var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path==="/")return"/";path=PATH.normalize(path);path=path.replace(/\/$/,"");var lastSlash=path.lastIndexOf("/");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:(...paths)=>PATH.normalize(paths.join("/")),join2:(l,r)=>PATH.normalize(l+"/"+r)};var initRandomFill=()=>{if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else if(ENVIRONMENT_IS_NODE){try{var crypto_module=require("crypto");var randomFillSync=crypto_module["randomFillSync"];if(randomFillSync){return view=>crypto_module["randomFillSync"](view)}var randomBytes=crypto_module["randomBytes"];return view=>(view.set(randomBytes(view.byteLength)),view)}catch(e){}}abort("no cryptographic support found for randomDevice. consider polyfilling it if you want to use something insecure like Math.random(), e.g. put this in a --pre-js: var crypto = { getRandomValues: (array) => { for (var i = 0; i < array.length; i++) array[i] = (Math.random()*256)|0 } };")};var randomFill=view=>(randomFill=initRandomFill())(view);var PATH_FS={resolve:(...args)=>{var resolvedPath="",resolvedAbsolute=false;for(var i=args.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?args[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{assert(typeof str==="string",`stringToUTF8Array expects a string (got ${typeof str})`);if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;if(u>1114111)warnOnce("Invalid Unicode code point "+ptrToString(u)+" encountered when serializing a JS string to a UTF-8 string in wasm memory! (Valid unicode code points should be in range 0-0x10FFFF).");heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var FS_stdin_getChar=()=>{if(!FS_stdin_getChar_buffer.length){var result=null;if(ENVIRONMENT_IS_NODE){var BUFSIZE=256;var buf=Buffer.alloc(BUFSIZE);var bytesRead=0;var fd=process.stdin.fd;try{bytesRead=fs.readSync(fd,buf,0,BUFSIZE)}catch(e){if(e.toString().includes("EOF"))bytesRead=0;else throw e}if(bytesRead>0){result=buf.slice(0,bytesRead).toString("utf-8")}}else if(typeof window!="undefined"&&typeof window.prompt=="function"){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else{}if(!result){return null}FS_stdin_getChar_buffer=intArrayFromString(result,true)}return FS_stdin_getChar_buffer.shift()};var TTY={ttys:[],init(){},shutdown(){},register(dev,ops){TTY.ttys[dev]={input:[],output:[],ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close(stream){stream.tty.ops.fsync(stream.tty)},fsync(stream){stream.tty.ops.fsync(stream.tty)},read(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output));tty.output=[]}},ioctl_tcgets(tty){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(tty,optional_actions,data){return 0},ioctl_tiocgwinsz(tty){return[24,80]}},default_tty1_ops:{put_char(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output));tty.output=[]}}}};var zeroMemory=(address,size)=>{HEAPU8.fill(0,address,address+size)};var alignMemory=(size,alignment)=>{assert(alignment,"alignment argument is required");return Math.ceil(size/alignment)*alignment};var mmapAlloc=size=>{size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(ptr)zeroMemory(ptr,size);return ptr};var MEMFS={ops_table:null,mount(mount){return MEMFS.createNode(null,"/",16384|511,0)},createNode(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}MEMFS.ops_table||={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}};var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup(parent,name){throw FS.genericErrors[44]},mknod(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp},unlink(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir(node){var entries=[".",".."];for(var key of Object.keys(node.contents)){entries.push(key)}return entries},symlink(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);assert(size>=0);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i0||position+length{var dep=!noRunDep?getUniqueRunDependency(`al ${url}`):"";readAsync(url).then(arrayBuffer=>{assert(arrayBuffer,`Loading data file "${url}" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},err=>{if(onerror){onerror()}else{throw`Loading data file "${url}" failed.`}});if(dep)addRunDependency(dep)};var FS_createDataFile=(parent,name,fileData,canRead,canWrite,canOwn)=>{FS.createDataFile(parent,name,fileData,canRead,canWrite,canOwn)};var preloadPlugins=Module["preloadPlugins"]||[];var FS_handledByPreloadPlugin=(byteArray,fullname,finish,onerror)=>{if(typeof Browser!="undefined")Browser.init();var handled=false;preloadPlugins.forEach(plugin=>{if(handled)return;if(plugin["canHandle"](fullname)){plugin["handle"](byteArray,fullname,finish,onerror);handled=true}});return handled};var FS_createPreloadedFile=(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish)=>{var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){preFinish?.();if(!dontCreateFile){FS_createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}onload?.();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{onerror?.();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url=="string"){asyncLoad(url,processData,onerror)}else{processData(url)}};var FS_modeStringToFlags=str=>{var flagModes={r:0,"r+":2,w:512|64|1,"w+":512|64|2,a:1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags};var FS_getMode=(canRead,canWrite)=>{var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode};var strError=errno=>UTF8ToString(_strerror(errno));var ERRNO_CODES={EPERM:63,ENOENT:44,ESRCH:71,EINTR:27,EIO:29,ENXIO:60,E2BIG:1,ENOEXEC:45,EBADF:8,ECHILD:12,EAGAIN:6,EWOULDBLOCK:6,ENOMEM:48,EACCES:2,EFAULT:21,ENOTBLK:105,EBUSY:10,EEXIST:20,EXDEV:75,ENODEV:43,ENOTDIR:54,EISDIR:31,EINVAL:28,ENFILE:41,EMFILE:33,ENOTTY:59,ETXTBSY:74,EFBIG:22,ENOSPC:51,ESPIPE:70,EROFS:69,EMLINK:34,EPIPE:64,EDOM:18,ERANGE:68,ENOMSG:49,EIDRM:24,ECHRNG:106,EL2NSYNC:156,EL3HLT:107,EL3RST:108,ELNRNG:109,EUNATCH:110,ENOCSI:111,EL2HLT:112,EDEADLK:16,ENOLCK:46,EBADE:113,EBADR:114,EXFULL:115,ENOANO:104,EBADRQC:103,EBADSLT:102,EDEADLOCK:16,EBFONT:101,ENOSTR:100,ENODATA:116,ETIME:117,ENOSR:118,ENONET:119,ENOPKG:120,EREMOTE:121,ENOLINK:47,EADV:122,ESRMNT:123,ECOMM:124,EPROTO:65,EMULTIHOP:36,EDOTDOT:125,EBADMSG:9,ENOTUNIQ:126,EBADFD:127,EREMCHG:128,ELIBACC:129,ELIBBAD:130,ELIBSCN:131,ELIBMAX:132,ELIBEXEC:133,ENOSYS:52,ENOTEMPTY:55,ENAMETOOLONG:37,ELOOP:32,EOPNOTSUPP:138,EPFNOSUPPORT:139,ECONNRESET:15,ENOBUFS:42,EAFNOSUPPORT:5,EPROTOTYPE:67,ENOTSOCK:57,ENOPROTOOPT:50,ESHUTDOWN:140,ECONNREFUSED:14,EADDRINUSE:3,ECONNABORTED:13,ENETUNREACH:40,ENETDOWN:38,ETIMEDOUT:73,EHOSTDOWN:142,EHOSTUNREACH:23,EINPROGRESS:26,EALREADY:7,EDESTADDRREQ:17,EMSGSIZE:35,EPROTONOSUPPORT:66,ESOCKTNOSUPPORT:137,EADDRNOTAVAIL:4,ENETRESET:39,EISCONN:30,ENOTCONN:53,ETOOMANYREFS:141,EUSERS:136,EDQUOT:19,ESTALE:72,ENOTSUP:138,ENOMEDIUM:148,EILSEQ:25,EOVERFLOW:61,ECANCELED:11,ENOTRECOVERABLE:56,EOWNERDEAD:62,ESTRPIPE:135};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,ErrnoError:class extends Error{constructor(errno){super(runtimeInitialized?strError(errno):"");this.name="ErrnoError";this.errno=errno;for(var key in ERRNO_CODES){if(ERRNO_CODES[key]===errno){this.code=key;break}}}},genericErrors:{},filesystems:null,syncFSRequests:0,readFiles:{},FSStream:class{constructor(){this.shared={}}get object(){return this.node}set object(val){this.node=val}get isRead(){return(this.flags&2097155)!==1}get isWrite(){return(this.flags&2097155)!==0}get isAppend(){return this.flags&1024}get flags(){return this.shared.flags}set flags(val){this.shared.flags=val}get position(){return this.shared.position}set position(val){this.shared.position=val}},FSNode:class{constructor(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev;this.readMode=292|73;this.writeMode=146}get read(){return(this.mode&this.readMode)===this.readMode}set read(val){val?this.mode|=this.readMode:this.mode&=~this.readMode}get write(){return(this.mode&this.writeMode)===this.writeMode}set write(val){val?this.mode|=this.writeMode:this.mode&=~this.writeMode}get isFolder(){return FS.isDir(this.mode)}get isDevice(){return FS.isChrdev(this.mode)}},lookupPath(path,opts={}){path=PATH_FS.resolve(path);if(!path)return{path:"",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath(node){var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!=="/"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName(parentid,name){var hash=0;for(var i=0;i>>0)%FS.nameTable.length},hashAddNode(node){var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode(node){var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode(parent,name){var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode(parent,name,mode,rdev){assert(typeof parent=="object");var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode(node){FS.hashRemoveNode(node)},isRoot(node){return node===node.parent},isMountpoint(node){return!!node.mounted},isFile(mode){return(mode&61440)===32768},isDir(mode){return(mode&61440)===16384},isLink(mode){return(mode&61440)===40960},isChrdev(mode){return(mode&61440)===8192},isBlkdev(mode){return(mode&61440)===24576},isFIFO(mode){return(mode&61440)===4096},isSocket(mode){return(mode&49152)===49152},flagsToPermissionString(flag){var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions(node,perms){if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}else if(perms.includes("w")&&!(node.mode&146)){return 2}else if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup(dir){if(!FS.isDir(dir.mode))return 54;var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate(dir,name){try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete(dir,name,isdir){var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen(node,flags){if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!=="r"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd(){for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStreamChecked(fd){var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}return stream},getStream:fd=>FS.streams[fd],createStream(stream,fd=-1){assert(fd>=-1);stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream(fd){FS.streams[fd]=null},dupStream(origStream,fd=-1){var stream=FS.createStream(origStream,fd);stream.stream_ops?.dup?.(stream);return stream},chrdev_stream_ops:{open(stream){var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;stream.stream_ops.open?.(stream)},llseek(){throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice(dev,ops){FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts(mount){var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push(...m.mounts)}return mounts},syncfs(populate,callback){if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){assert(FS.syncFSRequests>0);FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount(type,opts,mountpoint){if(typeof type=="string"){throw type}var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type,opts,mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount(mountpoint){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);assert(idx!==-1);node.mount.mounts.splice(idx,1)},lookup(parent,name){return parent.node_ops.lookup(parent,name)},mknod(path,mode,dev){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name==="."||name===".."){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create(path,mode){mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir(path,mode){mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree(path,mode){var dirs=path.split("/");var d="";for(var i=0;i=0);if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write(stream,buffer,offset,length,position,canOwn){assert(offset>=0);if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate(stream,offset,length){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap(stream,length,position,prot,flags){if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}if(!length){throw new FS.ErrnoError(28)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync(stream,buffer,offset,length,mmapFlags){assert(offset>=0);if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},ioctl(stream,cmd,arg){if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile(path,opts={}){opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){throw new Error(`Invalid encoding type "${opts.encoding}"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){ret=UTF8ArrayToString(buf)}else if(opts.encoding==="binary"){ret=buf}FS.close(stream);return ret},writeFile(path,data,opts={}){opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir(path){var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories(){FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices(){FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories(){FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount(){var node=FS.createNode(proc_self,"fd",16384|511,73);node.node_ops={lookup(parent,name){var fd=+name;var stream=FS.getStreamChecked(fd);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},"/proc/self/fd")},createStandardStreams(input,output,error){if(input){FS.createDevice("/dev","stdin",input)}else{FS.symlink("/dev/tty","/dev/stdin")}if(output){FS.createDevice("/dev","stdout",null,output)}else{FS.symlink("/dev/tty","/dev/stdout")}if(error){FS.createDevice("/dev","stderr",null,error)}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1);assert(stdin.fd===0,`invalid handle for stdin (${stdin.fd})`);assert(stdout.fd===1,`invalid handle for stdout (${stdout.fd})`);assert(stderr.fd===2,`invalid handle for stderr (${stderr.fd})`)},staticInit(){[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack=""});FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={MEMFS}},init(input,output,error){assert(!FS.initialized,"FS.init was previously called. If you want to initialize later with custom parameters, remove any earlier calls (note that one is automatically added to the generated code)");FS.initialized=true;input??=Module["stdin"];output??=Module["stdout"];error??=Module["stderr"];FS.createStandardStreams(input,output,error)},quit(){FS.initialized=false;_fflush(0);for(var i=0;ithis.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]}setDataGetter(getter){this.getter=getter}cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)throw new Error("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")throw new Error("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true}get length(){if(!this.lengthKnown){this.cacheLength()}return this._length}get chunkSize(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}if(typeof XMLHttpRequest!="undefined"){if(!ENVIRONMENT_IS_WORKER)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var lazyArray=new LazyUint8Array;var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=(...args)=>{FS.forceLoadFile(node);return fn(...args)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);assert(size>=0);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr,allocated:true}};node.stream_ops=stream_ops;return node},absolutePath(){abort("FS.absolutePath has been removed; use PATH_FS.resolve instead")},createFolder(){abort("FS.createFolder has been removed; use FS.mkdir instead")},createLink(){abort("FS.createLink has been removed; use FS.symlink instead")},joinPath(){abort("FS.joinPath has been removed; use PATH.join instead")},mmapAlloc(){abort("FS.mmapAlloc has been replaced by the top level function mmapAlloc")},standardizePath(){abort("FS.standardizePath has been removed; use PATH.normalize instead")}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat(func,path,buf){var stat=func(path);HEAP32[buf>>2]=stat.dev;checkInt32(stat.dev);HEAP32[buf+4>>2]=stat.mode;checkInt32(stat.mode);HEAPU32[buf+8>>2]=stat.nlink;checkInt32(stat.nlink);HEAP32[buf+12>>2]=stat.uid;checkInt32(stat.uid);HEAP32[buf+16>>2]=stat.gid;checkInt32(stat.gid);HEAP32[buf+20>>2]=stat.rdev;checkInt32(stat.rdev);tempI64=[stat.size>>>0,(tempDouble=stat.size,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+24>>2]=tempI64[0],HEAP32[buf+28>>2]=tempI64[1];checkInt64(stat.size);HEAP32[buf+32>>2]=4096;checkInt32(4096);HEAP32[buf+36>>2]=stat.blocks;checkInt32(stat.blocks);var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();tempI64=[Math.floor(atime/1e3)>>>0,(tempDouble=Math.floor(atime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+40>>2]=tempI64[0],HEAP32[buf+44>>2]=tempI64[1];checkInt64(Math.floor(atime/1e3));HEAPU32[buf+48>>2]=atime%1e3*1e3*1e3;checkInt32(atime%1e3*1e3*1e3);tempI64=[Math.floor(mtime/1e3)>>>0,(tempDouble=Math.floor(mtime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+56>>2]=tempI64[0],HEAP32[buf+60>>2]=tempI64[1];checkInt64(Math.floor(mtime/1e3));HEAPU32[buf+64>>2]=mtime%1e3*1e3*1e3;checkInt32(mtime%1e3*1e3*1e3);tempI64=[Math.floor(ctime/1e3)>>>0,(tempDouble=Math.floor(ctime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+72>>2]=tempI64[0],HEAP32[buf+76>>2]=tempI64[1];checkInt64(Math.floor(ctime/1e3));HEAPU32[buf+80>>2]=ctime%1e3*1e3*1e3;checkInt32(ctime%1e3*1e3*1e3);tempI64=[stat.ino>>>0,(tempDouble=stat.ino,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+88>>2]=tempI64[0],HEAP32[buf+92>>2]=tempI64[1];checkInt64(stat.ino);return 0},doMsync(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},getStreamFromFD(fd){var stream=FS.getStreamChecked(fd);return stream},varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);assert(flags===0||flags==512);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function syscallGetVarargI(){assert(SYSCALLS.varargs!=undefined);var ret=HEAP32[+SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret}var syscallGetVarargP=syscallGetVarargI;function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=syscallGetVarargI();if(arg<0){return-28}while(FS.streams[arg]){arg++}var newStream;newStream=FS.dupStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=syscallGetVarargI();stream.flags|=arg;return 0}case 12:{var arg=syscallGetVarargP();var offset=0;HEAP16[arg+offset>>1]=2;checkInt16(2);return 0}case 13:case 14:return 0}return-28}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var stringToUTF8=(str,outPtr,maxBytesToWrite)=>{assert(typeof maxBytesToWrite=="number","stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!");return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)};function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);stream.getdents||=FS.readdir(stream.path);var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx>>0,(tempDouble=id,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[dirp+pos>>2]=tempI64[0],HEAP32[dirp+pos+4>>2]=tempI64[1];checkInt64(id);tempI64=[(idx+1)*struct_size>>>0,(tempDouble=(idx+1)*struct_size,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[dirp+pos+8>>2]=tempI64[0],HEAP32[dirp+pos+12>>2]=tempI64[1];checkInt64((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;checkInt16(280);HEAP8[dirp+pos+18]=type;checkInt8(type);stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:{if(!stream.tty)return-59;return 0}case 21505:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcgets){var termios=stream.tty.ops.ioctl_tcgets(stream);var argp=syscallGetVarargP();HEAP32[argp>>2]=termios.c_iflag||0;checkInt32(termios.c_iflag||0);HEAP32[argp+4>>2]=termios.c_oflag||0;checkInt32(termios.c_oflag||0);HEAP32[argp+8>>2]=termios.c_cflag||0;checkInt32(termios.c_cflag||0);HEAP32[argp+12>>2]=termios.c_lflag||0;checkInt32(termios.c_lflag||0);for(var i=0;i<32;i++){HEAP8[argp+i+17]=termios.c_cc[i]||0;checkInt8(termios.c_cc[i]||0)}return 0}return 0}case 21510:case 21511:case 21512:{if(!stream.tty)return-59;return 0}case 21506:case 21507:case 21508:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcsets){var argp=syscallGetVarargP();var c_iflag=HEAP32[argp>>2];var c_oflag=HEAP32[argp+4>>2];var c_cflag=HEAP32[argp+8>>2];var c_lflag=HEAP32[argp+12>>2];var c_cc=[];for(var i=0;i<32;i++){c_cc.push(HEAP8[argp+i+17])}return stream.tty.ops.ioctl_tcsets(stream.tty,op,{c_iflag,c_oflag,c_cflag,c_lflag,c_cc})}return 0}case 21519:{if(!stream.tty)return-59;var argp=syscallGetVarargP();HEAP32[argp>>2]=0;checkInt32(0);return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=syscallGetVarargP();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tiocgwinsz){var winsize=stream.tty.ops.ioctl_tiocgwinsz(stream.tty);var argp=syscallGetVarargP();HEAP16[argp>>1]=winsize[0];checkInt16(winsize[0]);HEAP16[argp+2>>1]=winsize[1];checkInt16(winsize[1])}return 0}case 21524:{if(!stream.tty)return-59;return 0}case 21515:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]==="/")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;assert(!flags,`unknown flags in __syscall_newfstatat: ${flags}`);path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?syscallGetVarargI():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort("Invalid flags passed to unlinkat")}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __abort_js=()=>{abort("native code called abort()")};var nowIsMonotonic=1;var __emscripten_get_now_is_monotonic=()=>nowIsMonotonic;var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var __emscripten_runtime_keepalive_clear=()=>{noExitRuntime=false;runtimeKeepaliveCounter=0};var __emscripten_throw_longjmp=()=>{throw Infinity};var convertI32PairToI53Checked=(lo,hi)=>{assert(lo==lo>>>0||lo==(lo|0));assert(hi===(hi|0));return hi+2097152>>>0<4194305-!!lo?(lo>>>0)+hi*4294967296:NaN};function __mmap_js(len,prot,flags,fd,offset_low,offset_high,allocated,addr){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,offset,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;checkInt32(res.allocated);HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset_low,offset_high){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __tzset_js=(timezone,daylight,std_name,dst_name)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;checkInt32(stdTimezoneOffset*60);HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);checkInt32(Number(winterOffset!=summerOffset));var extractZone=timezoneOffset=>{var sign=timezoneOffset>=0?"-":"+";var absOffset=Math.abs(timezoneOffset);var hours=String(Math.floor(absOffset/60)).padStart(2,"0");var minutes=String(absOffset%60).padStart(2,"0");return`UTC${sign}${hours}${minutes}`};var winterName=extractZone(winterOffset);var summerName=extractZone(summerOffset);assert(winterName);assert(summerName);assert(lengthBytesUTF8(winterName)<=16,`timezone name truncated to fit in TZNAME_MAX (${winterName})`);assert(lengthBytesUTF8(summerName)<=16,`timezone name truncated to fit in TZNAME_MAX (${summerName})`);if(summerOffsetDate.now();var getHeapMax=()=>2147483648;var _emscripten_get_heap_max=()=>getHeapMax();var _emscripten_get_now=()=>performance.now();var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){err(`growMemory: Attempted to grow heap from ${b.byteLength} bytes to ${size} bytes, but got error: ${e}`)}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;assert(requestedSize>oldSize);var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){err(`Cannot enlarge memory, requested ${requestedSize} bytes, but the limit is ${maxHeapSize} bytes!`);return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var t0=_emscripten_get_now();var replacement=growMemory(newSize);var t1=_emscripten_get_now();dbg(`Heap resize call from ${oldSize} to ${newSize} took ${t1-t0} msecs. Success: ${!!replacement}`);if(replacement){return true}}err(`Failed to grow the heap from ${oldSize} bytes to ${newSize} bytes, not enough memory!`);return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:lang,_:getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var stringToAscii=(str,buffer)=>{for(var i=0;i{var bufSize=0;getEnvStrings().forEach((string,i)=>{var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;checkInt32(ptr);stringToAscii(string,ptr);bufSize+=string.length+1});return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;checkInt32(strings.length);var bufSize=0;strings.forEach(string=>bufSize+=string.length+1);HEAPU32[penviron_buf_size>>2]=bufSize;checkInt32(bufSize);return 0};function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf]=type;checkInt8(type);HEAP16[pbuf+2>>1]=flags;checkInt16(flags);tempI64=[rightsBase>>>0,(tempDouble=rightsBase,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[pbuf+8>>2]=tempI64[0],HEAP32[pbuf+12>>2]=tempI64[1];checkInt64(rightsBase);tempI64=[rightsInheriting>>>0,(tempDouble=rightsInheriting,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[pbuf+16>>2]=tempI64[0],HEAP32[pbuf+20>>2]=tempI64[1];checkInt64(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doReadv=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;checkInt32(num);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_seek(fd,offset_low,offset_high,whence,newOffset){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);tempI64=[stream.position>>>0,(tempDouble=stream.position,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[newOffset>>2]=tempI64[0],HEAP32[newOffset+4>>2]=tempI64[1];checkInt64(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doWritev=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;checkInt32(num);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var _getentropy=(buffer,size)=>{randomFill(HEAPU8.subarray(buffer,buffer+size));return 0};var _llvm_eh_typeid_for=type=>type;var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var getCFunc=ident=>{var func=Module["_"+ident];assert(func,"Cannot call unknown function "+ident+", make sure it is exported");return func};var writeArrayToMemory=(array,buffer)=>{assert(array.length>=0,"writeArrayToMemory array must have a length (should be an array or typed array)");HEAP8.set(array,buffer)};var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;assert(returnType!=="array",'Return type should not be "array".');if(args){for(var i=0;i(...args)=>ccall(ident,returnType,argTypes,args,opts);FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();function checkIncomingModuleAPI(){ignoredModuleProp("fetchSettings")}var wasmImports={__assert_fail:___assert_fail,__call_sighandler:___call_sighandler,__cxa_begin_catch:___cxa_begin_catch,__cxa_end_catch:___cxa_end_catch,__cxa_find_matching_catch_2:___cxa_find_matching_catch_2,__cxa_find_matching_catch_3:___cxa_find_matching_catch_3,__cxa_rethrow:___cxa_rethrow,__cxa_throw:___cxa_throw,__handle_stack_overflow:___handle_stack_overflow,__resumeException:___resumeException,__syscall_faccessat:___syscall_faccessat,__syscall_fcntl64:___syscall_fcntl64,__syscall_fstat64:___syscall_fstat64,__syscall_getdents64:___syscall_getdents64,__syscall_ioctl:___syscall_ioctl,__syscall_lstat64:___syscall_lstat64,__syscall_mkdirat:___syscall_mkdirat,__syscall_newfstatat:___syscall_newfstatat,__syscall_openat:___syscall_openat,__syscall_rmdir:___syscall_rmdir,__syscall_stat64:___syscall_stat64,__syscall_unlinkat:___syscall_unlinkat,_abort_js:__abort_js,_emscripten_get_now_is_monotonic:__emscripten_get_now_is_monotonic,_emscripten_memcpy_js:__emscripten_memcpy_js,_emscripten_runtime_keepalive_clear:__emscripten_runtime_keepalive_clear,_emscripten_throw_longjmp:__emscripten_throw_longjmp,_mmap_js:__mmap_js,_munmap_js:__munmap_js,_tzset_js:__tzset_js,emscripten_date_now:_emscripten_date_now,emscripten_get_heap_max:_emscripten_get_heap_max,emscripten_get_now:_emscripten_get_now,emscripten_resize_heap:_emscripten_resize_heap,environ_get:_environ_get,environ_sizes_get:_environ_sizes_get,fd_close:_fd_close,fd_fdstat_get:_fd_fdstat_get,fd_read:_fd_read,fd_seek:_fd_seek,fd_write:_fd_write,getentropy:_getentropy,invoke_d,invoke_diii,invoke_fi,invoke_fif,invoke_fifff,invoke_fii,invoke_fiii,invoke_fiiii,invoke_fiiiiii,invoke_fiiiiiii,invoke_fij,invoke_i,invoke_idiii,invoke_ii,invoke_iif,invoke_iifi,invoke_iifii,invoke_iifiiiii,invoke_iii,invoke_iiid,invoke_iiidii,invoke_iiif,invoke_iiiffi,invoke_iiii,invoke_iiiidi,invoke_iiiiffifffffffi,invoke_iiiifiii,invoke_iiiii,invoke_iiiiif,invoke_iiiiifi,invoke_iiiiii,invoke_iiiiiifi,invoke_iiiiiififiiiififiiiiiiiiiiiiiiiiiiii,invoke_iiiiiii,invoke_iiiiiiifii,invoke_iiiiiiii,invoke_iiiiiiiii,invoke_iiiiiiiiiffi,invoke_iiiiiiiiii,invoke_iiiiiiiiiifi,invoke_iiiiiiiiiii,invoke_iiiiiiiiiiii,invoke_iiiiiiiiiiiif,invoke_iiiiiiiiiiiiiii,invoke_iiiiiiij,invoke_iiiiiij,invoke_iiiiij,invoke_iiiij,invoke_iiij,invoke_iiiji,invoke_iiijii,invoke_iiijiii,invoke_iij,invoke_iijiii,invoke_iijj,invoke_iijji,invoke_iijjiii,invoke_ij,invoke_ijjiii,invoke_j,invoke_ji,invoke_jii,invoke_jiii,invoke_jiji,invoke_v,invoke_vdiii,invoke_vi,invoke_vid,invoke_vif,invoke_vififiif,invoke_vifii,invoke_vifiiifiiiiiiiiiiiiifiiii,invoke_vii,invoke_viid,invoke_viif,invoke_viiff,invoke_viifi,invoke_viifiii,invoke_viii,invoke_viiid,invoke_viiif,invoke_viiiffii,invoke_viiiffiiii,invoke_viiifi,invoke_viiii,invoke_viiiif,invoke_viiiii,invoke_viiiiif,invoke_viiiiii,invoke_viiiiiid,invoke_viiiiiif,invoke_viiiiiifi,invoke_viiiiiifif,invoke_viiiiiifii,invoke_viiiiiii,invoke_viiiiiiidiiii,invoke_viiiiiiii,invoke_viiiiiiiif,invoke_viiiiiiiii,invoke_viiiiiiiiii,invoke_viiiiiiiiiidii,invoke_viiiiiiiiiii,invoke_viiiiiiiiiiii,invoke_viiiiiiiiiiiiii,invoke_viiiiiiiiiiiiiiii,invoke_viiiiij,invoke_viiiiiji,invoke_viiiiji,invoke_viiiijiiifi,invoke_viiij,invoke_viiiji,invoke_viiijii,invoke_viiijjji,invoke_viij,invoke_viiji,invoke_viijii,invoke_viijj,invoke_vij,invoke_viji,invoke_vijif,invoke_vijii,invoke_vijj,invoke_vjjiii,llvm_eh_typeid_for:_llvm_eh_typeid_for,proc_exit:_proc_exit};var wasmExports=createWasm();var ___wasm_call_ctors=createExportWrapper("__wasm_call_ctors",0);var _web_engine_create=Module["_web_engine_create"]=createExportWrapper("web_engine_create",0);var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=createExportWrapper("web_engine_get_memory_stats",1);var _web_engine_destroy=Module["_web_engine_destroy"]=createExportWrapper("web_engine_destroy",1);var _fflush=createExportWrapper("fflush",1);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=createExportWrapper("web_engine_get_live_handles",0);var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=createExportWrapper("web_engine_get_allocated_bytes",0);var _web_engine_open_blend=Module["_web_engine_open_blend"]=createExportWrapper("web_engine_open_blend",3);var _web_engine_apply_command=Module["_web_engine_apply_command"]=createExportWrapper("web_engine_apply_command",3);var _web_engine_decimate_apply=Module["_web_engine_decimate_apply"]=createExportWrapper("web_engine_decimate_apply",35);var _web_engine_undo=Module["_web_engine_undo"]=createExportWrapper("web_engine_undo",1);var _web_engine_redo=Module["_web_engine_redo"]=createExportWrapper("web_engine_redo",1);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=createExportWrapper("web_engine_get_scene_snapshot",3);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=createExportWrapper("web_engine_get_scene_metadata",3);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=createExportWrapper("web_engine_get_scene_geometry",3);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=createExportWrapper("web_engine_get_scene_delta",3);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=createExportWrapper("web_engine_get_packed_asset",5);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=createExportWrapper("web_engine_evaluate_depsgraph",3);var _web_engine_save_blend=Module["_web_engine_save_blend"]=createExportWrapper("web_engine_save_blend",3);var _malloc=Module["_malloc"]=createExportWrapper("malloc",1);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=createExportWrapper("web_engine_free_buffer",1);var _free=Module["_free"]=createExportWrapper("free",1);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=createExportWrapper("web_engine_last_error_code",0);var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=createExportWrapper("web_engine_last_error_message",0);var _strerror=createExportWrapper("strerror",1);var _emscripten_builtin_memalign=createExportWrapper("emscripten_builtin_memalign",2);var _setThrew=createExportWrapper("setThrew",2);var __emscripten_tempret_set=createExportWrapper("_emscripten_tempret_set",1);var __emscripten_tempret_get=createExportWrapper("_emscripten_tempret_get",0);var _emscripten_stack_init=()=>(_emscripten_stack_init=wasmExports["emscripten_stack_init"])();var _emscripten_stack_get_free=()=>(_emscripten_stack_get_free=wasmExports["emscripten_stack_get_free"])();var _emscripten_stack_get_base=()=>(_emscripten_stack_get_base=wasmExports["emscripten_stack_get_base"])();var _emscripten_stack_get_end=()=>(_emscripten_stack_get_end=wasmExports["emscripten_stack_get_end"])();var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["_emscripten_stack_restore"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["_emscripten_stack_alloc"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["emscripten_stack_get_current"])();var ___cxa_decrement_exception_refcount=createExportWrapper("__cxa_decrement_exception_refcount",1);var ___cxa_increment_exception_refcount=createExportWrapper("__cxa_increment_exception_refcount",1);var ___cxa_can_catch=createExportWrapper("__cxa_can_catch",3);var ___cxa_get_exception_ptr=createExportWrapper("__cxa_get_exception_ptr",1);var ___set_stack_limits=Module["___set_stack_limits"]=createExportWrapper("__set_stack_limits",2);var dynCall_viij=Module["dynCall_viij"]=createExportWrapper("dynCall_viij",5);var dynCall_viiji=Module["dynCall_viiji"]=createExportWrapper("dynCall_viiji",6);var dynCall_viijii=Module["dynCall_viijii"]=createExportWrapper("dynCall_viijii",7);var dynCall_vij=Module["dynCall_vij"]=createExportWrapper("dynCall_vij",4);var dynCall_viiiijiiifi=Module["dynCall_viiiijiiifi"]=createExportWrapper("dynCall_viiiijiiifi",12);var dynCall_viiijjji=Module["dynCall_viiijjji"]=createExportWrapper("dynCall_viiijjji",11);var dynCall_viiiiji=Module["dynCall_viiiiji"]=createExportWrapper("dynCall_viiiiji",8);var dynCall_viiiji=Module["dynCall_viiiji"]=createExportWrapper("dynCall_viiiji",7);var dynCall_ij=Module["dynCall_ij"]=createExportWrapper("dynCall_ij",3);var dynCall_viji=Module["dynCall_viji"]=createExportWrapper("dynCall_viji",5);var dynCall_iij=Module["dynCall_iij"]=createExportWrapper("dynCall_iij",4);var dynCall_fij=Module["dynCall_fij"]=createExportWrapper("dynCall_fij",4);var dynCall_vijf=Module["dynCall_vijf"]=createExportWrapper("dynCall_vijf",5);var dynCall_jiii=Module["dynCall_jiii"]=createExportWrapper("dynCall_jiii",4);var dynCall_viiij=Module["dynCall_viiij"]=createExportWrapper("dynCall_viiij",6);var dynCall_iiijii=Module["dynCall_iiijii"]=createExportWrapper("dynCall_iiijii",7);var dynCall_iiijiii=Module["dynCall_iiijiii"]=createExportWrapper("dynCall_iiijiii",8);var dynCall_iiij=Module["dynCall_iiij"]=createExportWrapper("dynCall_iiij",5);var dynCall_vjjiii=Module["dynCall_vjjiii"]=createExportWrapper("dynCall_vjjiii",8);var dynCall_ijjiii=Module["dynCall_ijjiii"]=createExportWrapper("dynCall_ijjiii",8);var dynCall_iijiii=Module["dynCall_iijiii"]=createExportWrapper("dynCall_iijiii",7);var dynCall_iijjiii=Module["dynCall_iijjiii"]=createExportWrapper("dynCall_iijjiii",9);var dynCall_iiiji=Module["dynCall_iiiji"]=createExportWrapper("dynCall_iiiji",6);var dynCall_jii=Module["dynCall_jii"]=createExportWrapper("dynCall_jii",3);var dynCall_iijj=Module["dynCall_iijj"]=createExportWrapper("dynCall_iijj",6);var dynCall_ji=Module["dynCall_ji"]=createExportWrapper("dynCall_ji",2);var dynCall_vijii=Module["dynCall_vijii"]=createExportWrapper("dynCall_vijii",6);var dynCall_vijif=Module["dynCall_vijif"]=createExportWrapper("dynCall_vijif",6);var dynCall_viiiiij=Module["dynCall_viiiiij"]=createExportWrapper("dynCall_viiiiij",8);var dynCall_viiiiiji=Module["dynCall_viiiiiji"]=createExportWrapper("dynCall_viiiiiji",9);var dynCall_iiiij=Module["dynCall_iiiij"]=createExportWrapper("dynCall_iiiij",6);var dynCall_vijj=Module["dynCall_vijj"]=createExportWrapper("dynCall_vijj",6);var dynCall_iijji=Module["dynCall_iijji"]=createExportWrapper("dynCall_iijji",7);var dynCall_j=Module["dynCall_j"]=createExportWrapper("dynCall_j",1);var dynCall_iiiiiiij=Module["dynCall_iiiiiiij"]=createExportWrapper("dynCall_iiiiiiij",9);var dynCall_viijj=Module["dynCall_viijj"]=createExportWrapper("dynCall_viijj",7);var dynCall_iiiiij=Module["dynCall_iiiiij"]=createExportWrapper("dynCall_iiiiij",7);var dynCall_viiijii=Module["dynCall_viiijii"]=createExportWrapper("dynCall_viiijii",8);var dynCall_jij=Module["dynCall_jij"]=createExportWrapper("dynCall_jij",4);var dynCall_vijji=Module["dynCall_vijji"]=createExportWrapper("dynCall_vijji",7);var dynCall_iiiiiij=Module["dynCall_iiiiiij"]=createExportWrapper("dynCall_iiiiiij",8);var dynCall_jiji=Module["dynCall_jiji"]=createExportWrapper("dynCall_jiji",5);var dynCall_iiiiijj=Module["dynCall_iiiiijj"]=createExportWrapper("dynCall_iiiiijj",9);var dynCall_iiiiiijj=Module["dynCall_iiiiiijj"]=createExportWrapper("dynCall_iiiiiijj",10);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vif(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fi(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiif(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_diii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viifiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiffi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiififiiiififiiiiiiiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vififiif(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vifii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viif(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viifi(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiif(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fif(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifi(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vdiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_idiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiif(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiifiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_d(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fifff(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiifi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiifi(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vid(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiff(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iif(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiffiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiif(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vifiiifiiiiiiiiiiiiifiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiiif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiid(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiidiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiidii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiidi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viid(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiidii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiid(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiffii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiid(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fij(index,a1,a2,a3){var sp=stackSave();try{return dynCall_fij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viij(index,a1,a2,a3,a4){var sp=stackSave();try{dynCall_viij(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2,a3){var sp=stackSave();try{return dynCall_iij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ij(index,a1,a2){var sp=stackSave();try{return dynCall_ij(index,a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vij(index,a1,a2,a3){var sp=stackSave();try{dynCall_vij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiji(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiji(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viijii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viijii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiijiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{dynCall_viiiijiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiijjji(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{dynCall_viiijjji(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiji(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiiiji(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiji(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viiiji(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiijii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iiijii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiijiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_iiijiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiij(index,a1,a2,a3,a4){var sp=stackSave();try{return dynCall_iiij(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viji(index,a1,a2,a3,a4){var sp=stackSave();try{dynCall_viji(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijii(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiji(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iiiji(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iijiii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijjiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return dynCall_iijjiii(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vjjiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_vjjiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ijjiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_ijjiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iijj(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiij(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiiiij(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijif(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiji(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{dynCall_viiiiiji(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijji(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iijji(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijj(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viijj(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viijj(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ji(index,a1){var sp=stackSave();try{return dynCall_ji(index,a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiij(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iiiiij(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iiiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_j(index){var sp=stackSave();try{return dynCall_j(index)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiij(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return dynCall_iiiiiiij(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiijii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiijii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jii(index,a1,a2){var sp=stackSave();try{return dynCall_jii(index,a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiij(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_iiiiiij(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiji(index,a1,a2,a3,a4){var sp=stackSave();try{return dynCall_jiji(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["UTF8ToString"]=UTF8ToString;var missingLibrarySymbols=["writeI53ToI64","writeI53ToI64Clamped","writeI53ToI64Signaling","writeI53ToU64Clamped","writeI53ToU64Signaling","readI53FromI64","readI53FromU64","convertI32PairToI53","convertU32PairToI53","getTempRet0","exitJS","inetPton4","inetNtop4","inetPton6","inetNtop6","readSockaddr","writeSockaddr","emscriptenLog","readEmAsmArgs","jstoi_q","listenOnce","autoResumeAudioContext","dynCallLegacy","getDynCaller","dynCall","handleException","runtimeKeepalivePush","runtimeKeepalivePop","callUserCallback","maybeExit","asmjsMangle","HandleAllocator","getNativeTypeSize","STACK_SIZE","STACK_ALIGN","POINTER_SIZE","ASSERTIONS","uleb128Encode","sigToWasmTypes","generateFuncType","convertJsFunctionToWasm","getEmptyTableSlot","updateTableMap","getFunctionAddress","addFunction","removeFunction","reallyNegative","unSign","strLen","reSign","formatString","intArrayToString","AsciiToString","UTF16ToString","stringToUTF16","lengthBytesUTF16","UTF32ToString","stringToUTF32","lengthBytesUTF32","stringToNewUTF8","registerKeyEventCallback","maybeCStringToJsString","findEventTarget","getBoundingClientRect","fillMouseEventData","registerMouseEventCallback","registerWheelEventCallback","registerUiEventCallback","registerFocusEventCallback","fillDeviceOrientationEventData","registerDeviceOrientationEventCallback","fillDeviceMotionEventData","registerDeviceMotionEventCallback","screenOrientation","fillOrientationChangeEventData","registerOrientationChangeEventCallback","fillFullscreenChangeEventData","registerFullscreenChangeEventCallback","JSEvents_requestFullscreen","JSEvents_resizeCanvasForFullscreen","registerRestoreOldStyle","hideEverythingExceptGivenElement","restoreHiddenElements","setLetterbox","softFullscreenResizeWebGLRenderTarget","doRequestFullscreen","fillPointerlockChangeEventData","registerPointerlockChangeEventCallback","registerPointerlockErrorEventCallback","requestPointerLock","fillVisibilityChangeEventData","registerVisibilityChangeEventCallback","registerTouchEventCallback","fillGamepadEventData","registerGamepadEventCallback","registerBeforeUnloadEventCallback","fillBatteryEventData","battery","registerBatteryEventCallback","setCanvasElementSize","getCanvasElementSize","jsStackTrace","getCallstack","convertPCtoSourceLocation","checkWasiClock","wasiRightsToMuslOFlags","wasiOFlagsToMuslOFlags","createDyncallWrapper","safeSetTimeout","setImmediateWrapped","clearImmediateWrapped","polyfillSetImmediate","registerPostMainLoop","registerPreMainLoop","getPromise","makePromise","idsToPromises","makePromiseCallback","Browser_asyncPrepareDataCounter","safeRequestAnimationFrame","isLeapYear","ydayFromDate","arraySum","addDays","getSocketFromFD","getSocketAddress","FS_unlink","FS_mkdirTree","_setNetworkCallback","heapObjectForWebGLType","toTypedArrayIndex","webgl_enable_ANGLE_instanced_arrays","webgl_enable_OES_vertex_array_object","webgl_enable_WEBGL_draw_buffers","webgl_enable_WEBGL_multi_draw","webgl_enable_EXT_polygon_offset_clamp","webgl_enable_EXT_clip_control","webgl_enable_WEBGL_polygon_mode","emscriptenWebGLGet","computeUnpackAlignedImageSize","colorChannelsInGlTextureFormat","emscriptenWebGLGetTexPixelData","emscriptenWebGLGetUniform","webglGetUniformLocation","webglPrepareUniformLocationsBeforeFirstUse","webglGetLeftBracePos","emscriptenWebGLGetVertexAttrib","__glGetActiveAttribOrUniform","writeGLArray","registerWebGlEventCallback","runAndAbortIfError","ALLOC_NORMAL","ALLOC_STACK","allocate","writeStringToMemory","writeAsciiToMemory","setErrNo","demangle","stackTrace"];missingLibrarySymbols.forEach(missingLibrarySymbol);var unexportedSymbols=["run","addOnPreRun","addOnInit","addOnPreMain","addOnExit","addOnPostRun","addRunDependency","removeRunDependency","out","err","callMain","abort","wasmMemory","wasmExports","writeStackCookie","checkStackCookie","convertI32PairToI53Checked","stackSave","stackRestore","stackAlloc","setTempRet0","ptrToString","zeroMemory","getHeapMax","growMemory","ENV","setStackLimits","ERRNO_CODES","strError","DNS","Protocols","Sockets","initRandomFill","randomFill","timers","warnOnce","readEmAsmArgsArray","jstoi_s","getExecutableName","keepRuntimeAlive","asyncLoad","alignMemory","mmapAlloc","wasmTable","noExitRuntime","getCFunc","freeTableIndexes","functionsInTableMap","setValue","getValue","PATH","PATH_FS","UTF8Decoder","UTF8ArrayToString","stringToUTF8Array","stringToUTF8","lengthBytesUTF8","intArrayFromString","stringToAscii","UTF16Decoder","stringToUTF8OnStack","writeArrayToMemory","JSEvents","specialHTMLTargets","findCanvasEventTarget","currentFullscreenStrategy","restoreOldWindowedStyle","UNWIND_CACHE","ExitStatus","getEnvStrings","doReadv","doWritev","promiseMap","uncaughtExceptionCount","exceptionLast","exceptionCaught","ExceptionInfo","findMatchingCatch","Browser","getPreloadedImageData__data","wget","MONTH_DAYS_REGULAR","MONTH_DAYS_LEAP","MONTH_DAYS_REGULAR_CUMULATIVE","MONTH_DAYS_LEAP_CUMULATIVE","SYSCALLS","preloadPlugins","FS_createPreloadedFile","FS_modeStringToFlags","FS_getMode","FS_stdin_getChar_buffer","FS_stdin_getChar","FS_createPath","FS_createDevice","FS_readFile","FS","FS_createDataFile","FS_createLazyFile","MEMFS","TTY","PIPEFS","SOCKFS","tempFixedLengthArray","miniTempWebGLFloatBuffers","miniTempWebGLIntBuffers","GL","AL","GLUT","EGL","GLEW","IDBStore","SDL","SDL_gfx","allocateUTF8","allocateUTF8OnStack","print","printErr"];unexportedSymbols.forEach(unexportedRuntimeSymbol);var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function stackCheckInit(){_emscripten_stack_init();writeStackCookie()}function run(){if(runDependencies>0){return}stackCheckInit();if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();assert(!Module["_main"],'compiled without a main, but one is present. if you added it from JS, use Module["onRuntimeInitialized"]');postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}checkStackCookie()}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise;for(const prop of Object.keys(Module)){if(!(prop in moduleArg)){Object.defineProperty(moduleArg,prop,{configurable:true,get(){abort(`Access to module property ('${prop}') is no longer possible via the module constructor argument; Instead, use the result of the module constructor.`)}})}} return moduleRtn; diff --git a/web/app/public/vendor/blender/web_engine.wasm b/web/app/public/vendor/blender/web_engine.wasm old mode 100755 new mode 100644 index 8ec8efc4..4568056d Binary files a/web/app/public/vendor/blender/web_engine.wasm and b/web/app/public/vendor/blender/web_engine.wasm differ diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 13e4353c..de0f671c 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -10,6 +10,8 @@ import type { MeshEditOperation, MeshElementMode, MeshGeometryBuffer, WebEngineE import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; import type { SimplifyAttributePolicy, SimplifyDelimit, SimplifyMode, SimplifyProfile } from "../../../protocol/simplify"; import { StorageClient } from "../storage/StorageClient"; +import type { StorageBudgetResult } from "../../../protocol/storage"; +import { formatStorageBytes } from "../../../protocol/storage-budget"; import { AutosaveScheduler } from "../storage/autosave"; import { ViewportRenderer } from "../three-adapter/viewport"; import { acquireOffscreenViewportRenderer, OffscreenViewportRenderer, releaseOffscreenViewportRenderer, supportsOffscreenViewport, type ViewportBackend } from "../three-adapter/offscreen-viewport"; @@ -23,7 +25,22 @@ import { exportGLB } from "../../../protocol/glb-export"; import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export"; import { normalizeProjectAssetPath } from "../../../protocol/asset-path"; import { applyCurveGizmoDelta, curveGizmoAxisDelta, deriveCurveHandleGizmoFrame, type CurveGizmoFrameIR, type CurveGizmoHandleIR, type CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction"; +import { buildCurveToggleCyclicOperation, curveTopologyOperatorGate } from "../../../protocol/curve-topology-editor"; import { applyGreasePencilPointTranslation } from "../../../protocol/grease-pencil-editor"; +import type { GreasePencilDrawingIR } from "../../../protocol/grease-pencil"; +import type { + GreasePencilDrawingScopeIR, + GreasePencilMarqueeBoxIR, + GreasePencilMarqueeResultIR, +} from "../../../protocol/grease-pencil-marquee"; +import { + applyGreasePencilSelectionEdit, + createGreasePencilSelectionState, + type GreasePencilSelectionOperation, + type GreasePencilSelectionSource, + type GreasePencilSelectionStateIR, +} from "../../../protocol/grease-pencil-selection"; +import { validateGreasePencilReorderCommand, type GreasePencilLayerMoveDirection } from "../../../protocol/grease-pencil-reorder"; import { loadAndCommitNanoVDBViewportAsset, loadNanoVDBViewportAsset, @@ -38,15 +55,26 @@ import { acquireProjectAction, createProjectActionMutexState, releaseProjectActi import { DEFAULT_FILE_READ_YIELD_BYTES, FileByteReadError, readFileBytes, type FileReadPhase } from "../../../protocol/file-byte-reader"; import { advanceSaveTransaction, beginSaveTransaction, commitSaveTransaction, createSaveTransactionState, failSaveTransaction, type SaveTransactionState } from "../../../protocol/save-transaction"; import { acceptHistoryTransaction, acceptMainSave, acceptMainTransaction, createDirtyState, recoverDirtyState } from "../../../protocol/dirty-state"; +import type { WorkerFault } from "../../../protocol/worker-fault"; +import { normalizeRecentProjects, RECENT_PROJECTS_SCHEMA_VERSION, type RecentProjectBackend, type RecentProjectIssue, type RecentProjectRecord } from "../../../protocol/recent-projects"; +import { appendAppDiagnostic, createAppDiagnosticEntry, createAppDiagnosticReport, type AppDiagnosticArea, type AppDiagnosticCode, type AppDiagnosticContextValue, type AppDiagnosticEntry } from "../../../protocol/diagnostic-report"; import "./app-shell.css"; -function errorMessage(error: unknown): string { - if (error instanceof Error) return error.message; - if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { - const code = "code" in error && typeof error.code === "string" ? `${error.code}: ` : ""; - return `${code}${error.message}`; - } - return String(error); +function recentProjectIssueMessage(code: RecentProjectIssue["code"]): string { + if (code === "MISSING") return "项目内容缺失"; + if (code === "HASH_MISMATCH") return "项目内容校验失败"; + return "项目元数据不一致"; +} + +function StorageBudgetPanel({ budget, onCleanup }: { budget: StorageBudgetResult | null; onCleanup: () => void }) { + const values = budget ? [ + ["项目", budget.projectBytes], + ["快照", budget.snapshotBytes], + ["LOD", budget.lodBytes], + ["媒体", budget.mediaBytes], + ["VDB", budget.vdbBytes], + ] as const : []; + return
存储预算{values.map(([label, bytes]) => {label} {formatStorageBytes(bytes)})}合计 {formatStorageBytes(budget?.totalBytes ?? 0)}
; } async function sha256Hex(data: ArrayBuffer): Promise { @@ -105,7 +133,24 @@ interface MeshEditSelection { greasePencilPoints?: GreasePencilPointRef[]; } -function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, volumeProject, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onGreasePencilPointSelect, onTransform }: { +function currentGreasePencilDrawing(snapshot: SceneSnapshotIR | null): GreasePencilDrawingScopeIR | null { + if (!snapshot) return null; + const activeNode = snapshot.nodes.find((node) => node.id === snapshot.activeObjectId); + const data = snapshot.greasePencils?.find((candidate) => candidate.id === activeNode?.dataId); + const layer = data?.layers.find((candidate) => candidate.id === data.activeLayerId); + if (!data || !layer || !layer.visible || layer.locked) return null; + const frame = layer.frames + .filter((candidate) => candidate.frame <= snapshot.frame.current) + .sort((left, right) => right.frame - left.frame)[0]; + return frame ? { dataId: data.id, layerId: layer.id, frame: frame.frame, drawingId: frame.drawing.id } : null; +} + +function sameGreasePencilDrawing(left: GreasePencilDrawingScopeIR | null | undefined, right: GreasePencilDrawingScopeIR | null | undefined): boolean { + return Boolean(left && right && left.dataId === right.dataId && left.layerId === right.layerId && + left.frame === right.frame && left.drawingId === right.drawingId); +} + +function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, volumeProject, lodLevels, selectedObjectIds, editMode, meshSelection, greasePencilSelectionRevision, onSelect, onElementSelect, onGreasePencilPointSelect, onGreasePencilMarqueeSelect, onTransform, onDiagnostic }: { snapshot: SceneSnapshotIR | null; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[]; @@ -115,21 +160,28 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers selectedObjectIds: ReadonlySet; editMode: boolean; meshSelection: MeshEditSelection; + greasePencilSelectionRevision: number; onSelect: (id: string, additive: boolean) => void; onElementSelect: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void; - onGreasePencilPointSelect: (point: GreasePencilPointRef, additive: boolean) => void; + onGreasePencilPointSelect: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void; + onGreasePencilMarqueeSelect: (result: GreasePencilMarqueeResultIR, additive: boolean) => void; onTransform: (tool: "translate" | "rotate" | "scale", amount?: number, axis?: 0 | 1 | 2, axisVector?: [number, number, number]) => void; + onDiagnostic: (area: AppDiagnosticArea, code: AppDiagnosticCode, error: unknown, context?: Record) => string; }) { const canvasRef = useRef(null); const rendererRef = useRef(null); const onSelectRef = useRef(onSelect); const onElementSelectRef = useRef(onElementSelect); const onGreasePencilPointSelectRef = useRef(onGreasePencilPointSelect); + const onGreasePencilMarqueeSelectRef = useRef(onGreasePencilMarqueeSelect); onSelectRef.current = onSelect; onElementSelectRef.current = onElementSelect; onGreasePencilPointSelectRef.current = onGreasePencilPointSelect; + onGreasePencilMarqueeSelectRef.current = onGreasePencilMarqueeSelect; const [viewportError, setViewportError] = useState(null); - const [activeTool, setActiveTool] = useState<"translate" | "rotate" | "scale">("translate"); + const [activeTool, setActiveTool] = useState<"marquee" | "translate" | "rotate" | "scale">("translate"); + const marqueeSessionRef = useRef<{ pointerId: number; start: [number, number]; additive: boolean } | null>(null); + const [marqueeBox, setMarqueeBox] = useState(null); const [curveGizmoScreenFrame, setCurveGizmoScreenFrame] = useState(null); const [volumeAssets, setVolumeAssets] = useState([]); const volumeSources = useMemo(() => (snapshot?.nonMeshData ?? []).flatMap((data) => { @@ -137,6 +189,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers try { return [{ dataId: data.id, sourcePath: normalizeProjectAssetPath(data.sourcePath) }]; } catch { return []; } }), [snapshot?.nonMeshData]); + const marqueeDrawing = useMemo(() => currentGreasePencilDrawing(snapshot), [snapshot]); const curveControlPoints = (dataId: string, inline?: ArrayLike): ArrayLike | null => { if (inline) return inline; @@ -235,10 +288,11 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers const canvas = canvasRef.current; const selectObject = (id: string, additive: boolean): void => onSelectRef.current(id, additive); const selectElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => onElementSelectRef.current(meshId, mode, index, additive, nonMeshKind); - const selectGreasePencilPoint = (point: GreasePencilPointRef, additive: boolean): void => onGreasePencilPointSelectRef.current(point, additive); + const selectGreasePencilPoint = (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number): void => onGreasePencilPointSelectRef.current(point, additive, baseSelectionRevision); + const selectGreasePencilMarquee = (result: GreasePencilMarqueeResultIR, additive: boolean): void => onGreasePencilMarqueeSelectRef.current(result, additive); const renderer = offscreenRequested && supportsOffscreenViewport(canvas) - ? acquireOffscreenViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint) - : new ViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint); + ? acquireOffscreenViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint, selectGreasePencilMarquee) + : new ViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint, selectGreasePencilMarquee); rendererRef.current = renderer; return () => { rendererRef.current = null; @@ -246,8 +300,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers else renderer.dispose(); }; } catch (error) { - const message = error instanceof Error ? error.message : "无法创建 WebGL 上下文"; - setViewportError(message); + setViewportError(onDiagnostic("VIEWPORT", "VIEWPORT_INIT_FAILED", error)); return undefined; } }, []); @@ -270,9 +323,9 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers const elementSelection = meshSelection.meshId && meshSelection.nonMeshSelections ? new Map([[meshSelection.meshId, meshSelection.nonMeshSelections]]) : undefined; - renderer?.setSelection(selectedObjectIds, elementSelection, meshSelection.greasePencilPoints); + renderer?.setSelection(selectedObjectIds, elementSelection, meshSelection.greasePencilPoints, greasePencilSelectionRevision); renderer?.setInteractionMode(editMode, meshSelection.mode); - }, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections, meshSelection.greasePencilPoints]); + }, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections, meshSelection.greasePencilPoints, greasePencilSelectionRevision]); useEffect(() => { rendererRef.current?.setTextureAssets(textureAssets); @@ -323,21 +376,76 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers return () => canvas.removeEventListener("curve-gizmo-frame", update); }, []); + const marqueePoint = (element: HTMLElement, clientX: number, clientY: number): [number, number] => { + const bounds = element.getBoundingClientRect(); + return [ + Math.max(0, Math.min(1, (clientX - bounds.left) / Math.max(1, bounds.width))), + Math.max(0, Math.min(1, (clientY - bounds.top) / Math.max(1, bounds.height))), + ]; + }; + + const beginMarquee = (event: React.PointerEvent): void => { + if (!snapshot || !marqueeDrawing) return; + const start = marqueePoint(event.currentTarget, event.clientX, event.clientY); + marqueeSessionRef.current = { + pointerId: event.pointerId, + start, + additive: event.shiftKey || event.ctrlKey || event.metaKey, + }; + event.currentTarget.setPointerCapture(event.pointerId); + setMarqueeBox({ left: start[0], top: start[1], right: start[0], bottom: start[1] }); + }; + + const moveMarquee = (event: React.PointerEvent): void => { + const session = marqueeSessionRef.current; + if (!session || session.pointerId !== event.pointerId) return; + const current = marqueePoint(event.currentTarget, event.clientX, event.clientY); + setMarqueeBox({ + left: Math.min(session.start[0], current[0]), + top: Math.min(session.start[1], current[1]), + right: Math.max(session.start[0], current[0]), + bottom: Math.max(session.start[1], current[1]), + }); + }; + + const finishMarquee = (event: React.PointerEvent): void => { + const session = marqueeSessionRef.current; + if (!session || session.pointerId !== event.pointerId) return; + marqueeSessionRef.current = null; + const current = marqueePoint(event.currentTarget, event.clientX, event.clientY); + const box = { + left: Math.min(session.start[0], current[0]), + top: Math.min(session.start[1], current[1]), + right: Math.max(session.start[0], current[0]), + bottom: Math.max(session.start[1], current[1]), + }; + setMarqueeBox(null); + if (event.type === "pointercancel" || !snapshot || !marqueeDrawing || + box.right - box.left < 0.002 || box.bottom - box.top < 0.002) return; + try { + rendererRef.current?.selectGreasePencilMarquee(marqueeDrawing, box, snapshot.revision, greasePencilSelectionRevision, session.additive); + } + catch (error) { + onDiagnostic("VIEWPORT", "GREASE_PENCIL_EDIT_FAILED", error, { drawingId: marqueeDrawing.drawingId }); + } + }; + return ( -
+
{viewportError || !snapshot || snapshot.nodes.length === 0 ?
3D Viewport {viewportError ? WebGL 不可用,已保留场景编辑界面:{viewportError} : Three.js WebGL2 适配器}
: null} + {editMode && activeTool === "marquee" && marqueeDrawing ?
{marqueeBox ? : null}
: null}
- +
- {snapshot?.activeObjectId ?
{([0, 1, 2] as const).map((axis) =>
- {collectionRows.length === 0 ?
Scene Collection
: collectionRows.map((collection, index) => ( -
-
{collection.name}
- {collection.objectIds.map((objectId) => { - const node = nodeById.get(objectId); - if (!node) return null; - return
onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey); }} className={`tree-row child${node.id === snapshot?.activeObjectId ? " selected" : ""}`}>·{node.name}
; - })} -
- ))} +
+ {collectionRows.length === 0 ?
Scene Collection
: collectionRows.map((collection, index) => ( +
+
{collection.name}
+
+ {collection.objectIds.map((objectId) => { + const node = nodeById.get(objectId); + if (!node) return null; + return
onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey); } }} className={`tree-row child${node.id === snapshot?.activeObjectId ? " selected" : ""}`}>·{node.name}
; + })} +
+
+ ))} +
); } -function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, greasePencilPointSelection, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: { +const GREASE_PENCIL_CANVAS_WIDTH = 280; +const GREASE_PENCIL_CANVAS_HEIGHT = 150; + +interface GreasePencilCanvasPoint { + point: GreasePencilPointRef; + x: number; + y: number; +} + +function GreasePencilCanvas2D({ drawing, scope, selection, onSelect }: { + drawing: GreasePencilDrawingIR; + scope: GreasePencilDrawingScopeIR; + selection: GreasePencilSelectionStateIR; + onSelect: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void; +}) { + const canvasRef = useRef(null); + const layout = useMemo(() => { + const points = drawing.strokes.flatMap((stroke, strokeIndex) => (stroke.points ?? []).map((point, pointIndex) => ({ + point: { ...scope, strokeId: stroke.id, pointId: point.id, strokeIndex, pointIndex }, + position: point.position, + }))); + if (points.length === 0) return []; + const xs = points.map(({ position }) => position[0]); + const ys = points.map(({ position }) => position[1]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const rangeX = Math.max(1e-6, maxX - minX); + const rangeY = Math.max(1e-6, maxY - minY); + const padding = 18; + return points.map(({ point, position }) => ({ + point, + x: padding + ((position[0] - minX) / rangeX) * (GREASE_PENCIL_CANVAS_WIDTH - padding * 2), + y: GREASE_PENCIL_CANVAS_HEIGHT - padding - ((position[1] - minY) / rangeY) * (GREASE_PENCIL_CANVAS_HEIGHT - padding * 2), + })); + }, [drawing, scope]); + useEffect(() => { + const context = canvasRef.current?.getContext("2d"); + if (!context) return; + context.clearRect(0, 0, GREASE_PENCIL_CANVAS_WIDTH, GREASE_PENCIL_CANVAS_HEIGHT); + context.fillStyle = "#202328"; + context.fillRect(0, 0, GREASE_PENCIL_CANVAS_WIDTH, GREASE_PENCIL_CANVAS_HEIGHT); + context.strokeStyle = "#333840"; + context.lineWidth = 1; + for (let x = 20; x < GREASE_PENCIL_CANVAS_WIDTH; x += 20) { + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, GREASE_PENCIL_CANVAS_HEIGHT); + context.stroke(); + } + for (let y = 10; y < GREASE_PENCIL_CANVAS_HEIGHT; y += 20) { + context.beginPath(); + context.moveTo(0, y); + context.lineTo(GREASE_PENCIL_CANVAS_WIDTH, y); + context.stroke(); + } + context.strokeStyle = "#aeb7c4"; + context.lineWidth = 1.5; + for (let strokeIndex = 0; strokeIndex < drawing.strokes.length; strokeIndex++) { + const strokePoints = layout.filter((item) => item.point.strokeIndex === strokeIndex); + if (strokePoints.length < 2) continue; + context.beginPath(); + context.moveTo(strokePoints[0].x, strokePoints[0].y); + for (const item of strokePoints.slice(1)) context.lineTo(item.x, item.y); + if (drawing.strokes[strokeIndex].cyclic) context.closePath(); + context.stroke(); + } + const selected = new Set(selection.selectedPoints.map((point) => point.pointId)); + for (const item of layout) { + context.beginPath(); + context.arc(item.x, item.y, selected.has(item.point.pointId) ? 5 : 4, 0, Math.PI * 2); + context.fillStyle = selected.has(item.point.pointId) ? "#f08a45" : "#76baff"; + context.fill(); + context.strokeStyle = selected.has(item.point.pointId) ? "#fff2e7" : "#d7eaff"; + context.stroke(); + } + }, [drawing, layout, selection]); + const selectAt = (clientX: number, clientY: number, additive: boolean): void => { + const canvas = canvasRef.current; + if (!canvas || layout.length === 0) return; + const bounds = canvas.getBoundingClientRect(); + const x = ((clientX - bounds.left) / Math.max(1, bounds.width)) * GREASE_PENCIL_CANVAS_WIDTH; + const y = ((clientY - bounds.top) / Math.max(1, bounds.height)) * GREASE_PENCIL_CANVAS_HEIGHT; + const nearest = layout.reduce<{ item: GreasePencilCanvasPoint; distance: number } | null>((best, item) => { + const distance = Math.hypot(item.x - x, item.y - y); + return !best || distance < best.distance ? { item, distance } : best; + }, null); + if (nearest && nearest.distance <= 14) onSelect(nearest.item.point, additive, selection.revision); + }; + const keyboardSelect = (event: React.KeyboardEvent): void => { + if ((event.key !== "Enter" && event.key !== " ") || layout.length === 0) return; + event.preventDefault(); + const selectedIds = new Set(selection.selectedPoints.map((point) => point.pointId)); + onSelect(layout.find((item) => !selectedIds.has(item.point.pointId))?.point ?? layout[0].point, event.shiftKey || event.ctrlKey || event.metaKey, selection.revision); + }; + return point.pointId).join(",")} + data-point-layout={JSON.stringify(layout.map(({ point, x, y }) => ({ pointId: point.pointId, x, y })))} + onPointerDown={(event) => selectAt(event.clientX, event.clientY, event.shiftKey || event.ctrlKey || event.metaKey)} + onKeyDown={keyboardSelect} />; +} + +function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: { snapshot: SceneSnapshotIR | null; selectedFaceIndices: number[]; selectedVertexIndices: number[]; - greasePencilPointSelection: readonly GreasePencilPointRef[]; + greasePencilSelection: GreasePencilSelectionStateIR | null; + onGreasePencilCanvasPointSelect: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void; onCommand: (command: WebEngineEditCommand) => void; onImportImage: (file: File) => void; onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void; @@ -414,6 +633,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea previewActive: boolean; onCancelPreview: () => void; }) { + const greasePencilPointSelection = greasePencilSelection?.selectedPoints ?? []; const [mode, setMode] = useState("COLLAPSE"); const [ratio, setRatio] = useState(0.5); const [iterations, setIterations] = useState(1); @@ -436,6 +656,10 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea const [renameValue, setRenameValue] = useState(""); const [paintColor, setPaintColor] = useState("#cc6633"); const [paintWeight, setPaintWeight] = useState(1); + const [paintNormalize, setPaintNormalize] = useState(true); + const [paintLimit, setPaintLimit] = useState(4); + const [paintMirror, setPaintMirror] = useState(false); + const [paintMirrorAxis, setPaintMirrorAxis] = useState<0 | 1 | 2>(0); const [paintSelectionMask, setPaintSelectionMask] = useState(0.5); const [paintGroup, setPaintGroup] = useState("WebPaint"); const [greasePencilLayerId, setGreasePencilLayerId] = useState(""); @@ -443,8 +667,13 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea const [greasePencilStrokeIndex, setGreasePencilStrokeIndex] = useState(0); const [greasePencilPointIndex, setGreasePencilPointIndex] = useState(0); const [greasePencilPointDeltaX, setGreasePencilPointDeltaX] = useState(0.1); + const [greasePencilTargetFrame, setGreasePencilTargetFrame] = useState(12); + const [greasePencilReorderError, setGreasePencilReorderError] = useState(""); + const [curveSplineIndex, setCurveSplineIndex] = useState(0); + const [curveTopologyError, setCurveTopologyError] = useState(""); const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId); const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined; + const activeCurve = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "CURVE") : undefined; const activeGreasePencil = activeNode?.dataId ? snapshot?.greasePencils?.find((data) => data.id === activeNode.dataId) : undefined; const selectedGreasePencilPoint = greasePencilPointSelection.find((point) => point.dataId === activeGreasePencil?.id); const activeMaterial = snapshot?.materials.find((material) => material.id === activeMesh?.materialSlotIds?.[0]); @@ -461,6 +690,10 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea setMaterialEmissionStrength(activeMaterial.emissionStrength ?? 1); }, [activeMaterial]); useEffect(() => setRenameValue(activeNode?.name ?? ""), [activeNode?.id]); + useEffect(() => { + if (!activeCurve || curveSplineIndex >= activeCurve.splineCount) setCurveSplineIndex(0); + setCurveTopologyError(""); + }, [activeCurve?.id, activeCurve?.splineCount, curveSplineIndex]); useEffect(() => { if (!activeGreasePencil) setGreasePencilLayerId(""); else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? ""); @@ -471,7 +704,12 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea setGreasePencilStrokeIndex(selectedGreasePencilPoint.strokeIndex); setGreasePencilPointIndex(selectedGreasePencilPoint.pointIndex); }, [selectedGreasePencilPoint?.dataId, selectedGreasePencilPoint?.layerId, selectedGreasePencilPoint?.frame, selectedGreasePencilPoint?.strokeIndex, selectedGreasePencilPoint?.pointIndex]); - const activeGreasePencilFrame = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.find((entry) => entry.frame === (selectedGreasePencilPoint?.frame ?? snapshot?.frame.current ?? 1)); + const selectedGreasePencilLayer = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId); + const selectedGreasePencilLayerIndex = activeGreasePencil?.layers.findIndex((layer) => layer.id === greasePencilLayerId) ?? -1; + const activeGreasePencilFrame = selectedGreasePencilLayer?.frames.find((entry) => entry.frame === (selectedGreasePencilPoint?.frame ?? snapshot?.frame.current ?? 1)); + const greasePencilCanvasFrame = activeGreasePencil?.layers + .find((layer) => layer.id === greasePencilSelection?.drawing.layerId)?.frames + .find((entry) => entry.frame === greasePencilSelection?.drawing.frame && entry.drawing.id === greasePencilSelection.drawing.drawingId); const activeGreasePencilStroke = activeGreasePencilFrame?.drawing.strokes[greasePencilStrokeIndex]; const activeGreasePencilPoint = activeGreasePencilStroke?.points?.[greasePencilPointIndex]; const selectedPaintBrushWeights = selectedVertexIndices.map((index) => ({ index, weight: paintSelectionMask })); @@ -528,6 +766,63 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea strokes: result.strokes, }); }; + const moveGreasePencilLayer = (direction: GreasePencilLayerMoveDirection): void => { + if (!activeGreasePencil || !snapshot || !greasePencilLayerId) return; + try { + const command = validateGreasePencilReorderCommand({ + type: "moveGreasePencilLayer", + schemaVersion: 1, + dataId: activeGreasePencil.id, + layerId: greasePencilLayerId, + direction, + baseRevision: snapshot.revision, + }, snapshot.revision, snapshot.greasePencils ?? []); + setGreasePencilReorderError(""); + onCommand(command); + } + catch (error) { + setGreasePencilReorderError(error instanceof Error ? error.message : String(error)); + } + }; + const moveGreasePencilFrame = (): void => { + if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return; + try { + const command = validateGreasePencilReorderCommand({ + type: "moveGreasePencilFrame", + schemaVersion: 1, + dataId: activeGreasePencil.id, + layerId: greasePencilLayerId, + frame: activeGreasePencilFrame.frame, + targetFrame: greasePencilTargetFrame, + drawingId: activeGreasePencilFrame.drawing.id, + baseRevision: snapshot.revision, + }, snapshot.revision, snapshot.greasePencils ?? []); + setGreasePencilReorderError(""); + onCommand(command); + } + catch (error) { + setGreasePencilReorderError(error instanceof Error ? error.message : String(error)); + } + }; + const toggleCurveCyclic = (): void => { + if (!activeCurve || !snapshot) return; + try { + const operation = buildCurveToggleCyclicOperation({ + schemaVersion: 1, + dataId: activeCurve.id, + baseRevision: snapshot.revision, + splineIndex: curveSplineIndex, + splineCount: activeCurve.splineCount, + pointCount: activeCurve.pointCount, + cyclicU: activeCurve.cyclicU ?? [], + }, snapshot.revision); + setCurveTopologyError(""); + onCommand(operation.command); + } + catch (error) { + setCurveTopologyError(error instanceof Error ? error.message : String(error)); + } + }; const toggleDelimit = (value: SimplifyDelimit): void => { setDelimit((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value]); }; @@ -568,9 +863,45 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea

Transform

{activeNode ?

Object & Hierarchy

: null}

Viewport Display

+ {activeCurve && curveTopologyOperatorGate("TOGGLE_CYCLIC").status === "READY" ?

Curve Topology

{activeCurve.cyclicU?.[curveSplineIndex] ? "Cyclic" : "Open"}{curveTopologyError ? {curveTopologyError} : null}
: null} {activeMesh ?

UV Maps

: null} - {activeGreasePencil ?

Grease Pencil

{activeGreasePencilFrame ? <>
{greasePencilPointSelection.length} viewport points selected{activeGreasePencilPoint ? {activeGreasePencilPoint.position.join(", ")} : null} : null}{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes
: null} - {activeMesh && activeNode ?

Paint

{selectedVertexIndices.length} selected vertices{activeMesh.attributes?.some((attribute) => attribute.name === "WebPaintColor" && attribute.domain === "POINT") ? "WebPaintColor POINT" : "No WebPaintColor"}{activeMesh.vertexGroups?.some((group) => group.name === paintGroup) ? paintGroup : "No paint group"}
: null} + {activeGreasePencil ?
layer.name).join(",")} + data-selected-layer-id={greasePencilLayerId} + data-selected-layer-frames={(selectedGreasePencilLayer?.frames ?? []).map((entry) => entry.frame).sort((left, right) => left - right).join(",")} + data-selected-layer-drawing-ids={(selectedGreasePencilLayer?.frames ?? []).map((entry) => entry.drawing.id).sort().join(",")} + > +

Grease Pencil

+ +
+ + +
+ +
+ + + + + +
+ +
+ {greasePencilReorderError ? {greasePencilReorderError} : null} + {greasePencilCanvasFrame && greasePencilSelection ? : null} + {activeGreasePencilFrame ? <> + + + +
+ {greasePencilPointSelection.length} points selected + {activeGreasePencilPoint ? {activeGreasePencilPoint.position.join(", ")} : null} + : null} + {activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes +
: null} + {activeMesh && activeNode ?

Paint

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

Material Slots

{activeMesh.materialSlotIds?.map((id, index) => {index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"})}
@@ -620,16 +951,56 @@ interface OperatorCommand { execute: () => void; } +function OverlayFocusTrap({ children, onEscape, className, label }: { children: React.ReactNode; onEscape: () => void; className: string; label: string }) { + const rootRef = useRef(null); + const focusableSelector = "input, button, select, textarea, a[href], [role='button'], [tabindex]:not([tabindex='-1'])"; + useEffect(() => { + const first = rootRef.current?.querySelector(focusableSelector); + first?.focus(); + }, []); + const onKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + onEscape(); + return; + } + if (event.key !== "Tab") return; + const focusable = [...(rootRef.current?.querySelectorAll(focusableSelector) ?? [])].filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true"); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + }; + return
{children}
; +} + +function MenuPopover({ menu, labels, onClose, onSelect }: { menu: string; labels: readonly string[]; onClose: () => void; onSelect: (label: string) => void }) { + const menuRef = useRef(null); + useEffect(() => { menuRef.current?.querySelector("button")?.focus(); }, []); + const onKeyDown = (event: React.KeyboardEvent): void => { + const buttons = [...(menuRef.current?.querySelectorAll("button") ?? [])]; + const index = buttons.indexOf(document.activeElement as HTMLButtonElement); + if (event.key === "Escape") { event.preventDefault(); onClose(); } + else if (event.key === "ArrowDown" && buttons.length) { event.preventDefault(); buttons[(index + 1 + buttons.length) % buttons.length].focus(); } + else if (event.key === "ArrowUp" && buttons.length) { event.preventDefault(); buttons[(index - 1 + buttons.length) % buttons.length].focus(); } + else if (event.key === "Home" && buttons.length) { event.preventDefault(); buttons[0].focus(); } + else if (event.key === "End" && buttons.length) { event.preventDefault(); buttons[buttons.length - 1].focus(); } + else if (event.key === "Tab") { event.preventDefault(); onClose(); } + }; + return
{labels.map((label) => )}
; +} + function OperatorSearch({ commands, onClose }: { commands: readonly OperatorCommand[]; onClose: () => void }) { const [query, setQuery] = useState(""); const normalized = query.trim().toLowerCase(); const matches = commands.filter((command) => `${command.label} ${command.keywords}`.toLowerCase().includes(normalized)); const run = (command: OperatorCommand): void => { command.execute(); onClose(); }; return ( -
- setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") onClose(); else if (event.key === "Enter" && matches[0]) run(matches[0]); }} placeholder="Search operators" aria-label="搜索操作" /> + + setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && matches[0]) run(matches[0]); }} placeholder="Search operators" aria-label="搜索操作" />
{matches.map((command) => )}
-
+ ); } @@ -679,6 +1050,7 @@ export function App() { const [snapshot, setSnapshot] = useState(null); const [selectedObjectIds, setSelectedObjectIds] = useState>(() => new Set()); const [meshSelection, setMeshSelection] = useState({ meshId: null, mode: "FACE", indices: new Set() }); + const [greasePencilSelection, setGreasePencilSelection] = useState(null); const [geometryBuffers, setGeometryBuffers] = useState([]); const [nonMeshGeometryBuffers, setNonMeshGeometryBuffers] = useState([]); const [gpuTextureAssets, setGPUTextureAssets] = useState([]); @@ -691,6 +1063,13 @@ export function App() { const [saveTransaction, setSaveTransaction] = useState(() => createSaveTransactionState({ revision: 0, sha256: null })); const [userActions, dispatchUserAction] = useReducer(reduceUserActionStates, createInitialUserActionStates()); const [projectActionConflict, setProjectActionConflict] = useState(null); + const [workerFault, setWorkerFault] = useState(null); + const [workerRecoveryStatus, setWorkerRecoveryStatus] = useState<"IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED">("IDLE"); + const [recentProjects, setRecentProjects] = useState([]); + const [recentProjectsQuarantined, setRecentProjectsQuarantined] = useState(0); + const [recentProjectIssues, setRecentProjectIssues] = useState([]); + const [storageBudget, setStorageBudget] = useState(null); + const [diagnostics, setDiagnostics] = useState([]); const webClientRef = useRef(null); const storageClientRef = useRef(null); const autosaveRef = useRef(null); @@ -702,11 +1081,67 @@ export function App() { const userActionSequenceRef = useRef(0); const projectActionMutexRef = useRef(createProjectActionMutexState()); const openAbortControllerRef = useRef(null); + const workerRecoveryInFlightRef = useRef(false); + const diagnosticSequenceRef = useRef(0); + const greasePencilSelectionRef = useRef(null); const fileInputRef = useRef(null); + const projectDisplayNameRef = useRef(projectIdRef.current); + const operatorSearchTriggerRef = useRef(null); + const menuTriggerRefs = useRef>({}); + const workerFaultTestMode = useMemo(() => new URLSearchParams(window.location.search).get("worker-fault"), []); + const cancelOpenFileRead = (): void => { + openAbortControllerRef.current?.abort(); + }; + const saveBlendRef = useRef<() => Promise>(async () => undefined); const workspace = uiState.context.workspaceId; const saved = !dirtyState.dirty; const workspaceLabel = useMemo(() => `${workspace} Workspace`, [workspace]); + const activeGreasePencilDrawing = useMemo(() => currentGreasePencilDrawing(snapshot), [snapshot]); + const recordDiagnostic = (area: AppDiagnosticArea, code: AppDiagnosticCode, error: unknown, context?: Record): string => { + const entry = createAppDiagnosticEntry({ + sequence: ++diagnosticSequenceRef.current, + occurredAt: new Date().toISOString(), + area, + code, + error, + context: { + projectId: projectIdRef.current, + revision: projectRevisionRef.current, + ...context, + }, + }); + setDiagnostics((current) => appendAppDiagnostic(current, entry)); + return entry.summary; + }; + const exportDiagnosticReport = (): void => { + const report = createAppDiagnosticReport({ + generatedAt: new Date().toISOString(), + runtime: { + url: `${window.location.origin}${window.location.pathname}`, + userAgent: navigator.userAgent, + language: navigator.language, + crossOriginIsolated: window.crossOriginIsolated, + }, + project: { projectId: projectIdRef.current, revision: projectRevisionRef.current }, + entries: diagnostics, + }); + const url = URL.createObjectURL(new Blob([`${JSON.stringify(report, null, 2)}\n`], { type: "application/json" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "blender-web-diagnostics.json"; + anchor.click(); + URL.revokeObjectURL(url); + }; const dispatchUI = (command: UICommand) => setUIState((state) => reduceUICommand(state, command)); + const closeOperatorSearch = (): void => { + dispatchUI({ type: "toggleOperatorSearch", open: false }); + window.requestAnimationFrame(() => operatorSearchTriggerRef.current?.focus()); + }; + const closeMenu = (): void => { + const menu = uiState.openMenu; + dispatchUI({ type: "toggleMenu", menu: menu ?? undefined }); + window.requestAnimationFrame(() => { if (menu) menuTriggerRefs.current[menu]?.focus(); }); + }; const nextUserActionIdentity = (kind: Kind): UserActionIdentity & { kind: Kind } => ( { kind, actionId: `${kind}:${++userActionSequenceRef.current}` } ); @@ -732,7 +1167,7 @@ export function App() { if (!result.granted) { if (reportConflict) { setProjectActionConflict(result.conflict); - setEngineStatus(`Action: blocked (${result.conflict.code}: ${result.conflict.reason})`); + setEngineStatus(recordDiagnostic("ACTION", "ACTION_CONFLICT", result.conflict, { requestedAction: identity.kind })); } return false; } @@ -743,7 +1178,139 @@ export function App() { const releaseProjectActionLock = (identity: ProjectActionIdentity): void => { const result = releaseProjectAction(projectActionMutexRef.current, identity); if (result.released) projectActionMutexRef.current = result.state; - else setEngineStatus(`Action: lock failure (${result.errorCode})`); + else setEngineStatus(recordDiagnostic("ACTION", "ACTION_LOCK_FAILED", result, { action: identity.kind })); + }; + const refreshRecentProjects = async (storage: StorageClient): Promise => { + try { + const result = await storage.listRecentProjects(); + if (storageClientRef.current !== storage) return; + setRecentProjects((current) => normalizeRecentProjects([ + ...result.projects, + ...current.filter((project) => project.projectId === projectIdRef.current), + ]).index.projects); + setRecentProjectsQuarantined((current) => Math.max(current, result.quarantined)); + setRecentProjectIssues(result.issues); + } + catch (error) { + if (storageClientRef.current === storage) setStorageStatus(recordDiagnostic("STORAGE", "RECENT_PROJECT_LIST_FAILED", error)); + } + }; + const removeInvalidRecentProject = async (projectId: string): Promise => { + const storage = storageClientRef.current; + if (!storage) return; + try { + const result = await storage.removeRecentProject(projectId); + if (storageClientRef.current !== storage) return; + setRecentProjects(result.projects); + setRecentProjectsQuarantined((current) => Math.max(current, result.quarantined)); + setRecentProjectIssues(result.issues); + setStorageStatus("Storage: invalid recent project reference removed"); + } + catch (error) { + setStorageStatus(recordDiagnostic("STORAGE", "RECENT_PROJECT_REPAIR_FAILED", error, { targetProjectId: projectId })); + } + }; + const recordRecentProject = async (project: Omit): Promise => { + const storage = storageClientRef.current; + if (!storage) return; + try { + const result = await storage.touchRecentProject({ schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, ...project }); + if (storageClientRef.current === storage) { + setRecentProjects(result.projects); + setRecentProjectsQuarantined((current) => Math.max(current, result.quarantined)); + setRecentProjectIssues(result.issues); + } + } + catch (error) { + setStorageStatus(recordDiagnostic("STORAGE", "RECENT_PROJECT_UPDATE_FAILED", error, { targetProjectId: project.projectId })); + } + }; + const refreshStorageBudget = async (projectId = projectIdRef.current): Promise => { + const storage = storageClientRef.current; + if (!storage) return; + try { + const result = await storage.getBudget(projectId); + if (storageClientRef.current === storage && projectIdRef.current === projectId) setStorageBudget(result); + } + catch (error) { + if (storageClientRef.current === storage) setStorageStatus(recordDiagnostic("STORAGE", "STORAGE_BUDGET_FAILED", error, { targetProjectId: projectId })); + } + }; + const cleanupProjectAssets = async (): Promise => { + const storage = storageClientRef.current; + const projectId = projectIdRef.current; + if (!storage || !projectId || projectId === "untitled") return; + try { + const result = await storage.cleanupProject(projectId); + await refreshStorageBudget(projectId); + setStorageStatus(`Storage: removed ${result.removed} orphan asset(s), ${result.bytes} bytes`); + } + catch (error) { + setStorageStatus(recordDiagnostic("STORAGE", "STORAGE_CLEANUP_FAILED", error, { targetProjectId: projectId })); + } + }; + useEffect(() => { + const current = greasePencilSelectionRef.current; + if (!activeGreasePencilDrawing) { + if (current) { + greasePencilSelectionRef.current = null; + setGreasePencilSelection(null); + setMeshSelection((selection) => selection.greasePencilPoints === undefined ? selection : { ...selection, greasePencilPoints: undefined }); + } + return; + } + if (sameGreasePencilDrawing(current?.drawing, activeGreasePencilDrawing)) return; + const next = createGreasePencilSelectionState(activeGreasePencilDrawing); + greasePencilSelectionRef.current = next; + setGreasePencilSelection(next); + setMeshSelection((selection) => ({ ...selection, meshId: activeGreasePencilDrawing.dataId, mode: "VERT", indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: [] })); + }, [activeGreasePencilDrawing?.dataId, activeGreasePencilDrawing?.layerId, activeGreasePencilDrawing?.frame, activeGreasePencilDrawing?.drawingId]); + const commitGreasePencilSelection = ( + source: GreasePencilSelectionSource, + operation: GreasePencilSelectionOperation, + points: readonly GreasePencilPointRef[], + drawing = activeGreasePencilDrawing, + baseSelectionRevision?: number, + ): GreasePencilSelectionStateIR | null => { + const selectionDrawing = drawing ? { + dataId: drawing.dataId, + layerId: drawing.layerId, + frame: drawing.frame, + drawingId: drawing.drawingId, + } : null; + if (!selectionDrawing || !sameGreasePencilDrawing(selectionDrawing, activeGreasePencilDrawing)) { + setEngineStatus(recordDiagnostic("VIEWPORT", "GREASE_PENCIL_EDIT_FAILED", { + code: "GREASE_PENCIL_SELECTION_SCOPE_INVALID", + message: "Grease Pencil selection is not bound to the active drawing", + })); + return null; + } + const current = sameGreasePencilDrawing(greasePencilSelectionRef.current?.drawing, selectionDrawing) + ? greasePencilSelectionRef.current! + : createGreasePencilSelectionState(selectionDrawing); + try { + const next = applyGreasePencilSelectionEdit(current, { + schemaVersion: 1, + baseSelectionRevision: baseSelectionRevision ?? current.revision, + source, + operation, + points: [...points], + }, selectionDrawing); + greasePencilSelectionRef.current = next; + setGreasePencilSelection(next); + setMeshSelection({ + meshId: selectionDrawing.dataId, + mode: "VERT", + indices: new Set(), + nonMeshSelections: undefined, + greasePencilPoints: next.selectedPoints, + }); + return next; + } + catch (error) { + setEngineStatus(recordDiagnostic("VIEWPORT", "GREASE_PENCIL_EDIT_FAILED", error, { drawingId: selectionDrawing.drawingId })); + return null; + } }; const selectObject = (id: string, additive = false): void => { setSelectedObjectIds((current) => { @@ -754,7 +1321,19 @@ export function App() { return next; }); setSnapshot((current) => current ? { ...current, activeObjectId: id } : current); - setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: undefined })); + const selectedNode = snapshot?.nodes.find((node) => node.id === id); + const currentGreasePencilSelection = greasePencilSelectionRef.current; + const retainedGreasePencil = selectedNode?.dataId && currentGreasePencilSelection && selectedNode.dataId === currentGreasePencilSelection.drawing.dataId + ? currentGreasePencilSelection.selectedPoints + : undefined; + setMeshSelection((current) => ({ + ...current, + meshId: retainedGreasePencil ? selectedNode?.dataId ?? null : null, + mode: retainedGreasePencil ? "VERT" : current.mode, + indices: new Set(), + nonMeshSelections: undefined, + greasePencilPoints: retainedGreasePencil, + })); }; const selectMeshElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => { const owner = snapshot?.nodes.find((node) => node.dataId === meshId); @@ -779,21 +1358,32 @@ export function App() { return { meshId, mode, indices: combined, nonMeshKind, nonMeshSelections: selections, greasePencilPoints: undefined }; }); }; - const selectGreasePencilPoint = (point: GreasePencilPointRef, additive: boolean): void => { + const selectGreasePencilPoint = (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision?: number): void => { + if (!commitGreasePencilSelection("VIEWPORT_3D", additive ? "TOGGLE" : "REPLACE", [point], point, baseSelectionRevision)) return; const owner = snapshot?.nodes.find((node) => node.dataId === point.dataId); if (owner) { setSelectedObjectIds((current) => additive ? new Set([...current, owner.id]) : new Set([owner.id])); setSnapshot((current) => current ? { ...current, activeObjectId: owner.id } : current); } - setMeshSelection((current) => { - const sameDrawing = current.greasePencilPoints?.every((selected) => selected.dataId === point.dataId && selected.layerId === point.layerId && selected.frame === point.frame) ?? false; - const points = additive && sameDrawing ? [...(current.greasePencilPoints ?? [])] : []; - const index = points.findIndex((selected) => selected.strokeIndex === point.strokeIndex && selected.pointIndex === point.pointIndex); - if (index >= 0) points.splice(index, 1); - else points.push(point); - points.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex); - return { meshId: point.dataId, mode: "VERT", indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: points }; - }); + }; + const selectGreasePencilMarquee = (result: GreasePencilMarqueeResultIR, additive: boolean): void => { + const currentDrawing = currentGreasePencilDrawing(snapshot); + if (!snapshot || !currentDrawing || result.baseRevision !== snapshot.revision || + result.drawing.drawingId !== currentDrawing.drawingId || + result.selectedPoints.some((point) => point.drawingId !== currentDrawing.drawingId)) { + setEngineStatus(recordDiagnostic("VIEWPORT", "GREASE_PENCIL_EDIT_FAILED", { + code: "GREASE_PENCIL_SELECTION_SCOPE_INVALID", + message: "Grease Pencil marquee result does not belong to the current drawing", + })); + return; + } + const nextSelection = commitGreasePencilSelection("VIEWPORT_3D", additive ? "ADD" : "REPLACE", result.selectedPoints, currentDrawing, result.baseSelectionRevision); + if (!nextSelection) return; + const owner = snapshot.nodes.find((node) => node.dataId === currentDrawing.dataId); + if (owner) { + setSelectedObjectIds((current) => additive ? new Set([...current, owner.id]) : new Set([owner.id])); + setSnapshot((current) => current ? { ...current, activeObjectId: owner.id } : current); + } }; const restoreCachedLODs = async (projectId: string, scene: SceneSnapshotIR): Promise => { const storage = storageClientRef.current; @@ -860,12 +1450,19 @@ export function App() { return true; } const result = await client.applyCommand(command); + const preserveUIActiveObject = ["setCurveTopology", "undo", "redo"].includes(command.type); + const retainedActiveObjectId = preserveUIActiveObject && snapshot.nodes.some((node) => node.id === snapshot.activeObjectId) + ? snapshot.activeObjectId + : result.snapshot.activeObjectId; + const nextSnapshot = retainedActiveObjectId === result.snapshot.activeObjectId + ? result.snapshot + : { ...result.snapshot, activeObjectId: retainedActiveObjectId }; const logicalRevision = Math.max(projectRevisionRef.current + 1, result.snapshot.revision); projectRevisionRef.current = logicalRevision; setPreview(null); setLodLevels(null); - setSnapshot(result.snapshot); - setSelectedObjectIds(new Set(result.snapshot.activeObjectId ? [result.snapshot.activeObjectId] : [])); + setSnapshot(nextSnapshot); + setSelectedObjectIds(new Set(nextSnapshot.activeObjectId ? [nextSnapshot.activeObjectId] : [])); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); @@ -909,13 +1506,13 @@ export function App() { } } catch (error) { - setStorageStatus(`Storage: operation log failed${error instanceof Error ? ` (${error.message})` : ""}`); + setStorageStatus(recordDiagnostic("STORAGE", "OPERATION_LOG_FAILED", error, { command: command.type })); } } } return true; } catch (error) { - setEngineStatus(`Engine: command failed (${errorMessage(error)})`); + setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error, { command: command.type })); return false; } }; @@ -931,17 +1528,27 @@ export function App() { }; const setMeshSelectionMode = (mode: MeshElementMode): void => { const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId); + const currentGreasePencilSelection = greasePencilSelectionRef.current; + if (activeNode?.dataId && activeNode.dataId === currentGreasePencilSelection?.drawing.dataId) { + setMeshSelection({ meshId: activeNode.dataId, mode: "VERT", indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: currentGreasePencilSelection.selectedPoints }); + return; + } setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: undefined }); }; const selectAllMeshElements = (): void => { const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId); const greasePencil = snapshot?.greasePencils?.find((candidate) => candidate.id === activeNode?.dataId); if (greasePencil) { - const layer = greasePencil.layers.find((candidate) => candidate.id === meshSelection.greasePencilPoints?.[0]?.layerId) ?? greasePencil.layers.find((candidate) => candidate.visible && !candidate.locked); + const layer = greasePencil.layers.find((candidate) => candidate.id === meshSelection.greasePencilPoints?.[0]?.layerId) ?? greasePencil.layers.find((candidate) => candidate.id === greasePencil.activeLayerId); const frame = layer?.frames.filter((candidate) => candidate.frame <= (snapshot?.frame.current ?? 1)).sort((left, right) => right.frame - left.frame)[0]; if (!layer || !frame) return; - const points = frame.drawing.strokes.flatMap((stroke, strokeIndex) => (stroke.points ?? []).map((_point, pointIndex) => ({ dataId: greasePencil.id, layerId: layer.id, frame: frame.frame, strokeIndex, pointIndex }))); - setMeshSelection({ meshId: greasePencil.id, mode: "VERT", indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: points }); + const points = frame.drawing.strokes.flatMap((stroke, strokeIndex) => (stroke.points ?? []).map((point, pointIndex) => ({ dataId: greasePencil.id, layerId: layer.id, frame: frame.frame, drawingId: frame.drawing.id, strokeId: stroke.id, pointId: point.id, strokeIndex, pointIndex }))); + commitGreasePencilSelection("VIEWPORT_3D", "REPLACE", points, { + dataId: greasePencil.id, + layerId: layer.id, + frame: frame.frame, + drawingId: frame.drawing.id, + }); return; } const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode?.dataId); @@ -976,7 +1583,7 @@ export function App() { } catch (error) { failUserAction(identity, failureCode); - setEngineStatus(`Image import failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "IMAGE_IMPORT_FAILED", error, { fileName: file.name })); } finally { bitmap?.close(); @@ -1009,7 +1616,7 @@ export function App() { void applyEditCommand({ type: "setGreasePencilStrokes", dataId: greasePencil.id, layerId: layer.id, frame: frame.frame, baseRevision: snapshot.revision, strokes: result.strokes }); } catch (error) { - setEngineStatus(`Grease Pencil gizmo rejected${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "GREASE_PENCIL_EDIT_FAILED", error)); } return; } @@ -1035,7 +1642,7 @@ export function App() { applied = applyCurveGizmoDelta({ schemaVersion: 1, dataId: nonMesh.id, baseRevision: snapshot.revision, phase: "COMMIT", axis, axisVector, delta, handles }, snapshot.revision); } catch (error) { - setEngineStatus(`Curve gizmo rejected${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "CURVE_EDIT_FAILED", error)); return; } for (const handle of applied.handles) { @@ -1068,23 +1675,37 @@ export function App() { useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { const target = event.target as HTMLElement | null; + const interactiveTarget = target?.closest("button, a, input, textarea, select, option, [contenteditable='true'], [role='button'], [role='menuitem'], [role='tab']"); if (event.key === "F3") { + if (interactiveTarget && target?.matches("input, textarea, select, [contenteditable='true']")) return; event.preventDefault(); dispatchUI({ type: "toggleOperatorSearch", open: true }); return; } if (event.key === "Escape" && uiState.operatorSearchOpen) { event.preventDefault(); - dispatchUI({ type: "toggleOperatorSearch", open: false }); + closeOperatorSearch(); return; } - if (target?.matches("input, textarea, select")) return; - const activeId = snapshot?.activeObjectId; - if (event.key === "Tab") { + if (event.key === "Escape" && uiState.openMenu) { event.preventDefault(); - dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }); + closeMenu(); + return; } - else if ((event.key === "Delete" || event.key === "Backspace") && activeId) { + if (uiState.operatorSearchOpen || uiState.openMenu) return; + if (openProgress && userActions.OPEN.status === "RUNNING" && (openProgress.operation === "blend.open.read" || openProgress.cancellable === true) && event.key === "Escape") { + event.preventDefault(); + cancelOpenFileRead(); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s" && !target?.matches("input, textarea, select, [contenteditable='true']")) { + event.preventDefault(); + void saveBlendRef.current(); + return; + } + if (interactiveTarget) return; + const activeId = snapshot?.activeObjectId; + if ((event.key === "Delete" || event.key === "Backspace") && activeId) { event.preventDefault(); void applyEditCommand({ type: "deleteObject", objectId: activeId }); } @@ -1111,7 +1732,16 @@ export function App() { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [snapshot, uiState.context.mode, uiState.operatorSearchOpen]); + }, [cancelOpenFileRead, openProgress, snapshot, uiState.context.mode, uiState.operatorSearchOpen, uiState.openMenu, userActions.OPEN.status]); + useEffect(() => { + if (!uiState.openMenu) return undefined; + const onPointerDown = (event: PointerEvent): void => { + const target = event.target as Node | null; + if (target && !(target as Element).closest(".menu-bar")) closeMenu(); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [uiState.openMenu]); const generateLOD = async (meshId: string, triangleCount: number): Promise => { const client = webClientRef.current; if (!client || !snapshot || triangleCount <= 0) return; @@ -1149,7 +1779,7 @@ export function App() { }; const storage = storageClientRef.current; let displayLevels = result.lod.levels; - let cacheReadWarning = ""; + let cacheReadWarning: string | null = null; if (storage) { await storage.saveLOD(projectIdRef.current, cacheKey, encoded.slice(0)); await storage.putLODManifest(projectIdRef.current, manifest); @@ -1157,21 +1787,28 @@ export function App() { const cached = await storage.readLOD(projectIdRef.current, cacheKey); displayLevels = decodeLODGeometry(cached.data); } catch (error) { - cacheReadWarning = `; cache read failed, using generated geometry${error instanceof Error ? ` (${error.message})` : ""}`; + cacheReadWarning = recordDiagnostic("STORAGE", "LOD_CACHE_READ_FAILED", error, { meshId, cacheKey }); } } setLodLevels((current) => ({ ...(current ?? {}), [meshId]: displayLevels })); - setEngineStatus(`LOD: ${displayLevels.map((level) => `${level.outputTriangleCount}t`).join(" / ")} (${byteLength} bytes)${cacheReadWarning}`); + setEngineStatus(`LOD: ${displayLevels.map((level) => `${level.outputTriangleCount}t`).join(" / ")} (${byteLength} bytes)${cacheReadWarning ? `; ${cacheReadWarning}` : ""}`); } catch (error) { - setEngineStatus(`Engine: LOD generation failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "LOD_GENERATION_FAILED", error, { meshId })); } }; useEffect(() => { - const client = new WebEngineClient(); - webClientRef.current = client; let mounted = true; + const client = new WebEngineClient({ + onWorkerFault: (fault) => { + if (!mounted) return; + setWorkerFault(fault); + setWorkerRecoveryStatus("IDLE"); + setEngineStatus(recordDiagnostic("ENGINE", "ENGINE_WORKER_TERMINATED", fault.error)); + }, + }); + webClientRef.current = client; void client.init() .then((result) => { if (mounted) { @@ -1182,7 +1819,7 @@ export function App() { .catch((error: unknown) => { if (mounted) { setWasmStatus("WASM ABI: unavailable"); - setEngineStatus(`Engine: unavailable${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "ENGINE_START_FAILED", error)); } }); return () => { @@ -1202,8 +1839,8 @@ export function App() { if (requiredResource) await verifyWasmResource(requiredResource); if (mounted) setManifestStatus(`Manifest: verified r${manifest.protocolVersion}`); }) - .catch(() => { - if (mounted) setManifestStatus("Manifest: rejected"); + .catch((error: unknown) => { + if (mounted) setManifestStatus(recordDiagnostic("RUNTIME", "MANIFEST_REJECTED", error)); }); return () => { mounted = false; @@ -1211,14 +1848,25 @@ export function App() { }, []); useEffect(() => { - const client = new StorageClient(); + let mounted = true; + const client = new StorageClient({ + onWorkerFault: (fault) => { + if (!mounted) return; + setWorkerFault(fault); + setWorkerRecoveryStatus("IDLE"); + setStorageStatus(recordDiagnostic("STORAGE", "STORAGE_WORKER_TERMINATED", fault.error)); + }, + }); storageClientRef.current = client; void client.smoke().then((result) => { - setStorageStatus(result.opfsAvailable ? "Storage: IndexedDB + OPFS" : "Storage: IndexedDB"); - }).catch(() => { - setStorageStatus("Storage: unavailable"); + if (mounted) setStorageStatus(result.opfsAvailable ? "Storage: IndexedDB + OPFS" : "Storage: IndexedDB"); + }).catch((error: unknown) => { + if (mounted) setStorageStatus(recordDiagnostic("STORAGE", "STORAGE_START_FAILED", error)); }); + void refreshRecentProjects(client); + void refreshStorageBudget(projectIdRef.current); return () => { + mounted = false; storageClientRef.current = null; client.terminate(); }; @@ -1242,7 +1890,7 @@ export function App() { requests = collectGPUTextureAssetRequests(scene); } catch (error) { - setEngineStatus(`PBR asset gate: ${error instanceof RenderAssetValidationError ? error.code : "GPU_TEXTURE_INVALID"}`); + setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error)); return []; } const requestsByAssetId = new Map(); @@ -1272,7 +1920,7 @@ export function App() { gpuAssets.push(await createGPUTextureAsset({ ...request, width, height, mimeType: requested.mimeType ?? request.mimeType }, requested.data.slice(0))); } catch (error) { - if (error instanceof RenderAssetValidationError) setEngineStatus(`PBR asset blocked: ${error.code}`); + if (error instanceof RenderAssetValidationError) setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId: asset.assetId })); } } } @@ -1332,12 +1980,14 @@ export function App() { const inputSha256 = await sha256Hex(input); const projectId = file.name.replace(/\.blend$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled"; projectIdRef.current = projectId; + projectDisplayNameRef.current = file.name; rememberLastProjectId(projectId); failureCode = "OPEN_ENGINE_FAILED"; const result = await client.openBlend(input, (progress) => { setOpenCleanupEvidence((current) => ({ ...current, lastStage: progress.stage ?? current.lastStage })); setOpenProgress({ ...progress, requestId: identity.actionId }); }, controller.signal); + if (controller.signal.aborted) throw new FileByteReadError("FILE_READ_CANCELLED", "cancelled after engine open"); projectRevisionRef.current = result.snapshot.revision; committedProjectRevisionRef.current = 0; committedProjectHashRef.current = inputSha256; @@ -1380,7 +2030,7 @@ export function App() { } else { failUserAction(identity, error instanceof FileByteReadError ? error.code : failureCode); - setEngineStatus(`Engine: .blend read failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "BLEND_OPEN_FAILED", error, { fileName: file.name, failureCode })); } } finally { setOpenProgress(null); @@ -1388,10 +2038,6 @@ export function App() { releaseProjectActionLock(identity); } }; - const cancelOpenFileRead = (): void => { - openAbortControllerRef.current?.abort(); - }; - const persistProject = async (): Promise => { const client = webClientRef.current; if (!client || !snapshot) return null; @@ -1413,9 +2059,11 @@ export function App() { const storage = storageClientRef.current; let persisted = { revision, sha256 }; + let persistedBackend: RecentProjectBackend = "unknown"; if (storage) { const result = await storage.saveProject(projectIdRef.current, revision, data.slice(0)); persisted = { revision: result.revision, sha256: result.sha256 }; + persistedBackend = result.backend; } const sceneCommitted = advanceSaveTransaction(transaction, "SCENE_COMMIT"); if (!sceneCommitted.ok) throw new Error(sceneCommitted.errorCode); @@ -1430,6 +2078,16 @@ export function App() { committedProjectRevisionRef.current = persisted.revision; committedProjectHashRef.current = persisted.sha256; setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: persisted.sha256 }); + void recordRecentProject({ + projectId: projectIdRef.current, + displayName: projectDisplayNameRef.current, + revision: persisted.revision, + bytes: data.byteLength, + sha256: persisted.sha256, + updatedAt: new Date().toISOString(), + lastOpenedAt: new Date().toISOString(), + backend: persistedBackend, + }); setDirtyState((current) => { const accepted = acceptMainSave(current, persisted.revision); return accepted.ok ? accepted.state : current; @@ -1439,9 +2097,10 @@ export function App() { try { await storage.saveSnapshot(projectIdRef.current, revision, data.slice(0)); await storage.pruneOperations(projectIdRef.current, revision); + await refreshStorageBudget(projectIdRef.current); } catch (error) { - setStorageStatus(`Storage: post-commit maintenance failed${error instanceof Error ? ` (${error.message})` : ""}`); + setStorageStatus(recordDiagnostic("STORAGE", "POST_COMMIT_MAINTENANCE_FAILED", error)); } } return data; @@ -1452,19 +2111,27 @@ export function App() { } }; - const recoverCachedProject = async (): Promise => { + const recoverCachedProject = async (targetProjectId = projectIdRef.current, displayName?: string): Promise => { const client = webClientRef.current; const storage = storageClientRef.current; - if (!client || !storage) return; + if (!client || !storage) return false; try { - const projectId = projectIdRef.current; - rememberLastProjectId(projectId); + const projectId = targetProjectId; + const verification = await storage.listRecentProjects(); + if (storageClientRef.current !== storage) return false; + setRecentProjects(verification.projects); + setRecentProjectsQuarantined((current) => Math.max(current, verification.quarantined)); + setRecentProjectIssues(verification.issues); + const issue = verification.issues.find((candidate) => candidate.project.projectId === projectId); + if (issue) throw new Error(`RECENT_PROJECT_${issue.code}`); let baseRevision = 0; let buffer: ArrayBuffer; + let recoveredBackend: RecentProjectBackend = "unknown"; try { const project = await storage.readProject(projectId); baseRevision = project.revision; buffer = project.buffer; + recoveredBackend = project.backend; } catch { const listed = await storage.listSnapshots(projectId); @@ -1475,6 +2142,7 @@ export function App() { buffer = saved.buffer; } const baseSha256 = await sha256Hex(buffer); + const baseBytes = buffer.byteLength; let opened = await client.openBlend(buffer); setVolumeProject({ projectId, sourceBlendSha256: baseSha256 }); const replay = await storage.listOperations(projectId, baseRevision); @@ -1501,13 +2169,74 @@ export function App() { setNonMeshGeometryBuffers(opened.nonMeshGeometryBuffers ?? []); setFrame(opened.snapshot.frame.current); setSelectedObjectIds(new Set(opened.snapshot.activeObjectId ? [opened.snapshot.activeObjectId] : [])); + projectIdRef.current = projectId; + projectDisplayNameRef.current = displayName ?? recentProjects.find((project) => project.projectId === projectId)?.displayName ?? projectId; + rememberLastProjectId(projectId); setEngineStatus(`Recovery: ${replay.operations.length} operation(s), ${replay.quarantined} quarantined`); + await recordRecentProject({ + projectId, + displayName: projectDisplayNameRef.current, + revision: baseRevision, + bytes: baseBytes, + sha256: baseSha256, + updatedAt: new Date().toISOString(), + lastOpenedAt: new Date().toISOString(), + backend: recoveredBackend, + }); + await refreshStorageBudget(projectId); void cachePackedAssets(projectId, opened.snapshot).then((assets) => { if (projectIdRef.current === projectId) setGPUTextureAssets(assets); }); + return true; } catch (error) { - setEngineStatus(`Recovery: failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "PROJECT_RECOVERY_FAILED", error, { targetProjectId })); + return false; + } + }; + + const restartWorkersAndRecover = async (): Promise => { + if (workerRecoveryInFlightRef.current) return; + const engine = webClientRef.current; + const storage = storageClientRef.current; + if (!engine || !storage) return; + const retainedSelection = new Set(selectedObjectIds); + const retainedMeshSelection: MeshEditSelection = { + ...meshSelection, + indices: new Set(meshSelection.indices), + nonMeshSelections: meshSelection.nonMeshSelections + ? new Map([...meshSelection.nonMeshSelections].map(([kind, indices]) => [kind, new Set(indices)])) + : undefined, + greasePencilPoints: meshSelection.greasePencilPoints?.map((point) => ({ ...point })), + }; + const retainedFrame = frame; + const retainedActiveObjectId = snapshot?.activeObjectId ?? null; + workerRecoveryInFlightRef.current = true; + setWorkerRecoveryStatus("RUNNING"); + setEngineStatus("Recovery: restarting Workers"); + try { + const engineStatusAfterRestart = await engine.restart(); + if (!engineStatusAfterRestart.ready) throw new Error("WebEngineWorker did not become ready"); + const storageStatusAfterRestart = await storage.restart(); + setStorageStatus(storageStatusAfterRestart.opfsAvailable ? "Storage: IndexedDB + OPFS" : "Storage: IndexedDB"); + await refreshRecentProjects(storage); + if (!await recoverCachedProject()) throw new Error("no recoverable OPFS commit or operation log"); + setSelectedObjectIds(retainedSelection); + setMeshSelection(retainedMeshSelection); + setFrame(retainedFrame); + setSnapshot((current) => current && retainedActiveObjectId && current.nodes.some((node) => node.id === retainedActiveObjectId) + ? { ...current, activeObjectId: retainedActiveObjectId } + : current); + setWorkerFault(null); + setWorkerRecoveryStatus("SUCCEEDED"); + setEngineStatus("Recovery: Worker restarted and project restored"); + } + catch (error) { + setWorkerRecoveryStatus("FAILED"); + setEngineStatus(recordDiagnostic("ENGINE", "WORKER_RECOVERY_FAILED", error)); + } + finally { + workerRecoveryInFlightRef.current = false; } }; @@ -1527,7 +2256,7 @@ export function App() { } } catch (error) { failUserAction(saveIdentity, "SAVE_FAILED"); - setEngineStatus(`Engine: .blend save failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "BLEND_SAVE_FAILED", error)); } finally { releaseProjectActionLock(saveIdentity); @@ -1545,9 +2274,10 @@ export function App() { succeedUserAction(saveAsIdentity); } catch (error) { failUserAction(saveAsIdentity, "SAVE_AS_DOWNLOAD_FAILED"); - setEngineStatus(`Engine: .blend download failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("EXPORT", "BLEND_DOWNLOAD_FAILED", error)); } }; + saveBlendRef.current = saveBlend; const closeProject = (): void => { if (!snapshot) return; const identity: ProjectActionIdentity = { kind: "CLOSE", actionId: `CLOSE:${++userActionSequenceRef.current}` }; @@ -1561,6 +2291,8 @@ export function App() { setSnapshot(null); setSelectedObjectIds(new Set()); setMeshSelection({ meshId: null, mode: "FACE", indices: new Set() }); + greasePencilSelectionRef.current = null; + setGreasePencilSelection(null); setGeometryBuffers([]); setNonMeshGeometryBuffers([]); setFrame(1); @@ -1568,6 +2300,7 @@ export function App() { projectRevisionRef.current = 0; committedProjectRevisionRef.current = 0; committedProjectHashRef.current = null; + setStorageBudget(null); setSaveTransaction(createSaveTransactionState({ revision: 0, sha256: null })); commandCountRef.current = 0; setOpenProgress(null); @@ -1581,7 +2314,7 @@ export function App() { const identity = beginUserAction("EXPORT"); if (!snapshot) { failUserAction(identity, "EXPORT_PROJECT_UNAVAILABLE"); - setEngineStatus("GLB: blocked (no open project)"); + setEngineStatus(recordDiagnostic("EXPORT", "GLB_PROJECT_UNAVAILABLE", { code: "EXPORT_PROJECT_UNAVAILABLE", message: "No SceneIR snapshot is open" })); return; } try { @@ -1614,12 +2347,12 @@ export function App() { } else { failUserAction(identity, "EXPORT_BLOCKED"); - setEngineStatus(`GLB: blocked (${errorCount} errors, ${warningCount} warnings)`); + setEngineStatus(recordDiagnostic("EXPORT", "GLB_EXPORT_BLOCKED", { code: "EXPORT_BLOCKED", message: JSON.stringify(report.warnings) }, { errorCount, warningCount })); } } catch (error) { failUserAction(identity, "EXPORT_FAILED"); - setEngineStatus(`GLB: export failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("EXPORT", "GLB_EXPORT_FAILED", error)); } }; @@ -1634,7 +2367,7 @@ export function App() { try { await persistProject(); } catch (error) { - setEngineStatus(`Engine: autosave failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(recordDiagnostic("ENGINE", "AUTOSAVE_FAILED", error)); } finally { releaseProjectActionLock(identity); @@ -1647,6 +2380,10 @@ export function App() { const faceCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.faceCount, 0) ?? 0; const frameRange = snapshot?.frame ?? { current: frame, start: 1, end: 250 }; const canCancelOpenRead = userActions.OPEN.status === "RUNNING" && (openProgress?.operation === "blend.open.read" || openProgress?.cancellable === true); + const injectWorkerCrash = (): void => { + if (workerFaultTestMode === "storage") storageClientRef.current?.crashForTest(); + else webClientRef.current?.crashForTest(); + }; const operatorCommands: OperatorCommand[] = [ ...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })), { id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) }, @@ -1680,24 +2417,39 @@ export function App() { data-save-committed-revision={saveTransaction.committed.revision} data-save-committed-hash={saveTransaction.committed.sha256 ?? undefined} data-save-candidate-revision={saveTransaction.candidate?.revision} data-save-candidate-hash={saveTransaction.candidate?.sha256 ?? undefined} data-save-transaction-error={saveTransaction.errorCode ?? undefined} data-dirty={dirtyState.dirty} - data-current-main-revision={dirtyState.currentMainRevision} data-committed-main-revision={dirtyState.committedMainRevision}> + data-current-main-revision={dirtyState.currentMainRevision} data-committed-main-revision={dirtyState.committedMainRevision} + data-worker-fault-source={workerFault?.source} data-worker-fault-code={workerFault?.error.code} + data-worker-recovery-status={workerRecoveryStatus} data-project-id={projectIdRef.current} + data-project-snapshot-revision={snapshot?.revision} data-recent-project-count={recentProjects.length} + data-recent-project-ids={recentProjects.map((project) => project.projectId).join(",")} + data-recent-project-quarantined={recentProjectsQuarantined} data-recent-project-invalid-count={recentProjectIssues.length} + data-recent-project-invalid-ids={recentProjectIssues.map((issue) => issue.project.projectId).join(",")} + data-selected-object-ids={[...selectedObjectIds].sort().join(",")} data-current-frame={frame} + data-selected-grease-pencil-point-ids={(greasePencilSelection?.selectedPoints ?? []).map((point) => point.pointId).sort().join(",")} + data-grease-pencil-selection-revision={greasePencilSelection?.revision ?? 0} + data-grease-pencil-selection-source={greasePencilSelection?.lastSource ?? "NONE"} + data-grease-pencil-selection-drawing-id={greasePencilSelection?.drawing.drawingId ?? ""} + data-diagnostic-count={diagnostics.length} data-last-diagnostic-code={diagnostics.at(-1)?.code}>
Web Blender Modeler V1
- + -
- { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} /> +
{workerFaultTestMode ? : null}
+ { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} />
+ {workerFault ?
{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.{workerRecoveryStatus === "FAILED" ? Recovery failed; retry is safe. : null}
: null} + {recentProjectIssues.length > 0 ?
最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。
{recentProjectIssues.map((issue) =>
{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}
)}
: null} + void cleanupProjectAssets()} />
{workspaceLabel}{uiState.context.mode === "Edit" ? <>
{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => )}
{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => )} : <>}{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}{saved ? "已保存" : "未保存"}
- + void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /> - void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /> + { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], point, baseSelectionRevision); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /> void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} />
- {uiState.operatorSearchOpen ? dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null} + {uiState.operatorSearchOpen ? : null}
Web Blender Modeler V1Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}{openProgress ? 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} />{openProgress.message ?? "Opening"}{canCancelOpenRead ? : null} : null}{manifestStatus}{wasmStatus}{engineStatus}{storageStatus}
); diff --git a/web/app/src/app/app-shell.css b/web/app/src/app/app-shell.css index dc034160..3c037862 100644 --- a/web/app/src/app/app-shell.css +++ b/web/app/src/app/app-shell.css @@ -10,13 +10,14 @@ body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; } button, input { font: inherit; } button { color: inherit; border: 0; cursor: pointer; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; } -.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; } +.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 34px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; } .topbar, .workspace-toolbar, .status-bar { display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #27282b; border-bottom: 1px solid #111214; } .topbar { gap: 14px; } .brand { display: flex; align-items: center; gap: 7px; min-width: 120px; font-weight: 700; color: #f4f4f5; } .brand-mark { color: #e37a2c; font-size: 18px; } -.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; } +.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }.menu-entry { position: relative; display: inline-flex; } .menu-bar button, .topbar-actions button, .workspace-tab, .workspace-toolbar button { padding: 5px 8px; background: transparent; border-radius: 3px; color: #bfc2c8; } .menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-toolbar button:hover { background: #3a3c41; color: #fff; } .topbar-actions button:disabled { color: #6f737b; background: transparent; cursor: default; } @@ -42,6 +43,8 @@ button { color: inherit; border: 0; cursor: pointer; } .timeline-area { grid-column: 1; grid-row: 2; } .viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background: #25272b; } .viewport-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; } +.grease-pencil-marquee-surface { position: absolute; inset: 0; z-index: 1; cursor: crosshair; touch-action: none; } +.grease-pencil-marquee-box { position: absolute; border: 1px solid #7fc4ff; background: #4c9ed126; box-shadow: 0 0 0 1px #17212aaa; pointer-events: none; } .viewport-grid { position: absolute; inset: 0; opacity: .3; background-image: linear-gradient(#62656b 1px, transparent 1px), linear-gradient(90deg, #62656b 1px, transparent 1px); background-size: 32px 32px; transform: perspective(500px) rotateX(58deg) scale(1.7); transform-origin: 50% 100%; } .viewport-placeholder::after { position: absolute; inset: 48% 0 0; border-top: 1px solid #777b83; content: ""; opacity: .5; pointer-events: none; } .viewport-message { position: absolute; top: 50%; left: 50%; z-index: 1; display: grid; gap: 5px; transform: translate(-50%, -50%); text-align: center; color: #c8cbd0; } @@ -50,6 +53,7 @@ button { color: inherit; border: 0; cursor: pointer; } .axis-gizmo { position: absolute; top: 14px; right: 14px; z-index: 2; width: 54px; height: 54px; border: 1px solid #545860; border-radius: 50%; color: #c2c5ca; font-size: 11px; } .axis-gizmo span { position: absolute; padding: 2px; border-radius: 2px; background: #35373d; }.axis-x { right: -3px; top: 23px; color: #f06a6a; }.axis-y { left: 21px; top: -4px; color: #75d18d; }.axis-z { left: 3px; bottom: 4px; color: #6fa4f5; } .viewport-toolbar { position: absolute; top: 14px; left: 12px; z-index: 2; display: grid; gap: 3px; padding: 4px; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.tool-button { width: 27px; height: 27px; background: transparent; color: #c7cad0; border-radius: 3px; }.tool-button:hover, .tool-button.active { color: #fff; background: #d26928; } +.tool-button:disabled { color: #666a72; cursor: default; }.tool-button:disabled:hover { background: transparent; } .transform-gizmo { position: absolute; left: 50%; top: 50%; z-index: 2; width: 92px; height: 92px; transform: translate(-46px, -46px); pointer-events: none; } .transform-gizmo.handle-local::before { position: absolute; left: 42px; top: 42px; width: 8px; height: 8px; border: 1px solid #fff; border-radius: 50%; background: #24262b; box-shadow: 0 0 0 2px #24262baa; content: ""; } .gizmo-axis { position: absolute; width: 28px; height: 28px; padding: 0; border: 2px solid currentColor; border-radius: 50%; background: #24262bcc; font-weight: 700; pointer-events: auto; touch-action: none; } @@ -57,17 +61,25 @@ button { color: inherit; border: 0; cursor: pointer; } .viewport-sidebar { position: absolute; top: 14px; right: 12px; z-index: 2; display: grid; gap: 7px; padding: 9px; color: #aeb3bc; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.viewport-sidebar span { writing-mode: vertical-rl; } .outliner-content, .properties-content { padding: 8px; color: #c8cbd0; } .outliner-tools { display: flex; gap: 4px; margin-bottom: 8px; }.outliner-tools input { min-width: 0; flex: 1; padding: 5px 7px; color: #e4e6ea; background: #1d1f22; border: 1px solid #464950; border-radius: 3px; outline: none; }.outliner-tools button { width: 30px; background: #3a3c42; border-radius: 3px; } -.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { margin-left: auto; min-width: 22px; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; } +.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { width: 24px; min-width: 24px; height: 24px; margin-left: auto; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; } .property-tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid #42454b; }.property-tab { padding: 6px 8px; color: #aeb3bc; background: transparent; border-bottom: 2px solid transparent; }.property-tab.active { color: #fff; border-bottom-color: #e37a2c; }.property-section { padding: 8px 0; border-bottom: 1px solid #383a40; }.property-section h3 { margin: 0 0 8px; color: #eceef1; font-size: 12px; font-weight: 600; }.property-section label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; color: #9fa4ad; }.property-section output { color: #e0e2e6; font-variant-numeric: tabular-nums; }.property-section select { min-width: 118px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="range"] { flex: 1; min-width: 78px; accent-color: #e37a2c; }.modifier-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: #7f858e; font-size: 11px; }.modifier-summary button { padding: 4px 9px; color: #fff; background: #b45f29; border: 1px solid #dc8243; border-radius: 2px; }.modifier-summary button:disabled { color: #777c84; background: #303237; border-color: #45484e; }.swatch { width: 32px; height: 14px; background: #a35d43; border: 1px solid #d18a68; border-radius: 2px; } .modifier-row { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 26px); align-items: center; gap: 3px; min-height: 26px; }.modifier-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-section .modifier-row label { justify-content: center; gap: 2px; min-height: 20px; font-size: 10px; } .property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; } -.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #7f858f; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; } +.grease-pencil-canvas-2d { display: block; width: 100%; max-width: 280px; aspect-ratio: 28 / 15; margin: 7px 0; border: 1px solid #4d5158; border-radius: 3px; background: #202328; cursor: crosshair; touch-action: none; } +.grease-pencil-canvas-2d:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; } +.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #aeb3bc; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; } .status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; } +.storage-budget-panel { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 10px; overflow-x: auto; color: #aeb2bb; background: #222427; border-bottom: 1px solid #111214; white-space: nowrap; scrollbar-width: thin; }.storage-budget-panel strong { color: #e4e6e9; font-size: 11px; }.storage-budget-panel span { color: #aeb2bb; font-variant-numeric: tabular-nums; }.storage-budget-panel output { margin-left: auto; color: #e37a2c; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; padding: 3px 7px; color: #d9dce1; background: #35373d; border: 1px solid #4d5158; border-radius: 3px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; } .open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; } +.worker-fault-banner { position: fixed; top: 76px; left: 50%; z-index: 12; display: flex; align-items: center; gap: 10px; max-width: calc(100vw - 24px); padding: 8px 10px; transform: translateX(-50%); color: #f1f2f4; background: #3a2923; border: 1px solid #c6753b; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.worker-fault-banner span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.worker-fault-banner button { flex: none; padding: 5px 9px; color: #fff; background: #a55325; border: 1px solid #d17a42; border-radius: 3px; }.worker-fault-banner button:disabled { opacity: .6; cursor: default; } +.recent-project-repair-banner { position: fixed; top: 76px; left: 50%; z-index: 11; display: grid; gap: 6px; width: min(560px, calc(100vw - 24px)); padding: 9px 11px; transform: translateX(-50%); color: #f1f2f4; background: #332d22; border: 1px solid #b98a42; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.recent-project-repair-list { display: grid; gap: 4px; max-height: 160px; overflow: auto; }.recent-project-repair-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; color: #d9d1bd; }.recent-project-repair-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.recent-project-repair-item button { flex: none; padding: 4px 8px; color: #fff; background: #765729; border: 1px solid #c49c58; border-radius: 3px; }.recent-project-repair-item button:hover { background: #92703a; } .file-input-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .operator-search { position: fixed; top: 56px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 8px; transform: translateX(-50%); background: #303238; border: 1px solid #545860; border-radius: 4px; box-shadow: 0 12px 30px #0008; } +.main-menu-popover { position: absolute; top: 100%; left: 0; z-index: 14; display: grid; min-width: 128px; padding: 4px; background: #303238; border: 1px solid #545860; border-radius: 3px; box-shadow: 0 8px 20px #0008; }.main-menu-popover button { padding: 6px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 2px; white-space: nowrap; }.main-menu-popover button:hover, .main-menu-popover button:focus-visible { color: #fff; background: #a55325; } .operator-search input { width: 100%; padding: 8px 10px; color: #f4f5f6; background: #1d1f22; border: 1px solid #5a5e66; border-radius: 3px; outline: none; } .operator-results { display: grid; gap: 2px; margin-top: 6px; }.operator-results button { padding: 8px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 3px; }.operator-results button:hover { color: #fff; background: #a55325; } +.outliner-tools input:focus-visible, .operator-search input:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; } +.file-button:has(input:focus-visible) { outline: 2px solid #f2a15b; outline-offset: 1px; } @media (max-width: 800px) { .topbar { min-width: 0; overflow-x: auto; scrollbar-width: none; } .topbar::-webkit-scrollbar { display: none; } @@ -81,6 +93,7 @@ button { color: inherit; border: 0; cursor: pointer; } .timeline-area { grid-row: 2; } .workspace-tab { min-width: 64px; } .status-bar span:nth-child(2), .status-bar span:nth-child(4) { display: none; } + .status-bar { overflow-x: auto; scrollbar-width: thin; } .timeline-content { padding-inline: 8px; } .timeline-controls { gap: 2px; } .timeline-controls button { min-width: 24px; padding-inline: 4px; font-size: 11px; } diff --git a/web/app/src/compositor/CompositorExecutor.ts b/web/app/src/compositor/CompositorExecutor.ts index 086226b0..0fcdfb5c 100644 --- a/web/app/src/compositor/CompositorExecutor.ts +++ b/web/app/src/compositor/CompositorExecutor.ts @@ -1,6 +1,8 @@ export { + COMPOSITOR_WEBGPU_NODE_ALLOWLIST, CompositorFrameCache, CompositorValidationError, + compileCompositorWebGPUPlan, compositorFrameCacheKey, executeCompositorGraph, executeCompositorGraphCached, @@ -12,4 +14,6 @@ export type { CompositorExecutionResult, CompositorGraphIR, CompositorImageBuffer, + CompositorWebGPUInstructionIR, + CompositorWebGPUPlanIR, } from "../../../protocol/compositor"; diff --git a/web/app/src/compositor/CompositorWebGPU.ts b/web/app/src/compositor/CompositorWebGPU.ts new file mode 100644 index 00000000..b5b32537 --- /dev/null +++ b/web/app/src/compositor/CompositorWebGPU.ts @@ -0,0 +1,83 @@ +import { + COMPOSITOR_BUDGET, + CompositorValidationError, + compileCompositorWebGPUPlan, + type CompositorImageBuffer, + type CompositorWebGPUInstructionIR, +} from "../../../protocol/compositor"; + +function wgslFloat(value: number): string { + if (!Number.isFinite(value)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "WebGPU compositor constant is not finite"); + const text = String(Math.fround(value)); + return text.includes(".") || /e/i.test(text) ? text : `${text}.0`; +} + +function instructionWGSL(instruction: CompositorWebGPUInstructionIR): string { + if (instruction.type === "CONSTANT_COLOR") { + return `color = vec4(${instruction.color.map(wgslFloat).join(", ")});`; + } + if (instruction.type === "EXPOSURE") { + return `color = vec4(color.rgb * ${wgslFloat(2 ** instruction.exposure)}, color.a);`; + } + if (instruction.type === "INVERT") return "color = vec4(vec3(1.0) - color.rgb, color.a);"; + return ""; +} + +export async function requestCompositorWebGPUDevice(): Promise { + if (!navigator.gpu) throw new CompositorValidationError("WEBGPU_RENDERER_UNAVAILABLE", "WebGPU is unavailable for the compositor allowlist"); + const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); + if (!adapter) throw new CompositorValidationError("WEBGPU_RENDERER_UNAVAILABLE", "No WebGPU adapter is available for the compositor allowlist"); + return adapter.requestDevice(); +} + +export async function executeCompositorGraphWebGPU( + device: GPUDevice, + value: unknown, + width: number, + height: number, +): Promise { + const plan = compileCompositorWebGPUPlan(value); + const pixelCount = width * height; + const byteLength = pixelCount * 16; + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 || + width > COMPOSITOR_BUDGET.maxDimension || height > COMPOSITOR_BUDGET.maxDimension || + !Number.isSafeInteger(pixelCount) || pixelCount > COMPOSITOR_BUDGET.maxPixels || + byteLength > COMPOSITOR_BUDGET.maxImageBytes || byteLength > device.limits.maxStorageBufferBindingSize || + byteLength > device.limits.maxBufferSize) { + throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "WebGPU compositor output exceeds the image or device budget"); + } + const output = device.createBuffer({ label: "Compositor WebGPU output", size: byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); + const readback = device.createBuffer({ label: "Compositor WebGPU readback", size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + try { + device.pushErrorScope("validation"); + const module = device.createShaderModule({ label: `Compositor ${plan.graphId}`, code: /* wgsl */` +@group(0) @binding(0) var pixels: array>; +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) id: vec3) { + if (id.x >= ${pixelCount}u) { return; } + var color = vec4(0.0); + ${plan.instructions.map(instructionWGSL).filter(Boolean).join("\n ")} + pixels[id.x] = color; +}` }); + const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } }); + const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: output } }] }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.dispatchWorkgroups(Math.ceil(pixelCount / 64)); + pass.end(); + encoder.copyBufferToBuffer(output, 0, readback, 0, byteLength); + device.queue.submit([encoder.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const data = new Float32Array(readback.getMappedRange().slice(0)); + readback.unmap(); + const validationError = await device.popErrorScope(); + if (validationError) throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `WebGPU compositor shader validation failed: ${validationError.message ?? "unknown error"}`); + return { width, height, data, colorSpace: "LINEAR_SRGB" }; + } + finally { + output.destroy(); + readback.destroy(); + } +} diff --git a/web/app/src/engine-client/WebEngineClient.ts b/web/app/src/engine-client/WebEngineClient.ts index c357c72c..dc54f453 100644 --- a/web/app/src/engine-client/WebEngineClient.ts +++ b/web/app/src/engine-client/WebEngineClient.ts @@ -13,6 +13,7 @@ import type { } from "../../../protocol/web-engine"; import type { LODGenerationRequest } from "../../../protocol/lod"; import type { SceneSnapshotIR } from "../../../protocol/scene-ir"; +import type { ShaderCompileReport } from "../../../protocol/shader-compiler"; import type { SceneDelta } from "../../../protocol/scene-delta"; import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; import type { SimplifyResult } from "../../../protocol/simplify"; @@ -20,6 +21,15 @@ import type { DepsgraphEvaluationIR } from "../../../protocol/depsgraph"; import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities"; import type { CapabilityGateResult } from "../../../protocol/capability-gates"; import { applyMeshGeometryDelta } from "../../../protocol/mesh-geometry-delta"; +import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault"; +import type { + PaintStrokeSessionBeginIR, + PaintStrokeSessionCancelIR, + PaintStrokeSessionChunkIR, + PaintStrokeSessionCommitIR, + PaintStrokeSessionReceiptIR, +} from "../../../protocol/paint-stroke-session"; +import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability"; interface PendingRequest { resolve: (result: WebEngineResult) => void; @@ -27,11 +37,13 @@ interface PendingRequest { onProgress?: (progress: ProgressEvent) => void; timer: ReturnType; cleanup?: () => void; + aborted?: boolean; } export interface WebEngineClientOptions { timeoutMs?: number; workerFactory?: () => Worker; + onWorkerFault?: (fault: WorkerFault) => void; } const defaultWorkerFactory = () => @@ -48,7 +60,10 @@ export interface BlendOpenResult { export class WebEngineClient { private readonly timeoutMs: number; private readonly workerFactory: () => Worker; + private readonly onWorkerFault?: (fault: WorkerFault) => void; private worker: Worker | null = null; + private workerFaulted = false; + private lastWorkerFault: WorkerFault | null = null; private requestCounter = 0; private pending = new Map(); private geometryBuffers: MeshGeometryBuffer[] = []; @@ -57,6 +72,7 @@ export class WebEngineClient { constructor(options: WebEngineClientOptions = {}) { this.timeoutMs = options.timeoutMs ?? 30_000; this.workerFactory = options.workerFactory ?? defaultWorkerFactory; + this.onWorkerFault = options.onWorkerFault; } async init(): Promise { @@ -88,7 +104,7 @@ export class WebEngineClient { return { status: result.status, delta: result.delta }; } - async applyCommand(payload: WebEngineEditCommand): Promise { + async applyCommand(payload: WebEngineEditCommand): Promise { const result = await this.request({ type: "applyCommand", payload }); if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回命令结果", true); this.geometryBuffers = result.geometryDelta @@ -102,9 +118,53 @@ export class WebEngineClient { nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers], delta: result.delta, simplify: result.simplify, + shaderCompile: result.shaderCompile, }; } + async beginPaintStroke(session: PaintStrokeSessionBeginIR): Promise { + const result = await this.request({ type: "beginPaintStroke", session }); + if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not open the paint pointer session", true); + return result.paintStrokeSession; + } + + async appendPaintStrokeChunk(chunk: PaintStrokeSessionChunkIR): Promise { + const result = await this.request({ type: "appendPaintStrokeChunk", chunk }); + if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not accept the paint stroke chunk", true); + return result.paintStrokeSession; + } + + async commitPaintStroke(session: PaintStrokeSessionCommitIR): Promise { + const result = await this.request({ type: "commitPaintStroke", session }); + if (!result.snapshot || !result.delta || !result.paintStrokeSession) { + throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not return the committed paint stroke", true); + } + this.geometryBuffers = result.geometryDelta + ? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta) + : result.geometryBuffers ?? this.geometryBuffers; + this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers; + return { + status: result.status, + snapshot: result.snapshot, + geometryBuffers: [...this.geometryBuffers], + nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers], + delta: result.delta, + paintStrokeSession: result.paintStrokeSession, + }; + } + + async cancelPaintStroke(session: PaintStrokeSessionCancelIR): Promise { + const result = await this.request({ type: "cancelPaintStroke", session }); + if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not cancel the paint pointer session", true); + return result.paintStrokeSession; + } + + async queryPaintPBVHCapability(request: PaintPBVHCapabilityRequest): Promise { + const result = await this.request({ type: "queryPaintPBVHCapability", request }); + if (!result.capabilityGate) throw this.report("CAPABILITY_MISSING", "WebEngine did not return the PBVH paint capability gate", true); + return result.capabilityGate; + } + async previewCommand(payload: Extract): Promise { const result = await this.request({ type: "applyCommand", payload }); if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回预览结果", true); @@ -154,21 +214,41 @@ export class WebEngineClient { return result.openResources; } + async restart(): Promise { + this.worker?.terminate(); + this.worker = null; + this.workerFaulted = false; + this.lastWorkerFault = null; + this.geometryBuffers = []; + this.nonMeshGeometryBuffers = []; + return this.init(); + } + + crashForTest(): void { + this.worker?.postMessage({ + requestId: `web-engine-crash-${++this.requestCounter}`, + command: { type: "crashForTest" }, + } satisfies WebEngineRequest); + } + terminate(): void { this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true)); this.worker?.terminate(); this.worker = null; + this.workerFaulted = true; this.geometryBuffers = []; this.nonMeshGeometryBuffers = []; } private start(): Worker { + if (this.workerFaulted) { + throw this.lastWorkerFault?.error ?? this.report("WORKER_TERMINATED", "WebEngineWorker 需要重启", true); + } if (this.worker) return this.worker; const worker = this.workerFactory(); worker.onmessage = (event: MessageEvent) => this.handleResponse(event.data); - worker.onerror = (event) => { - this.failPending(this.report("WORKER_TERMINATED", event.message || "WebEngineWorker 发生异常", true)); - }; + worker.onerror = (event) => this.handleWorkerFault(event.message || "WebEngineWorker 发生异常"); + worker.onmessageerror = () => this.handleWorkerFault("WebEngineWorker 消息无法解析"); this.worker = worker; return worker; } @@ -179,7 +259,13 @@ export class WebEngineClient { onProgress?: (progress: ProgressEvent) => void, signal?: AbortSignal, ): Promise { - const worker = this.start(); + let worker: Worker; + try { + worker = this.start(); + } + catch (error) { + return Promise.reject(error); + } const requestId = `web-engine-${++this.requestCounter}`; const request = { requestId, command } as WebEngineRequest; return new Promise((resolve, reject) => { @@ -189,6 +275,8 @@ export class WebEngineClient { reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true)); }, this.timeoutMs); const abort = (): void => { + const pending = this.pending.get(requestId); + if (pending) pending.aborted = true; worker.postMessage({ requestId: `web-engine-cancel-${++this.requestCounter}`, command: { type: "cancelOpen", targetRequestId: requestId }, @@ -214,7 +302,10 @@ export class WebEngineClient { this.pending.delete(response.requestId); clearTimeout(pending.timer); pending.cleanup?.(); - if (response.ok) pending.resolve(response.result); + if (response.ok) { + if (pending.aborted) pending.reject(this.report("OPEN_CANCELLED", "WebEngine open cancelled", true)); + else pending.resolve(response.result); + } else pending.reject(response.error); } @@ -227,6 +318,18 @@ export class WebEngineClient { this.pending.clear(); } + private handleWorkerFault(message: string): void { + if (this.workerFaulted) return; + const fault = createWorkerFault("engine", message); + this.workerFaulted = true; + this.lastWorkerFault = fault; + const worker = this.worker; + this.worker = null; + this.failPending(fault.error); + worker?.terminate(); + this.onWorkerFault?.(fault); + } + private report(code: ErrorReport["code"], message: string, recoverable: boolean): ErrorReport { return { code, severity: "error", message, recoverable }; } diff --git a/web/app/src/fonts/external-vfont-import.ts b/web/app/src/fonts/external-vfont-import.ts new file mode 100644 index 00000000..fd10e15f --- /dev/null +++ b/web/app/src/fonts/external-vfont-import.ts @@ -0,0 +1,130 @@ +import { + createExternalVFontMainImport, + ExternalVFontValidationError, + validateExternalVFontImport, + validateStoredExternalVFontAsset, + type ExternalVFontImportRequestIR, +} from "../../../protocol/external-vfont"; +import type { NonMeshFontLinksIR, SceneSnapshotIR, VFontResourceIR } from "../../../protocol/scene-ir"; +import type { StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord } from "../../../protocol/storage"; +import type { WebEngineEditCommand } from "../../../protocol/web-engine"; + +export interface ExternalVFontStoragePort { + putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise; +} + +export interface ExternalVFontReadStoragePort { + readAsset(projectId: string, sha256: string): Promise; +} + +export interface ExternalVFontEnginePort { + applyCommand(payload: WebEngineEditCommand): Promise<{ snapshot: SceneSnapshotIR }>; +} + +export interface ImportExternalVFontOptions { + projectId: string; + request: ExternalVFontImportRequestIR; + storage: ExternalVFontStoragePort; + engine: ExternalVFontEnginePort; +} + +export interface ImportExternalVFontResult { + asset: StorageAssetPutResult; + vfont: VFontResourceIR; + snapshot: SceneSnapshotIR; +} + +export type ExternalVFontStyleSlot = keyof NonMeshFontLinksIR; + +export interface ReplaceExternalVFontStyleOptions { + projectId: string; + sha256: string; + dataId: string; + vfontId: string; + style: ExternalVFontStyleSlot; + snapshot: SceneSnapshotIR; + storage: ExternalVFontReadStoragePort; + engine: ExternalVFontEnginePort; +} + +export interface ReplaceExternalVFontStyleResult { + asset: StorageAssetRecord; + vfont: VFontResourceIR; + previousLinks: NonMeshFontLinksIR; + links: NonMeshFontLinksIR; + snapshot: SceneSnapshotIR; +} + +function encodeBase64(data: ArrayBuffer): string { + const bytes = new Uint8Array(data); + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +export async function importExternalVFontIntoMain(options: ImportExternalVFontOptions): Promise { + const validated = await validateExternalVFontImport(options.request); + const asset = await options.storage.putAsset( + options.projectId, + validated.data.slice(0), + validated.mimeType, + validated.sourcePath, + ); + const mainImport = createExternalVFontMainImport(validated, asset); + const { data, ...proof } = mainImport; + const applied = await options.engine.applyCommand({ type: "importVFont", ...proof, base64: encodeBase64(data) }); + const vfont = applied.snapshot.vfonts?.find((candidate) => + candidate.sourcePath === proof.sourcePath && candidate.name === proof.name && candidate.packed && + candidate.packedByteLength === proof.byteLength && candidate.sha256 === proof.sha256); + if (!vfont) throw new Error("NON_MESH_RESOURCE_MISSING: imported VFont was not published from Blender Main"); + return { asset, vfont, snapshot: applied.snapshot }; +} + +export async function replaceExternalVFontStyleInMain( + options: ReplaceExternalVFontStyleOptions, +): Promise { + if (!(["regular", "bold", "italic", "boldItalic"] as string[]).includes(options.style)) { + throw new ExternalVFontValidationError("NON_MESH_PROPERTY_INVALID", "external VFont style slot is invalid"); + } + const data = options.snapshot.nonMeshData?.find((candidate) => candidate.id === options.dataId); + if (!data || data.type !== "FONT" || !data.fontLinks) { + throw new ExternalVFontValidationError("NON_MESH_DATA_UNSUPPORTED", "font data block or style links are unavailable"); + } + const vfont = options.snapshot.vfonts?.find((candidate) => candidate.id === options.vfontId); + if (!vfont || vfont.builtin || !vfont.packed || vfont.sha256 !== options.sha256 || + !Number.isSafeInteger(vfont.packedByteLength) || (vfont.packedByteLength as number) <= 0) { + throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "replacement VFont is not a verified packed Main resource"); + } + let stored: StorageAssetReadResult; + try { + stored = await options.storage.readAsset(options.projectId, options.sha256); + } + catch (error) { + if (error && typeof error === "object" && "code" in error && + (error as { code?: unknown }).code === "NON_MESH_RESOURCE_MISSING") { + throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "replacement VFont project asset is missing"); + } + throw error; + } + const validated = await validateStoredExternalVFontAsset(options.projectId, options.sha256, stored); + if (vfont.sourcePath !== validated.sourcePath || vfont.packedByteLength !== validated.byteLength || + vfont.sha256 !== validated.sha256) { + throw new ExternalVFontValidationError("ASSET_SOURCE_HASH_MISMATCH", "packed Main VFont does not match its project asset"); + } + const previousLinks = { ...data.fontLinks }; + if (previousLinks[options.style] === vfont.id) { + throw new ExternalVFontValidationError("NON_MESH_PROPERTY_INVALID", "font style already uses the requested VFont"); + } + const links = { ...previousLinks, [options.style]: vfont.id }; + const applied = await options.engine.applyCommand({ type: "setFontLinks", dataId: data.id, links }); + const appliedData = applied.snapshot.nonMeshData?.find((candidate) => candidate.id === data.id); + const appliedVFont = applied.snapshot.vfonts?.find((candidate) => candidate.id === vfont.id); + if (!appliedData?.fontLinks || appliedData.fontLinks[options.style] !== vfont.id || + !appliedVFont?.packed || appliedVFont.sha256 !== validated.sha256 || + appliedVFont.packedByteLength !== validated.byteLength) { + throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "Blender Main did not publish the verified font replacement"); + } + return { asset: stored.asset, vfont: appliedVFont, previousLinks, links, snapshot: applied.snapshot }; +} diff --git a/web/app/src/render/nanovdb-volume-renderer.ts b/web/app/src/render/nanovdb-volume-renderer.ts index ef256810..b960b562 100644 --- a/web/app/src/render/nanovdb-volume-renderer.ts +++ b/web/app/src/render/nanovdb-volume-renderer.ts @@ -1,4 +1,18 @@ import type { NanoVDBGridIR, NanoVDBMaterialIR } from "../../../protocol/volume-vdb"; +import { + consumeNanoVDBProgressiveRedrawBudget, + NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE, + NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES, + validateNanoVDBProgressiveRedrawLimit, +} from "../../../protocol/nanovdb-progressive-redraw"; +import { + createNanoVDBPageFeedbackBuffer, + nanoVDBPageFeedbackByteLength, + NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, + NANOVDB_PAGE_FEEDBACK_WGSL, + parseNanoVDBPageFeedbackBatch, + type NanoVDBPageFeedbackBatch, +} from "../../../protocol/nanovdb-page-feedback"; export interface NanoVDBWebGPUCapabilityIR { available: boolean; @@ -23,6 +37,9 @@ export interface NanoVDBWebGPUGrid { residentVirtualPages: readonly number[]; uploadPage(pageIndex: number, data?: ArrayBuffer): void; touchPage(pageIndex: number): boolean; + beginFrame(): void; + pinPage(pageIndex: number): boolean; + endFrame(): void; evictPage(pageIndex: number): void; hasResidentPage(pageIndex: number): boolean; dispose(): void; @@ -44,6 +61,104 @@ export interface NanoVDBGpuPageAllocatorStatsIR { keys: string[]; } +export interface NanoVDBProgressiveRedrawStatsIR { + pending: boolean; + scheduledCount: number; + redrawCount: number; + maxRedraws: number; + capped: boolean; + errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" | null; +} + +export interface NanoVDBProgressiveRedrawOptionsIR { + maxRedraws?: number; +} + +export class NanoVDBProgressiveRedrawScheduler { + private pending = false; + private scheduledCount = 0; + private redrawCount = 0; + private capped = false; + private disposed = false; + private generation = 0; + private readonly maxRedraws: number; + + constructor( + private readonly requestFrame: (callback: () => void) => void, + private readonly redraw: () => void, + options: NanoVDBProgressiveRedrawOptionsIR = {}, + ) { + const maxRedraws = options.maxRedraws ?? NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES; + this.maxRedraws = validateNanoVDBProgressiveRedrawLimit(maxRedraws); + } + + schedule(): boolean { + if (this.disposed || this.pending || this.capped) return false; + const budget = consumeNanoVDBProgressiveRedrawBudget(this.redrawCount, this.maxRedraws); + if (!budget.allowed) { + this.capped = budget.capped; + return false; + } + this.pending = true; + const generation = this.generation; + try { + this.requestFrame(() => { + if (this.disposed || generation !== this.generation || !this.pending) return; + this.pending = false; + const consumed = consumeNanoVDBProgressiveRedrawBudget(this.redrawCount, this.maxRedraws); + this.redrawCount = consumed.redrawCount; + this.capped = consumed.capped; + this.redraw(); + }); + this.scheduledCount++; + return true; + } + catch (error) { + this.pending = false; + throw error; + } + } + + stats(): NanoVDBProgressiveRedrawStatsIR { + return { + pending: this.pending, + scheduledCount: this.scheduledCount, + redrawCount: this.redrawCount, + maxRedraws: this.maxRedraws, + capped: this.capped, + errorCode: this.capped ? NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE : null, + }; + } + + /** Starts a new render epoch and invalidates callbacks queued by the old epoch. */ + beginRender(): void { + if (this.disposed) return; + this.generation++; + this.pending = false; + this.scheduledCount = 0; + this.redrawCount = 0; + this.capped = false; + } + + dispose(): void { + this.disposed = true; + this.generation++; + this.pending = false; + } +} + +export class NanoVDBProgressivePageUploader { + constructor( + private readonly grid: NanoVDBWebGPUGrid, + private readonly redraw: NanoVDBProgressiveRedrawScheduler, + ) {} + + upload(pageId: number, data: ArrayBuffer): { pageId: number; redrawScheduled: boolean } { + this.grid.uploadPage(pageId, data); + return { pageId, redrawScheduled: this.redraw.schedule() }; + } +} + export interface NanoVDBDeviceLossIR { reason?: string; message: string } const traversalWGSL = /* wgsl */` @@ -57,7 +172,7 @@ fn word(byte_offset: u32) -> u32 { let page = byte_offset / params.page_bytes; if (page >= params.page_count) { return 0u; } let slot = page_table[page]; - if (slot == 0xffffffffu || slot >= params.resident_pages) { return 0u; } + if (slot == 0xffffffffu || slot >= params.resident_pages) { /* NANOVDB_PAGE_FAULT */ return 0u; } let physical = slot * params.page_bytes + (byte_offset % params.page_bytes); if (physical > params.resident_pages * params.page_bytes - 4u) { return 0u; } return grid[physical >> 2u]; @@ -148,6 +263,11 @@ fn sample_density_linear(position: vec3) -> vec2 { } `; +const pageFeedbackTraversalWGSL = traversalWGSL.replaceAll( + "/* NANOVDB_PAGE_FAULT */", + "nanovdb_record_page_fault(page);", +); + function specializeFloatTraversal(prefix: string, gridName: string, pageTableName: string, parameterPrefix: string): string { let source = traversalWGSL .replaceAll("grid[", `${gridName}[`) @@ -255,9 +375,10 @@ export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer const buffer = device.createBuffer({ label: "NanoVDB Float32 grid", size: payload.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true }); new Uint8Array(buffer.getMappedRange()).set(new Uint8Array(payload)); buffer.unmap(); - const pageTable = device.createBuffer({ label: "NanoVDB direct page table", size: 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true }); + const pageTable = device.createBuffer({ label: "NanoVDB direct page table", size: 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, mappedAtCreation: true }); new Uint32Array(pageTable.getMappedRange())[0] = 0; pageTable.unmap(); + let disposed = false; return { buffer, pageTable, @@ -275,9 +396,17 @@ export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer if (pageIndex !== 0 || (data && data.byteLength !== payload.byteLength)) throw new Error("NANOVDB_STREAM_INCOMPLETE: direct NanoVDB grid has one immutable page"); }, touchPage: (pageIndex) => pageIndex === 0, + beginFrame: () => undefined, + pinPage: (pageIndex) => pageIndex === 0, + endFrame: () => undefined, evictPage: () => { throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: direct NanoVDB grid cannot evict its only page"); }, hasResidentPage: (pageIndex) => pageIndex === 0, - dispose: () => { buffer.destroy(); pageTable.destroy(); }, + dispose: () => { + if (disposed) return; + disposed = true; + buffer.destroy(); + pageTable.destroy(); + }, }; } @@ -322,7 +451,7 @@ export function createNanoVDBFloat32GridPaged( const pageTableBytes = Math.max(4, pageCount * 4); try { buffer = device.createBuffer({ label: "NanoVDB paged Float32 grid", size: physicalBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); - pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true }); + pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, mappedAtCreation: true }); new Uint32Array(pageTable.getMappedRange()).fill(0xffffffff); pageTable.unmap(); } @@ -335,6 +464,9 @@ export function createNanoVDBFloat32GridPaged( const lastUsed = new Map(); let clock = 0; let evictions = 0; + let frameActive = false; + let disposed = false; + const framePins = new Set(); const writePageTable = (pageIndex: number, slot: number): void => { device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([slot])); }; @@ -356,8 +488,12 @@ export function createNanoVDBFloat32GridPaged( const existingSlot = resident.get(pageIndex); let slot = existingSlot ?? [...Array(residentPageCount).keys()].find((candidate) => !residentHasSlot(candidate)); if (slot === undefined) { - const oldest = [...lastUsed].sort((left, right) => left[1] - right[1] || left[0] - right[0])[0]; - if (!oldest) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available"); + const oldest = [...lastUsed] + .filter(([candidate]) => !framePins.has(candidate)) + .sort((left, right) => left[1] - right[1] || left[0] - right[0])[0]; + if (!oldest) throw new Error(frameActive + ? "NANOVDB_GPU_BUDGET_EXCEEDED: all resident NanoVDB pages are pinned" + : "NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available"); slot = resident.get(oldest[0]); evict(oldest[0], true); } @@ -386,9 +522,25 @@ export function createNanoVDBFloat32GridPaged( lastUsed.set(pageIndex, ++clock); return true; }, + beginFrame: () => { frameActive = true; framePins.clear(); }, + pinPage: (pageIndex) => { + if (!frameActive || !resident.has(pageIndex)) return false; + framePins.add(pageIndex); + return true; + }, + endFrame: () => { frameActive = false; framePins.clear(); }, evictPage: evict, hasResidentPage: (pageIndex) => resident.has(pageIndex), - dispose: () => { resident.clear(); lastUsed.clear(); buffer.destroy(); pageTable.destroy(); }, + dispose: () => { + if (disposed) return; + disposed = true; + frameActive = false; + framePins.clear(); + resident.clear(); + lastUsed.clear(); + buffer.destroy(); + pageTable.destroy(); + }, }; } @@ -499,7 +651,97 @@ function paramsBuffer(device: GPUDevice, values: Uint32Array): GPUBuffer { return buffer; } -export async function readNanoVDBWordsWebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, byteOffsets: readonly number[]): Promise { +export function createNanoVDBPageFeedbackGPUBuffer(device: GPUDevice, capacity = 1024): GPUBuffer { + const initial = createNanoVDBPageFeedbackBuffer(capacity); + let buffer: GPUBuffer | undefined; + try { + buffer = device.createBuffer({ + label: "NanoVDB page feedback", + size: initial.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + device.queue.writeBuffer(buffer, 0, initial); + return buffer; + } + catch (error) { + buffer?.destroy(); + throw error; + } +} + +export interface NanoVDBPagedRenderResources { + grid: NanoVDBWebGPUGrid; + feedbackBuffer: GPUBuffer; + feedbackCapacity: number; + dispose(): void; +} + +export function createNanoVDBPagedRenderResources( + device: GPUDevice, + byteLength: number, + pageByteLength: number, + maxResidentBytes: number, + feedbackCapacity = 1024, +): NanoVDBPagedRenderResources { + let grid: NanoVDBWebGPUGrid | undefined; + let feedbackBuffer: GPUBuffer | undefined; + try { + grid = createNanoVDBFloat32GridPaged(device, byteLength, pageByteLength, maxResidentBytes); + feedbackBuffer = createNanoVDBPageFeedbackGPUBuffer(device, feedbackCapacity); + } + catch (error) { + feedbackBuffer?.destroy(); + grid?.dispose(); + throw error; + } + let disposed = false; + return { + grid, + feedbackBuffer, + feedbackCapacity, + dispose: () => { + if (disposed) return; + disposed = true; + feedbackBuffer.destroy(); + grid.dispose(); + }, + }; +} + +export async function readNanoVDBPageFeedbackGPUBuffer( + device: GPUDevice, + feedback: GPUBuffer, + capacity: number, + pageCount: number, + renderRevision: number, +): Promise { + const byteLength = nanoVDBPageFeedbackByteLength(capacity); + const readback = device.createBuffer({ + label: `NanoVDB page feedback readback revision ${renderRevision}`, + size: byteLength, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + const encoder = device.createCommandEncoder(); + encoder.copyBufferToBuffer(feedback, 0, readback, 0, byteLength); + device.queue.submit([encoder.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const buffer = readback.getMappedRange().slice(0); + readback.unmap(); + readback.destroy(); + return parseNanoVDBPageFeedbackBatch(buffer, pageCount, renderRevision); +} + +function resolveNanoVDBPageFeedbackGPUBuffer(device: GPUDevice, value: GPUBuffer | undefined): { buffer: GPUBuffer; owned: boolean } { + if (value) return { buffer: value, owned: false }; + return { buffer: createNanoVDBPageFeedbackGPUBuffer(device, 1), owned: true }; +} + +export async function readNanoVDBWordsWebGPU( + device: GPUDevice, + uploaded: NanoVDBWebGPUGrid, + byteOffsets: readonly number[], + pageFeedback?: GPUBuffer, +): Promise { if (byteOffsets.length < 1 || byteOffsets.length > 4096 || byteOffsets.some((offset) => !Number.isSafeInteger(offset) || offset < 0 || offset > uploaded.byteLength - 4 || offset % 4 !== 0)) { throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: paged word read offsets"); } @@ -510,6 +752,7 @@ export async function readNanoVDBWordsWebGPU(device: GPUDevice, uploaded: NanoVD const resultBuffer = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); const readback = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); const params = paramsBuffer(device, new Uint32Array([uploaded.byteLength, byteOffsets.length, 0, 0, uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0])); + const feedback = resolveNanoVDBPageFeedbackGPUBuffer(device, pageFeedback); const module = device.createShaderModule({ label: "NanoVDB paged word reader", code: /* wgsl */` struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32 } @group(0) @binding(0) var grid: array; @@ -517,7 +760,10 @@ struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes @group(0) @binding(2) var results: array; @group(0) @binding(3) var params: Params; @group(0) @binding(4) var page_table: array; -${traversalWGSL} +${NANOVDB_PAGE_FEEDBACK_WGSL} +@group(0) @binding(5) var nanovdb_page_feedback: NanoVDBPageFeedback; +${NANOVDB_PAGE_FEEDBACK_RECORD_WGSL} +${pageFeedbackTraversalWGSL} @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3) { if (id.x < params.count) { results[id.x] = word(offsets[id.x]); } @@ -527,6 +773,7 @@ fn main(@builtin(global_invocation_id) id: vec3) { { binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: offsetBuffer } }, { binding: 2, resource: { buffer: resultBuffer } }, { binding: 3, resource: { buffer: params } }, { binding: 4, resource: { buffer: uploaded.pageTable } }, + { binding: 5, resource: { buffer: feedback.buffer } }, ] }); const encoder = device.createCommandEncoder(); const pass = encoder.beginComputePass(); @@ -537,10 +784,16 @@ fn main(@builtin(global_invocation_id) id: vec3) { const result = [...new Uint32Array(readback.getMappedRange().slice(0))]; readback.unmap(); offsetBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy(); + if (feedback.owned) feedback.buffer.destroy(); return result; } -export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, coordinates: Array): Promise> { +export async function sampleNanoVDBFloat32WebGPU( + device: GPUDevice, + uploaded: NanoVDBWebGPUGrid, + coordinates: Array, + pageFeedback?: GPUBuffer, +): Promise> { if (coordinates.length < 1 || coordinates.length > 4096) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: sample count"); const coordinateData = new Int32Array(coordinates.length * 4); coordinates.forEach((coord, index) => coordinateData.set(coord, index * 4)); @@ -550,6 +803,7 @@ export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: Na const resultBuffer = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); const readback = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); const params = paramsBuffer(device, new Uint32Array([uploaded.byteLength, coordinates.length, 0, 0, uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0])); + const feedback = resolveNanoVDBPageFeedbackGPUBuffer(device, pageFeedback); const module = device.createShaderModule({ label: "NanoVDB Float32 sampler", code: /* wgsl */` struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32 } @group(0) @binding(0) var grid: array; @@ -557,7 +811,10 @@ struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes @group(0) @binding(2) var results: array>; @group(0) @binding(3) var params: Params; @group(0) @binding(4) var page_table: array; -${traversalWGSL} +${NANOVDB_PAGE_FEEDBACK_WGSL} +@group(0) @binding(5) var nanovdb_page_feedback: NanoVDBPageFeedback; +${NANOVDB_PAGE_FEEDBACK_RECORD_WGSL} +${pageFeedbackTraversalWGSL} @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3) { if (id.x >= params.count) { return; } @@ -569,6 +826,7 @@ fn main(@builtin(global_invocation_id) id: vec3) { { binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: coordinateBuffer } }, { binding: 2, resource: { buffer: resultBuffer } }, { binding: 3, resource: { buffer: params } }, { binding: 4, resource: { buffer: uploaded.pageTable } }, + { binding: 5, resource: { buffer: feedback.buffer } }, ] }); const encoder = device.createCommandEncoder(); const pass = encoder.beginComputePass(); @@ -579,6 +837,7 @@ fn main(@builtin(global_invocation_id) id: vec3) { const values = new Float32Array(readback.getMappedRange().slice(0)); readback.unmap(); coordinateBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy(); + if (feedback.owned) feedback.buffer.destroy(); return coordinates.map((_coord, index) => ({ value: values[index * 4], active: values[index * 4 + 1] > 0.5, valid: values[index * 4 + 1] >= 0 })); } diff --git a/web/app/src/sequencer/SequencerAudioSession.ts b/web/app/src/sequencer/SequencerAudioSession.ts new file mode 100644 index 00000000..16782191 --- /dev/null +++ b/web/app/src/sequencer/SequencerAudioSession.ts @@ -0,0 +1,232 @@ +import { + parseSequencerAudioSessionReport, + SEQUENCER_AUDIO_SESSION_SCHEMA, + type SequencerAudioSessionIssueCode, + type SequencerAudioSessionReportIR, +} from "../../../protocol/sequencer-audio-session"; + +interface AudioParamLike { + value: number; + cancelScheduledValues?(startTime: number): void; + setValueAtTime(value: number, startTime: number): unknown; +} + +interface GainNodeLike { + readonly gain: AudioParamLike; + connect(destination: unknown): unknown; + disconnect(): void; +} + +export interface AudioContextLike { + readonly state: "suspended" | "running" | "closed" | string; + readonly currentTime: number; + readonly destination: unknown; + createGain(): GainNodeLike; + resume(): Promise; + suspend(): Promise; + close(): Promise; +} + +export interface SequencerAudioSessionOptions { + scope?: object; + contextFactory?: () => AudioContextLike; + outputGain?: number; +} + +type AudioContextConstructor = new () => AudioContextLike; + +export class SequencerAudioSession { + private readonly scope: object; + private readonly contextFactory?: () => AudioContextLike; + private readonly nominalGain: number; + private context: AudioContextLike | null = null; + private output: GainNodeLike | null = null; + private mutedValue = false; + private disposed = false; + private revisionValue = 0; + private issueCode: SequencerAudioSessionIssueCode | null = null; + + constructor(options: SequencerAudioSessionOptions = {}) { + const outputGain = options.outputGain ?? 1; + if (typeof outputGain !== "number" || !Number.isFinite(outputGain) || outputGain <= 0 || outputGain > 1) { + const error = new Error("SEQUENCER_AUDIO_CONTEXT_INVALID: outputGain must be in (0, 1]") as Error & { code: string }; + error.code = "SEQUENCER_AUDIO_CONTEXT_INVALID"; + throw error; + } + this.scope = options.scope ?? globalThis; + this.contextFactory = options.contextFactory; + this.nominalGain = outputGain; + } + + snapshot(): SequencerAudioSessionReportIR { + return this.report(); + } + + async initialize(): Promise { + this.revisionValue += 1; + if (this.disposed) return this.report(); + if (!this.context || this.context.state === "closed") await this.openContext(); + return this.report(); + } + + async resume(): Promise { + this.revisionValue += 1; + if (this.disposed) return this.report(); + if (!this.context || this.context.state === "closed") await this.openContext(); + if (!this.context) return this.report(); + try { + if (this.context.state !== "running") await this.context.resume(); + if (this.context.state !== "running") throw new Error("AudioContext did not enter running state"); + this.issueCode = null; + this.applyGain(); + } + catch { + this.issueCode = "SEQUENCER_AUDIO_RESUME_FAILED"; + } + return this.report(); + } + + async suspend(): Promise { + this.revisionValue += 1; + if (this.disposed) return this.report(); + if (!this.context) { + this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE"; + return this.report(); + } + try { + if (this.context.state === "running") await this.context.suspend(); + if (this.context.state !== "suspended") throw new Error("AudioContext did not enter suspended state"); + this.issueCode = null; + } + catch { + this.issueCode = "SEQUENCER_AUDIO_SUSPEND_FAILED"; + } + return this.report(); + } + + setMuted(muted: boolean): SequencerAudioSessionReportIR { + if (typeof muted !== "boolean") { + const error = new Error("SEQUENCER_AUDIO_CONTEXT_INVALID: muted must be boolean") as Error & { code: string }; + error.code = "SEQUENCER_AUDIO_CONTEXT_INVALID"; + throw error; + } + this.revisionValue += 1; + if (!this.disposed) { + this.mutedValue = muted; + this.applyGain(); + } + return this.report(); + } + + async recoverDevice(): Promise { + this.revisionValue += 1; + if (this.disposed) return this.report(); + await this.releaseContext(); + this.issueCode = null; + await this.openContext(); + return this.report(); + } + + async close(): Promise { + this.revisionValue += 1; + if (!this.disposed) { + this.disposed = true; + await this.releaseContext(); + this.issueCode = null; + } + return this.report(); + } + + private createContext(): AudioContextLike | null { + if (this.contextFactory) return this.contextFactory(); + const constructor = (this.scope as { AudioContext?: unknown }).AudioContext; + if (typeof constructor !== "function") return null; + return new (constructor as AudioContextConstructor)(); + } + + private async openContext(): Promise { + try { + const context = this.createContext(); + if (!context || context.state === "closed") { + this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE"; + return; + } + const output = context.createGain(); + output.connect(context.destination); + this.context = context; + this.output = output; + this.issueCode = null; + this.applyGain(); + } + catch { + this.context = null; + this.output = null; + this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE"; + } + } + + private applyGain(): void { + if (!this.context || !this.output || this.context.state === "closed") return; + const value = this.mutedValue ? 0 : this.nominalGain; + this.output.gain.cancelScheduledValues?.(this.context.currentTime); + this.output.gain.setValueAtTime(value, this.context.currentTime); + } + + private async releaseContext(): Promise { + const context = this.context; + const output = this.output; + this.context = null; + this.output = null; + if (context && output) { + try { + output.gain.cancelScheduledValues?.(context.currentTime); + output.gain.setValueAtTime(0, context.currentTime); + } + catch { /* Context loss can invalidate AudioParam before release. */ } + try { output.disconnect(); } + catch { /* A disconnected node is already released. */ } + } + if (context && context.state !== "closed") { + try { await context.close(); } + catch { /* The session still drops all references on close failure. */ } + } + } + + private report(): SequencerAudioSessionReportIR { + if (this.disposed) { + return parseSequencerAudioSessionReport({ + schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA, + revision: this.revisionValue, + contextState: "CLOSED", + outputState: "SILENT", + muted: this.mutedValue, + outputGain: 0, + issueCode: null, + }); + } + if (!this.context || !this.output || this.context.state === "closed") { + return parseSequencerAudioSessionReport({ + schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA, + revision: this.revisionValue, + contextState: "UNAVAILABLE", + outputState: "BLOCKED", + muted: this.mutedValue, + outputGain: 0, + issueCode: "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE", + }); + } + const contextState = this.context.state === "running" ? "RUNNING" : "SUSPENDED"; + const outputGain = this.mutedValue ? 0 : this.nominalGain; + return parseSequencerAudioSessionReport({ + schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA, + revision: this.revisionValue, + contextState, + outputState: contextState === "RUNNING" && !this.mutedValue && this.issueCode === null ? "ENABLED" : "SILENT", + muted: this.mutedValue, + outputGain, + issueCode: this.issueCode, + }); + } +} + +export type { SequencerAudioSessionReportIR }; diff --git a/web/app/src/sequencer/SequencerCodecProbe.ts b/web/app/src/sequencer/SequencerCodecProbe.ts new file mode 100644 index 00000000..faa58274 --- /dev/null +++ b/web/app/src/sequencer/SequencerCodecProbe.ts @@ -0,0 +1,145 @@ +import { + SEQUENCER_CODEC_PROBE_SCHEMA, + parseSequencerCodecProbeRequest, + type SequencerCodecProbeBackend, + type SequencerCodecProbeBlockReason, + type SequencerCodecProbeRequestIR, + type SequencerCodecProbeResultIR, +} from "../../../protocol/sequencer"; + +async function sha256(data: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function blocked( + request: SequencerCodecProbeRequestIR, + backend: SequencerCodecProbeBackend | null, + reason: SequencerCodecProbeBlockReason, +): SequencerCodecProbeResultIR { + return { ...request, status: "BLOCKED", backend, reason, decoded: null }; +} + +function waitForMedia(video: HTMLVideoElement, eventName: "loadeddata" | "seeked", timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs); + const finish = (error?: Error): void => { + window.clearTimeout(timeout); + video.removeEventListener(eventName, ready); + video.removeEventListener("error", failed); + if (error) reject(error); else resolve(); + }; + const ready = (): void => finish(); + const failed = (): void => finish(new Error("decode")); + video.addEventListener(eventName, ready, { once: true }); + video.addEventListener("error", failed, { once: true }); + }); +} + +async function probeImage(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise { + if (typeof createImageBitmap !== "function") return blocked(request, null, "RUNTIME_UNAVAILABLE"); + try { + const bitmap = await createImageBitmap(new Blob([data], { type: request.mimeType })); + try { + if (bitmap.width < 1 || bitmap.height < 1) return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED"); + return { ...request, status: "READY", backend: "IMAGE_BITMAP", reason: null, decoded: { width: bitmap.width, height: bitmap.height } }; + } + finally { bitmap.close(); } + } + catch { return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED"); } +} + +async function probeSound(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise { + if (typeof OfflineAudioContext === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE"); + const context = new OfflineAudioContext(1, 1, 48_000); + try { + const decoded = await context.decodeAudioData(data.slice(0)); + if (decoded.sampleRate < 1 || decoded.numberOfChannels < 1 || decoded.length < 1) return blocked(request, "WEB_AUDIO", "DECODE_FAILED"); + return { + ...request, + status: "READY", + backend: "WEB_AUDIO", + reason: null, + decoded: { sampleRate: decoded.sampleRate, channels: decoded.numberOfChannels, durationFrames: decoded.length }, + }; + } + catch { return blocked(request, "WEB_AUDIO", "DECODE_FAILED"); } +} + +async function probeMovie(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise { + if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE"); + const video = document.createElement("video"); + if (video.canPlayType(request.mimeType) === "") return blocked(request, "HTML_MEDIA", "MIME_UNSUPPORTED"); + const url = URL.createObjectURL(new Blob([data], { type: request.mimeType })); + video.preload = "auto"; + video.muted = true; + video.playsInline = true; + video.style.display = "none"; + document.body.append(video); + try { + video.src = url; + video.load(); + await waitForMedia(video, "loadeddata", 10_000); + if (!Number.isFinite(video.duration) || video.duration <= 0 || video.videoWidth < 1 || video.videoHeight < 1) { + return blocked(request, "HTML_MEDIA", "DECODE_FAILED"); + } + const target = Math.min(video.duration / 2, Math.max(0, video.duration - 0.001)); + if (target > 0) { + video.currentTime = target; + await waitForMedia(video, "seeked", 10_000); + } + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return blocked(request, "HTML_MEDIA", "RUNTIME_UNAVAILABLE"); + context.drawImage(video, 0, 0, 1, 1); + context.getImageData(0, 0, 1, 1); + return { + ...request, + status: "READY", + backend: "HTML_MEDIA", + reason: null, + decoded: { + width: video.videoWidth, + height: video.videoHeight, + durationMicros: Math.max(1, Math.round(video.duration * 1_000_000)), + }, + }; + } + catch { return blocked(request, "HTML_MEDIA", "DECODE_FAILED"); } + finally { + video.removeAttribute("src"); + video.load(); + video.remove(); + URL.revokeObjectURL(url); + } +} + +export async function probeSequencerCodec( + requestValue: unknown, + source: ArrayBuffer, +): Promise { + const request = parseSequencerCodecProbeRequest(requestValue); + if (!(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await sha256(source) !== request.sourceSha256) { + return blocked(request, null, "SOURCE_IDENTITY_MISMATCH"); + } + if (request.stripType === "IMAGE") return probeImage(request, source); + if (request.stripType === "SOUND") return probeSound(request, source); + return probeMovie(request, source); +} + +export function createSequencerCodecProbeRequest( + stripType: SequencerCodecProbeRequestIR["stripType"], + mimeType: string, + byteLength: number, + sourceSha256: string, +): SequencerCodecProbeRequestIR { + return parseSequencerCodecProbeRequest({ + schemaVersion: SEQUENCER_CODEC_PROBE_SCHEMA, + stripType, + mimeType, + byteLength, + sourceSha256, + }); +} diff --git a/web/app/src/sequencer/SequencerFinalExport.ts b/web/app/src/sequencer/SequencerFinalExport.ts new file mode 100644 index 00000000..55a1961f --- /dev/null +++ b/web/app/src/sequencer/SequencerFinalExport.ts @@ -0,0 +1,18 @@ +import { + routeSequencerFinalExport, + type SequencerFinalExportRequestIR, + type SequencerFinalExportRouteIR, +} from "../../../protocol/sequencer-export"; + +export async function routeSequencerFinalExportInBrowser( + request: SequencerFinalExportRequestIR, + serverExportAvailable: boolean, + scope: object = globalThis, +): Promise { + return routeSequencerFinalExport(request, { + serverExportAvailable, + browserVideoEncoderAvailable: "VideoEncoder" in scope, + }); +} + +export type { SequencerFinalExportRequestIR, SequencerFinalExportRouteIR }; diff --git a/web/app/src/sequencer/SequencerMediaProxyCache.ts b/web/app/src/sequencer/SequencerMediaProxyCache.ts new file mode 100644 index 00000000..c2464606 --- /dev/null +++ b/web/app/src/sequencer/SequencerMediaProxyCache.ts @@ -0,0 +1,201 @@ +import { + SEQUENCER_MEDIA_PROXY_MAX_BYTES, + createSequencerMediaCacheManifest, + computeSequencerMediaCacheIdentity, + parseSequencerMediaProxyProfile, + sequencerMediaCacheKey, + verifySequencerMediaCacheEntry, + SequencerMediaCacheValidationError, + type SequencerMediaCacheManifestIR, + type SequencerMediaProxyProfileIR, +} from "../../../protocol/sequencer-media-cache"; +import { + gateSequencerCodec, + parseSequencerCodecProbeRequest, + parseSequencerCodecProbeResult, + type SequencerCodecProbeRequestIR, + type SequencerCodecProbeResultIR, +} from "../../../protocol/sequencer"; + +export interface SequencerMediaProxyCacheStatsIR { + entries: number; + bytes: number; + maxBytes: number; + hits: number; + misses: number; + evictions: number; + keys: string[]; +} + +export interface SequencerGeneratedProxyFrameIR { + manifest: SequencerMediaCacheManifestIR; + data: ArrayBuffer; +} + +interface CacheEntry { + manifest: SequencerMediaCacheManifestIR; + data: ArrayBuffer; +} + +async function sha256(data: ArrayBuffer): Promise { + const result = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join(""); +} + +function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number): Promise { + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs); + const finish = (error?: Error): void => { + window.clearTimeout(timeout); + video.removeEventListener("loadeddata", ready); + video.removeEventListener("error", failed); + if (error) reject(error); else resolve(); + }; + const ready = (): void => finish(); + const failed = (): void => finish(new Error("decode")); + video.addEventListener("loadeddata", ready, { once: true }); + video.addEventListener("error", failed, { once: true }); + }); +} + +export async function generateInitialSequencerMovieProxyFrame( + sourceValue: unknown, + capabilityValue: unknown, + profileValue: unknown, + sourceData: ArrayBuffer, +): Promise { + const source = parseSequencerCodecProbeRequest(sourceValue); + const capability = parseSequencerCodecProbeResult(capabilityValue); + const profile = parseSequencerMediaProxyProfile(profileValue); + if (source.stripType !== "MOVIE" || capability.status !== "READY" || capability.stripType !== "MOVIE" || + gateSequencerCodec(source, capability).status !== "READY") { + throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy generation requires a READY movie receipt"); + } + if (!(sourceData instanceof ArrayBuffer) || sourceData.byteLength !== source.byteLength || await sha256(sourceData) !== source.sourceSha256) { + throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Movie proxy source bytes failed identity verification"); + } + if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") { + throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "HTML media proxy generation is unavailable"); + } + + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + video.preload = "auto"; + const url = URL.createObjectURL(new Blob([sourceData], { type: source.mimeType })); + try { + video.src = url; + video.load(); + await waitForLoadedFrame(video, 10_000); + const canvas = document.createElement("canvas"); + canvas.width = profile.width; + canvas.height = profile.height; + const context = canvas.getContext("2d", { alpha: true, willReadFrequently: true }); + if (!context || video.videoWidth < 1 || video.videoHeight < 1) { + throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy frame could not be decoded"); + } + context.clearRect(0, 0, profile.width, profile.height); + context.drawImage(video, 0, 0, profile.width, profile.height); + const pixels = context.getImageData(0, 0, profile.width, profile.height).data; + const data = pixels.buffer.slice(pixels.byteOffset, pixels.byteOffset + pixels.byteLength); + return { + manifest: await createSequencerMediaCacheManifest(source, capability, profile, 0, data), + data, + }; + } + catch (error) { + if (error instanceof SequencerMediaCacheValidationError) throw error; + throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy frame decode failed"); + } + finally { + video.pause(); + video.removeAttribute("src"); + video.load(); + URL.revokeObjectURL(url); + } +} + +export class SequencerMediaProxyCache { + readonly maxBytes: number; + private readonly entries = new Map(); + private currentBytes = 0; + private hitCount = 0; + private missCount = 0; + private evictionCount = 0; + + constructor(maxBytes: number) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > SEQUENCER_MEDIA_PROXY_MAX_BYTES) { + throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer proxy cache budget is invalid"); + } + this.maxBytes = maxBytes; + } + + async put( + manifestValue: unknown, + data: ArrayBuffer, + currentSource: SequencerCodecProbeRequestIR, + currentCapability: SequencerCodecProbeResultIR, + ): Promise { + const manifest = await verifySequencerMediaCacheEntry(manifestValue, data, currentSource, currentCapability); + if (data.byteLength > this.maxBytes) { + throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer proxy entry exceeds the cache budget"); + } + const key = sequencerMediaCacheKey(manifest); + const previous = this.entries.get(key); + if (previous) { + this.currentBytes -= previous.data.byteLength; + this.entries.delete(key); + } + while (this.currentBytes + data.byteLength > this.maxBytes) { + const oldest = this.entries.entries().next().value as [string, CacheEntry] | undefined; + if (!oldest) break; + this.entries.delete(oldest[0]); + this.currentBytes -= oldest[1].data.byteLength; + this.evictionCount++; + } + this.entries.set(key, { manifest, data: data.slice(0) }); + this.currentBytes += data.byteLength; + return key; + } + + async get( + sourceValue: unknown, + capabilityValue: unknown, + profileValue: unknown, + sourceFrame: number, + ): Promise { + const source = parseSequencerCodecProbeRequest(sourceValue); + const capability = parseSequencerCodecProbeResult(capabilityValue); + const profile = parseSequencerMediaProxyProfile(profileValue); + const identitySha256 = await computeSequencerMediaCacheIdentity(source, capability, profile, sourceFrame); + const key = `sequencer-media-cache:v1:${identitySha256}`; + const entry = this.entries.get(key); + if (!entry) { this.missCount++; return undefined; } + this.hitCount++; + this.entries.delete(key); + this.entries.set(key, entry); + return { manifest: entry.manifest, data: entry.data.slice(0) }; + } + + clear(): number { + const released = this.currentBytes; + this.entries.clear(); + this.currentBytes = 0; + return released; + } + + stats(): SequencerMediaProxyCacheStatsIR { + return { + entries: this.entries.size, + bytes: this.currentBytes, + maxBytes: this.maxBytes, + hits: this.hitCount, + misses: this.missCount, + evictions: this.evictionCount, + keys: [...this.entries.keys()], + }; + } +} + +export type { SequencerMediaCacheManifestIR, SequencerMediaProxyProfileIR }; diff --git a/web/app/src/sequencer/SequencerMediaRevisionGate.ts b/web/app/src/sequencer/SequencerMediaRevisionGate.ts new file mode 100644 index 00000000..2d7e624a --- /dev/null +++ b/web/app/src/sequencer/SequencerMediaRevisionGate.ts @@ -0,0 +1,76 @@ +import { + SEQUENCER_MEDIA_REVISION_SCHEMA, + gateSequencerMediaRevision, + parseSequencerMediaRevisionRequest, + parseSequencerMediaRevisionState, + SequencerMediaRevisionValidationError, + type SequencerMediaOperation, + type SequencerMediaRevisionDecisionIR, + type SequencerMediaRevisionRequestIR, + type SequencerMediaRevisionResultIR, + type SequencerMediaRevisionStateIR, +} from "../../../protocol/sequencer-media-revision"; + +export class SequencerMediaRevisionGate { + private stateValue: SequencerMediaRevisionStateIR; + + constructor(timelineId: string, timelineRevision: number) { + this.stateValue = parseSequencerMediaRevisionState({ + schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA, + timelineId, + timelineRevision, + latestRequestRevision: 0, + }); + } + + state(): SequencerMediaRevisionStateIR { + return { ...this.stateValue }; + } + + begin(operation: SequencerMediaOperation, frame: number): SequencerMediaRevisionRequestIR { + this.stateValue = { + ...this.stateValue, + latestRequestRevision: this.stateValue.latestRequestRevision + 1, + }; + return parseSequencerMediaRevisionRequest({ + schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA, + requestId: `media:${this.stateValue.latestRequestRevision}`, + timelineId: this.stateValue.timelineId, + timelineRevision: this.stateValue.timelineRevision, + requestRevision: this.stateValue.latestRequestRevision, + operation, + frame, + }); + } + + replaceTimeline(timelineId: string, timelineRevision: number): SequencerMediaRevisionStateIR { + if (timelineId === this.stateValue.timelineId && timelineRevision <= this.stateValue.timelineRevision) { + throw new SequencerMediaRevisionValidationError("REVISION_CONFLICT", "Sequencer timeline revision must advance monotonically"); + } + this.stateValue = parseSequencerMediaRevisionState({ + schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA, + timelineId, + timelineRevision, + latestRequestRevision: this.stateValue.latestRequestRevision + 1, + }); + return this.state(); + } + + resolve( + request: SequencerMediaRevisionRequestIR, + result: SequencerMediaRevisionResultIR, + publish: (result: SequencerMediaRevisionResultIR) => void, + ): SequencerMediaRevisionDecisionIR { + const decision = gateSequencerMediaRevision(request, this.stateValue, result); + if (decision.status === "PUBLISH") publish(result); + return decision; + } +} + +export type { + SequencerMediaOperation, + SequencerMediaRevisionDecisionIR, + SequencerMediaRevisionRequestIR, + SequencerMediaRevisionResultIR, + SequencerMediaRevisionStateIR, +}; diff --git a/web/app/src/sequencer/SequencerTimeline.ts b/web/app/src/sequencer/SequencerTimeline.ts index f11e6982..adcc85c4 100644 --- a/web/app/src/sequencer/SequencerTimeline.ts +++ b/web/app/src/sequencer/SequencerTimeline.ts @@ -8,7 +8,54 @@ export { sequencerSourceFrame, } from "../../../protocol/sequencer"; export type { + SequencerCodecProbeRequestIR, + SequencerCodecProbeResultIR, SequencerFrameStripIR, SequencerTimelineIR, SequencerTransitionFrameIR, } from "../../../protocol/sequencer"; +export { + computeSequencerMediaCacheIdentity, + createSequencerMediaCacheManifest, + parseSequencerMediaCacheManifest, + parseSequencerMediaProxyProfile, + sequencerMediaCacheKey, + verifySequencerMediaCacheEntry, +} from "../../../protocol/sequencer-media-cache"; +export { + gateSequencerMediaRevision, + parseSequencerMediaRevisionRequest, + parseSequencerMediaRevisionResult, + parseSequencerMediaRevisionState, +} from "../../../protocol/sequencer-media-revision"; +export { + parseSequencerFinalExportEnvironment, + parseSequencerFinalExportRequest, + routeSequencerFinalExport, +} from "../../../protocol/sequencer-export"; +export type { + SequencerFinalExportEnvironmentIR, + SequencerFinalExportRequestIR, + SequencerFinalExportRouteIR, +} from "../../../protocol/sequencer-export"; +export { + parseSequencerAudioSessionReport, + SEQUENCER_AUDIO_SESSION_SCHEMA, +} from "../../../protocol/sequencer-audio-session"; +export type { + SequencerAudioContextState, + SequencerAudioOutputState, + SequencerAudioSessionIssueCode, + SequencerAudioSessionReportIR, +} from "../../../protocol/sequencer-audio-session"; +export type { + SequencerMediaOperation, + SequencerMediaRevisionDecisionIR, + SequencerMediaRevisionRequestIR, + SequencerMediaRevisionResultIR, + SequencerMediaRevisionStateIR, +} from "../../../protocol/sequencer-media-revision"; +export type { + SequencerMediaCacheManifestIR, + SequencerMediaProxyProfileIR, +} from "../../../protocol/sequencer-media-cache"; diff --git a/web/app/src/storage/StorageClient.ts b/web/app/src/storage/StorageClient.ts index 1a6ef4b5..66eefc0c 100644 --- a/web/app/src/storage/StorageClient.ts +++ b/web/app/src/storage/StorageClient.ts @@ -1,19 +1,59 @@ -import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage"; +import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRecentProjectsResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage"; import type { LODCacheRecord } from "../../../protocol/lod"; import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache"; +import type { RecentProjectRecord } from "../../../protocol/recent-projects"; +import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault"; +import type { TexturePaintTileBindingRequestIR, TexturePaintTileBindingResultIR, TexturePaintTileCommitIR, TexturePaintTileCommitResultIR } from "../../../protocol/texture-paint-asset"; + +export interface StorageClientOptions { + workerFactory?: () => Worker; + onWorkerFault?: (fault: WorkerFault) => void; +} + +const defaultWorkerFactory = () => new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" }); export class StorageClient { - private readonly worker: Worker; + private readonly workerFactory: () => Worker; + private readonly onWorkerFault?: (fault: WorkerFault) => void; + private worker: Worker | null = null; + private workerFaulted = false; + private lastWorkerFault: WorkerFault | null = null; private counter = 0; - private readonly pending = new Map void; reject: (error: Error) => void; timeout: number }>(); + private readonly pending = new Map void; reject: (error: Error) => void; timeout: number; cleanupAbort?: () => void }>(); - constructor() { - this.worker = new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" }); - this.worker.onmessage = (event: MessageEvent) => { + constructor(options: StorageClientOptions = {}) { + this.workerFactory = options.workerFactory ?? defaultWorkerFactory; + this.onWorkerFault = options.onWorkerFault; + this.start(); + } + + async restart(): Promise { + this.worker?.terminate(); + this.worker = null; + this.workerFaulted = false; + this.lastWorkerFault = null; + return this.info(); + } + + crashForTest(): void { + this.worker?.postMessage({ + requestId: `storage-crash-${++this.counter}`, + command: { type: "crashForTest" }, + } satisfies StorageRequest); + } + + private start(): Worker { + if (this.workerFaulted) { + throw this.lastWorkerFault ? this.toError(this.lastWorkerFault) : new Error("StorageWorker 需要重启"); + } + if (this.worker) return this.worker; + const worker = this.workerFactory(); + worker.onmessage = (event: MessageEvent) => { const pending = this.pending.get(event.data.requestId); if (!pending) return; this.pending.delete(event.data.requestId); window.clearTimeout(pending.timeout); + pending.cleanupAbort?.(); if (event.data.ok && event.data.result) pending.resolve(event.data.result); else { const error = new Error(event.data.error ?? "StorageWorker failed") as Error & { code?: string }; @@ -21,14 +61,10 @@ export class StorageClient { pending.reject(error); } }; - this.worker.onerror = (event) => { - const error = new Error(event.message || "StorageWorker error"); - for (const pending of this.pending.values()) { - window.clearTimeout(pending.timeout); - pending.reject(error); - } - this.pending.clear(); - }; + worker.onerror = (event) => this.handleWorkerFault(event.message || "StorageWorker error"); + worker.onmessageerror = () => this.handleWorkerFault("StorageWorker 消息无法解析"); + this.worker = worker; + return worker; } smoke(): Promise { @@ -39,10 +75,30 @@ export class StorageClient { return this.request({ type: "info" }) as Promise; } + getBudget(projectId: string): Promise { + return this.request({ type: "getBudget", projectId }) as Promise; + } + + listRecentProjects(): Promise { + return this.request({ type: "listRecentProjects" }) as Promise; + } + + touchRecentProject(project: RecentProjectRecord): Promise { + return this.request({ type: "touchRecentProject", project }) as Promise; + } + + removeRecentProject(projectId: string): Promise { + return this.request({ type: "removeRecentProject", projectId }) as Promise; + } + ensureProject(projectId: string): Promise { return this.request({ type: "ensureProject", projectId }) as Promise; } + cleanupProject(projectId: string): Promise { + return this.request({ type: "cleanupProject", projectId }) as Promise; + } + saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise { return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise; } @@ -91,6 +147,14 @@ export class StorageClient { return this.request({ type: "listAssets", projectId }) as Promise; } + commitTexturePaintTile(commit: TexturePaintTileCommitIR): Promise { + return this.request({ type: "commitTexturePaintTile", commit }) as Promise; + } + + readTexturePaintTileBinding(request: TexturePaintTileBindingRequestIR): Promise { + return this.request({ type: "readTexturePaintTileBinding", request }) as Promise; + } + saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise { return this.request({ type: "saveLOD", projectId, cacheKey, data }, [data]) as Promise; } @@ -119,45 +183,111 @@ export class StorageClient { return this.request({ type: "pruneLOD", projectId, maxBytes }) as Promise; } - putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer): Promise { - return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data]) as Promise; + putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer, signal?: AbortSignal): Promise { + return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data], signal) as Promise; } - readSimulationCache(projectId: string, cacheKey: string): Promise { - return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise; + prepareSimulationCachePlayback(projectId: string, cacheKey: string, signal?: AbortSignal): Promise { + return this.request({ type: "prepareSimulationCachePlayback", projectId, cacheKey }, [], signal) as Promise; } - readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise { - return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }) as Promise; + releaseSimulationCachePlayback(projectId: string, cacheKey: string): Promise { + return this.request({ type: "releaseSimulationCachePlayback", projectId, cacheKey }) as Promise; + } + + readSimulationCache(projectId: string, cacheKey: string, signal?: AbortSignal): Promise { + return this.request({ type: "readSimulationCache", projectId, cacheKey }, [], signal) as Promise; + } + + readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, signal?: AbortSignal): Promise { + return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }, [], signal) as Promise; } listSimulationCaches(projectId: string): Promise { return this.request({ type: "listSimulationCaches", projectId }) as Promise; } + pruneSimulationCaches(projectId: string, maxBytes: number, protectedCacheKeys: string[] = []): Promise { + return this.request({ type: "pruneSimulationCaches", projectId, maxBytes, protectedCacheKeys }) as Promise; + } + getPendingRequestCount(): number { return this.pending.size; } - private request(command: StorageRequest["command"], transfer: Transferable[] = []): Promise> { + private request(command: StorageRequest["command"], transfer: Transferable[] = [], signal?: AbortSignal): Promise> { + if (signal?.aborted) return Promise.reject(new DOMException("Storage request aborted", "AbortError")); + let worker: Worker; + try { + worker = this.start(); + } + catch (error) { + return Promise.reject(error); + } const requestId = `storage-${++this.counter}`; const request: StorageRequest = { requestId, command }; return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { + const pending = this.pending.get(requestId); + if (!pending) return; this.pending.delete(requestId); + pending.cleanupAbort?.(); + worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest); reject(new Error("StorageWorker timeout")); }, 10_000); - this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable), reject, timeout }); - this.worker.postMessage(request, transfer); + const abort = () => { + const pending = this.pending.get(requestId); + if (!pending) return; + this.pending.delete(requestId); + window.clearTimeout(pending.timeout); + pending.cleanupAbort?.(); + worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest); + pending.reject(new DOMException("Storage request aborted", "AbortError")); + }; + const cleanupAbort = signal ? () => signal.removeEventListener("abort", abort) : undefined; + this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable), reject, timeout, cleanupAbort }); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) { + abort(); + return; + } + worker.postMessage(request, transfer); }); } + private handleWorkerFault(message: string): void { + if (this.workerFaulted) return; + const fault = createWorkerFault("storage", message); + this.workerFaulted = true; + this.lastWorkerFault = fault; + const worker = this.worker; + this.worker = null; + const error = this.toError(fault); + for (const pending of this.pending.values()) { + window.clearTimeout(pending.timeout); + pending.cleanupAbort?.(); + pending.reject(error); + } + this.pending.clear(); + worker?.terminate(); + this.onWorkerFault?.(fault); + } + + private toError(fault: WorkerFault): Error { + const error = new Error(fault.error.message) as Error & { code?: string }; + error.code = fault.error.code; + return error; + } + terminate(): void { for (const pending of this.pending.values()) { window.clearTimeout(pending.timeout); + pending.cleanupAbort?.(); pending.reject(new Error("StorageClient terminated")); } this.pending.clear(); - this.worker.terminate(); + this.worker?.terminate(); + this.worker = null; + this.workerFaulted = true; } } diff --git a/web/app/src/storage/migrations.ts b/web/app/src/storage/migrations.ts index 7454ad26..16a849a0 100644 --- a/web/app/src/storage/migrations.ts +++ b/web/app/src/storage/migrations.ts @@ -1,7 +1,7 @@ export const STORAGE_DATABASE_NAME = "blender-web-metadata"; -export const STORAGE_SCHEMA_VERSION = 6; +export const STORAGE_SCHEMA_VERSION = 7; -export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"] as const; +export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "migration"] as const; export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransaction, oldVersion: number): void { if (oldVersion < 1 && !db.objectStoreNames.contains("smoke")) { @@ -31,4 +31,8 @@ export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransactio if (!db.objectStoreNames.contains("simulation_manifest")) db.createObjectStore("simulation_manifest", { keyPath: "id" }); transaction.objectStore("migration").put({ id: "schema-6", version: 6, appliedAt: new Date().toISOString() }); } + if (oldVersion < 7) { + if (!db.objectStoreNames.contains("simulation_quarantine")) db.createObjectStore("simulation_quarantine", { keyPath: "id" }); + transaction.objectStore("migration").put({ id: "schema-7", version: 7, appliedAt: new Date().toISOString() }); + } } diff --git a/web/app/src/storage/opfs-files.ts b/web/app/src/storage/opfs-files.ts index a2d69723..a621dbca 100644 --- a/web/app/src/storage/opfs-files.ts +++ b/web/app/src/storage/opfs-files.ts @@ -203,6 +203,32 @@ export async function ensureProjectLayout(projectId: string, storage: StorageMan return layout; } +export async function measureProjectVDBBytes(projectId: string, storage: StorageManager | undefined = typeof navigator === "undefined" ? undefined : navigator.storage): Promise { + const layout = projectLayout(projectId); + const manager = storage as OpfsStorage | undefined; + if (!manager?.getDirectory) return 0; + try { + let current = await manager.getDirectory(); + for (const segment of `${layout.cachePath}/vdb`.split("/")) current = await current.getDirectoryHandle(segment); + let total = 0; + const entries = (current as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries(); + for await (const [, handle] of entries) { + if (handle.kind !== "directory") continue; + const bundle = handle as FileSystemDirectoryHandle; + const files = (bundle as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries(); + for await (const [name, fileHandle] of files) { + if (fileHandle.kind !== "file" || !name.endsWith(".chunk")) continue; + total += (await (fileHandle as FileSystemFileHandle).getFile()).size; + } + } + return Number.isSafeInteger(total) ? total : 0; + } + catch (error) { + if (error instanceof DOMException && error.name === "NotFoundError") return 0; + throw error; + } +} + export async function writeLodCache(projectId: string, cacheKey: string, data: ArrayBuffer, storage?: StorageManager): Promise { const layout = await ensureProjectLayout(projectId, storage); if (!/^[A-Za-z0-9_-]{1,128}$/.test(cacheKey)) throw new Error("Invalid LOD cache key"); @@ -310,6 +336,66 @@ export async function readProjectBlend(projectId: string, storage?: StorageManag return (await file.getFile()).arrayBuffer(); } +function snapshotFileName(revision: number): string { + if (!Number.isInteger(revision) || revision < 0) throw new Error("Invalid snapshot revision"); + return `${revision}.blend`; +} + +export async function writeProjectSnapshot( + projectId: string, + revision: number, + data: ArrayBuffer, + storage?: StorageManager, +): Promise<{ layout: OpfsProjectLayout; path: string }> { + if (data.byteLength === 0) throw new Error("Project snapshot buffer is empty"); + const fileName = snapshotFileName(revision); + const layout = await ensureProjectLayout(projectId, storage); + const manager = (storage ?? navigator.storage) as OpfsStorage; + const root = await manager.getDirectory!(); + const snapshots = await ensureDirectory(root, layout.snapshotsPath); + const stageName = `${revision}.${crypto.randomUUID()}.tmp`; + const sha256 = await sha256Hex(data); + try { + await writeFile(snapshots, stageName, data); + if (!await verifyFile(snapshots, stageName, data.byteLength, sha256)) { + throw new Error("SNAPSHOT_STAGE_VERIFY_FAILED: staged snapshot does not match its digest"); + } + const stage = await snapshots.getFileHandle(stageName); + const move = (stage as FileSystemFileHandle & { move?: (name: string) => Promise }).move; + if (move) await move.call(stage, fileName); + else { + await writeFile(snapshots, fileName, await (await stage.getFile()).arrayBuffer()); + await removeFile(snapshots, stageName); + } + if (!await verifyFile(snapshots, fileName, data.byteLength, sha256)) { + throw new Error("SNAPSHOT_COMMIT_VERIFY_FAILED: committed snapshot does not match its digest"); + } + return { layout, path: `${layout.snapshotsPath}/${fileName}` }; + } + catch (error) { + await removeFile(snapshots, stageName); + throw error; + } +} + +export async function readProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise { + const layout = projectLayout(projectId); + const manager = (storage ?? navigator.storage) as OpfsStorage; + if (!manager.getDirectory) throw new Error("OPFS is unavailable"); + const root = await manager.getDirectory(); + const snapshots = await ensureDirectory(root, layout.snapshotsPath); + return readFile(snapshots, snapshotFileName(revision)); +} + +export async function deleteProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise { + const layout = projectLayout(projectId); + const manager = (storage ?? navigator.storage) as OpfsStorage; + if (!manager.getDirectory) throw new Error("OPFS is unavailable"); + const root = await manager.getDirectory(); + const snapshots = await ensureDirectory(root, layout.snapshotsPath); + await removeFile(snapshots, snapshotFileName(revision)); +} + export async function recoverProjectBlend(projectId: string, storage?: StorageManager): Promise { const layout = await ensureProjectLayout(projectId, storage); const manager = (storage ?? navigator.storage) as OpfsStorage; @@ -377,6 +463,71 @@ export async function writeContentAsset(projectId: string, sha256: string, data: return { layout, path, deduplicated: false }; } +export interface OrphanContentAssetCleanupResult { + removed: number; + bytes: number; + paths: string[]; +} + +export async function removeOrphanContentAssets(projectId: string, referencedSha256: ReadonlySet, storage?: StorageManager): Promise { + const layout = projectLayout(projectId); + const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined; + if (!manager?.getDirectory) return { removed: 0, bytes: 0, paths: [] }; + const root = await manager.getDirectory(); + let assets: FileSystemDirectoryHandle; + try { + let current = root; + for (const segment of layout.assetsPath.split("/")) current = await current.getDirectoryHandle(segment); + assets = current; + } + catch (error) { + if (error instanceof DOMException && error.name === "NotFoundError") return { removed: 0, bytes: 0, paths: [] }; + throw error; + } + let removed = 0; + let bytes = 0; + const paths: string[] = []; + const shaRoot = await assets.getDirectoryHandle("sha256", { create: false }).catch((error) => { + if (error instanceof DOMException && error.name === "NotFoundError") return undefined; + throw error; + }); + if (!shaRoot) return { removed, bytes, paths }; + const prefixes = (shaRoot as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries(); + for await (const [prefix, prefixHandle] of prefixes) { + if (prefixHandle.kind !== "directory" || !/^[a-f0-9]{2}$/.test(prefix)) continue; + const directory = prefixHandle as FileSystemDirectoryHandle; + const files = (directory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries(); + for await (const [name, fileHandle] of files) { + if (fileHandle.kind !== "file") continue; + if (/^[a-f0-9]{64}$/.test(name) && referencedSha256.has(name)) continue; + const size = (await (fileHandle as FileSystemFileHandle).getFile()).size; + await directory.removeEntry(name); + removed += 1; + bytes += size; + paths.push(`${layout.assetsPath}/sha256/${prefix}/${name}`); + } + } + return { removed, bytes, paths }; +} + +export async function deleteContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise { + const layout = projectLayout(projectId); + validateSha256(sha256); + const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined; + if (!manager?.getDirectory) return; + const root = await manager.getDirectory(); + try { + let current = root; + for (const segment of `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`.split("/")) { + current = await current.getDirectoryHandle(segment); + } + await current.removeEntry(sha256); + } + catch (error) { + if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error; + } +} + export async function readContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise { const layout = projectLayout(projectId); validateSha256(sha256); diff --git a/web/app/src/testing/editing-domain-recovery.ts b/web/app/src/testing/editing-domain-recovery.ts new file mode 100644 index 00000000..42fd488a --- /dev/null +++ b/web/app/src/testing/editing-domain-recovery.ts @@ -0,0 +1,275 @@ +import { + BoxGeometry, + BufferGeometry, + Float32BufferAttribute, + Line, + LineBasicMaterial, + Mesh, + MeshBasicMaterial, + PerspectiveCamera, + Points, + PointsMaterial, + Scene, + WebGLRenderer, +} from "../vendor/three/three.module.js"; +import type { SceneSnapshotIR } from "../../../protocol/scene-ir"; +import { + EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION, + EDITING_DOMAINS, + parseEditingDomainRecoveryEvidence, + parseEditingDomainRecoverySuite, + summarizeEditingDomain, + type EditingDomain, + type EditingDomainIdentityIR, + type EditingDomainRecoveryEvidenceIR, +} from "../../../protocol/editing-domain-recovery"; +import { WebEngineClient } from "../engine-client/WebEngineClient"; +import { OOMFaultSessionAccessError, assertFault, beginOOMFaultSession } from "./oom-fault-session"; + +type FixtureMap = Partial>; + +function stable(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; + return `{${Object.keys(value as Record).sort().map((key) => `${JSON.stringify(key)}:${stable((value as Record)[key])}`).join(",")}}`; +} + +async function sha256(value: string | ArrayBuffer): Promise { + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value); + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function domainPayload(snapshot: SceneSnapshotIR, domain: EditingDomain, identity: EditingDomainIdentityIR): unknown { + const nodeById = new Map(snapshot.nodes.filter((node) => identity.objectIds.includes(node.id)).map((node) => [node.id, node])); + if (domain === "CURVE") { + return { + nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })), + data: (snapshot.nonMeshData ?? []).filter((data) => identity.dataIds.includes(data.id)).map((data) => ({ + id: data.id, + type: data.type, + pointCount: data.pointCount, + splineCount: data.splineCount, + controlPoints: data.controlPoints, + splineOffsets: data.splineOffsets, + splineTypes: data.splineTypes, + cyclicU: data.cyclicU, + cyclicV: data.cyclicV, + handleTypes: data.handleTypes, + handlePoints: data.handlePoints, + })), + }; + } + if (domain === "GREASE_PENCIL") { + return { + nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })), + data: (snapshot.greasePencils ?? []).filter((data) => identity.dataIds.includes(data.id)), + }; + } + return { + nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })), + data: snapshot.meshes.filter((mesh) => identity.dataIds.includes(mesh.id)).map((mesh) => ({ + id: mesh.id, + vertexCount: mesh.vertexCount, + edgeCount: mesh.edgeCount, + faceCount: mesh.faceCount, + cornerCount: mesh.cornerCount, + attributes: mesh.attributes, + vertexGroups: mesh.vertexGroups, + })), + }; +} + +async function identity(snapshot: SceneSnapshotIR, domain: EditingDomain): Promise { + const summary = summarizeEditingDomain(snapshot, domain); + return { ...summary, revision: snapshot.revision, identityHash: await sha256(stable(domainPayload(snapshot, domain, summary))) }; +} + +function curveCommand(snapshot: SceneSnapshotIR): Parameters[0] { + const data = snapshot.nonMeshData?.find((candidate) => (candidate.type === "CURVE" || candidate.type === "SURFACE") && candidate.controlPoints && candidate.controlPoints.length > 0); + if (data?.controlPoints) return { type: "setCurveControlPoints", dataId: data.id, controlPoints: [...data.controlPoints], splineOffsets: data.splineOffsets, resolution: data.resolution }; + const topology = snapshot.nonMeshData?.find((candidate) => (candidate.type === "CURVE" || candidate.type === "SURFACE") && candidate.splineTypes && candidate.cyclicU); + if (!topology || !topology.splineTypes || !topology.cyclicU) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: CURVE topology"); + return { + type: "setCurveTopology", + dataId: topology.id, + baseRevision: snapshot.revision, + splineTypes: [...topology.splineTypes], + cyclicU: [...topology.cyclicU], + cyclicV: topology.cyclicV ? [...topology.cyclicV] : undefined, + handleTypes: topology.handleTypes ? [...topology.handleTypes] : undefined, + handlePoints: topology.handlePoints ? [...topology.handlePoints] : undefined, + }; +} + +function greasePencilCommand(snapshot: SceneSnapshotIR): Extract[0], { type: "setGreasePencilStrokes" }> { + const data = snapshot.greasePencils?.[0]; + const layer = data?.layers[0]; + const frame = layer?.frames[0]; + if (!data || !layer || !frame) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: Grease Pencil drawing"); + const strokes = frame.drawing.strokes.map((stroke) => { + const points = stroke.points; + if (!points || points.length === 0) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: Grease Pencil points"); + return { + cyclic: stroke.cyclic, + materialIndex: stroke.materialIndex, + points: points.map((point) => ({ position: [...point.position] as [number, number, number], radius: point.radius, opacity: point.opacity, vertexColor: point.vertexColor })), + }; + }); + return { + type: "setGreasePencilStrokes", + dataId: data.id, + layerId: layer.id, + frame: frame.frame, + baseRevision: snapshot.revision, + strokes, + }; +} + +function paintCommand(snapshot: SceneSnapshotIR): Extract[0], { type: "setVertexColors" }> { + const mesh = snapshot.meshes.find((candidate) => candidate.vertexCount > 0); + if (!mesh) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: paint mesh"); + return { type: "setVertexColors", meshId: mesh.id, attributeName: "M9RecoveryColor", domain: "POINT", indices: [0], colors: [0.2, 0.7, 0.9, 1] }; +} + +async function renderProbe(domain: EditingDomain): Promise { + const render = (canvas: HTMLCanvasElement): { renderer: WebGLRenderer; geometry: BufferGeometry; material: LineBasicMaterial | PointsMaterial | MeshBasicMaterial; visiblePixels: number; hash: Promise } => { + const renderer = new WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true }); + renderer.setSize(48, 48, false); + const scene = new Scene(); + const camera = new PerspectiveCamera(45, 1, 0.1, 100); + camera.position.z = 4; + let geometry: BufferGeometry; + let material: LineBasicMaterial | PointsMaterial | MeshBasicMaterial; + if (domain === "CURVE") { + geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute([-1, -0.5, 0, 0, 0.75, 0, 1, -0.25, 0], 3)); + material = new LineBasicMaterial({ color: 0xf0a23b }); + scene.add(new Line(geometry, material)); + } + else if (domain === "GREASE_PENCIL") { + geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute([-0.8, -0.5, 0, 0, 0.7, 0, 0.8, -0.2, 0], 3)); + material = new PointsMaterial({ color: 0x4db6ff, size: 0.14 }); + scene.add(new Points(geometry, material)); + } + else { + geometry = new BoxGeometry(1.2, 1.2, 1.2); + material = new MeshBasicMaterial({ color: 0x71d39a }); + scene.add(new Mesh(geometry, material)); + } + renderer.render(scene, camera); + const gl = renderer.getContext(); + const pixels = new Uint8Array(48 * 48 * 4); + gl.readPixels(0, 0, 48, 48, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + let visiblePixels = 0; + for (let index = 0; index < pixels.length; index += 4) if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 12) visiblePixels++; + return { renderer, geometry, material, visiblePixels, hash: sha256(pixels.buffer) }; + }; + const first = render(document.createElement("canvas")); + const pixelHashBefore = await first.hash; + first.geometry.dispose(); + first.material.dispose(); + first.renderer.dispose(); + const second = render(document.createElement("canvas")); + const pixelHashAfter = await second.hash; + second.geometry.dispose(); + second.material.dispose(); + second.renderer.dispose(); + return { + status: "RECOVERED", + backend: "WEBGL2", + releaseCount: 1, + reinitCount: 1, + disposedResources: 6, + visiblePixels: Math.min(first.visiblePixels, second.visiblePixels), + pixelHashBefore, + pixelHashAfter, + }; +} + +async function runDomain(domain: EditingDomain, input: ArrayBuffer): Promise { + const engine = new WebEngineClient({ timeoutMs: 60_000 }); + let restarted: WebEngineClient | undefined; + try { + const started = await engine.init(); + if (!started.ready) throw new Error(`EDITING_RECOVERY_ENGINE_UNAVAILABLE: ${domain}`); + const opened = await engine.openBlend(input.slice(0)); + const command = domain === "CURVE" ? curveCommand(opened.snapshot) : domain === "GREASE_PENCIL" ? greasePencilCommand(opened.snapshot) : paintCommand(opened.snapshot); + const edited = await engine.applyCommand(command); + const committed = await identity(edited.snapshot, domain); + const saved = await engine.saveBlend(); + + restarted = new WebEngineClient({ timeoutMs: 60_000 }); + const restartedStatus = await restarted.init(); + const reopened = await restarted.openBlend(saved.slice(0)); + const reopenedIdentity = await identity(reopened.snapshot, domain); + if (reopenedIdentity.identityHash !== committed.identityHash) throw new Error(`EDITING_RECOVERY_WORKER_RESTART_FAILED: ${domain} revision ${committed.revision}/${reopenedIdentity.revision} hash ${committed.identityHash}/${reopenedIdentity.identityHash}`); + + const faultSession = beginOOMFaultSession({ point: "GPU_GEOMETRY_UPLOAD", failAfterCount: 0 }); + let unauthorized = false; + try { faultSession.session.reserve(`${faultSession.token}-unauthorized`, "GPU_GEOMETRY_UPLOAD", 1, `${domain.toLowerCase()}-unauthorized`); } + catch (error) { unauthorized = error instanceof OOMFaultSessionAccessError; } + const lease = faultSession.session.reserve(faultSession.token, "GPU_TEXTURE_UPLOAD", 4096, `${domain.toLowerCase()}-baseline`); + let observation; + try { faultSession.session.reserve(faultSession.token, "GPU_GEOMETRY_UPLOAD", 16 * 1024, `${domain.toLowerCase()}-geometry`); } + catch (error) { observation = assertFault(error, "GPU_GEOMETRY_UPLOAD"); } + lease.release(); + const faultStats = faultSession.session.close(faultSession.token); + if (!observation || !unauthorized || faultStats.liveResources !== 0 || faultStats.releasedBytes < 4096) throw new Error(`EDITING_RECOVERY_OOM_FAILED: ${domain}`); + const afterOOM = await restarted.snapshot(); + const afterOOMIdentity = await identity(afterOOM.snapshot, domain); + + const gpuRelease = await renderProbe(domain); + const smallScene: EditingDomainRecoveryEvidenceIR["smallScene"] = { + status: "RECOVERED", + revision: reopenedIdentity.revision, + identityHash: reopenedIdentity.identityHash, + objectCount: reopenedIdentity.objectCount, + dataIds: reopenedIdentity.dataIds, + visiblePixels: gpuRelease.visiblePixels, + }; + const evidence: EditingDomainRecoveryEvidenceIR = { + schemaVersion: EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION, + domain, + baseline: reopenedIdentity, + workerRestart: { + status: "RECOVERED", + workerGeneration: 2, + revisionBefore: committed.revision, + revisionAfter: reopenedIdentity.revision, + hashBefore: committed.identityHash, + hashAfter: reopenedIdentity.identityHash, + liveHandles: restartedStatus.liveHandles, + temporaryResourcesAfter: 0, + }, + oom: { + status: "RECOVERED", + faultPoint: "GPU_GEOMETRY_UPLOAD", + code: "GPU_GEOMETRY_BUDGET_EXCEEDED", + revisionBefore: afterOOMIdentity.revision, + revisionAfter: afterOOMIdentity.revision, + hashBefore: committed.identityHash, + hashAfter: afterOOMIdentity.identityHash, + releasedBytes: faultStats.releasedBytes, + temporaryResourcesAfter: 0, + }, + gpuRelease, + smallScene, + }; + return parseEditingDomainRecoveryEvidence(evidence); + } + finally { + restarted?.terminate(); + engine.terminate(); + } +} + +export async function runEditingDomainRecoverySuite(fixtures: FixtureMap): Promise { + const reports: EditingDomainRecoveryEvidenceIR[] = []; + for (const domain of EDITING_DOMAINS) { + const input = fixtures[domain]; + if (!(input instanceof ArrayBuffer) || input.byteLength === 0) throw new Error(`EDITING_RECOVERY_FIXTURE_INVALID: ${domain}`); + reports.push(await runDomain(domain, input)); + } + return parseEditingDomainRecoverySuite(reports); +} diff --git a/web/app/src/testing/oom-recovery-scenarios.ts b/web/app/src/testing/oom-recovery-scenarios.ts index 300a86b0..616a7afa 100644 --- a/web/app/src/testing/oom-recovery-scenarios.ts +++ b/web/app/src/testing/oom-recovery-scenarios.ts @@ -11,7 +11,7 @@ import { recoverProjectBlend, writeProjectBlend, } from "../storage/opfs-files"; -import { createNanoVDBFloat32GridPaged } from "../render/nanovdb-volume-renderer"; +import { createNanoVDBFloat32GridPaged, createNanoVDBPagedRenderResources } from "../render/nanovdb-volume-renderer"; import { BoxGeometry, BufferGeometry, @@ -411,34 +411,126 @@ async function runNanoVdbScenario(): Promise { }, }); - let observation: OOMFaultObservationIR | undefined; + let pageTableObservation: OOMFaultObservationIR | undefined; try { createNanoVDBFloat32GridPaged(trackedDevice, 128 * 1024, 64 * 1024, 64 * 1024); throw new Error("NanoVDB page-table OOM did not reject the grid"); } catch (error) { - observation = assertFault(error, "NANOVDB_PAGE_TABLE"); + pageTableObservation = assertFault(error, "NANOVDB_PAGE_TABLE"); } const stats = started.session.close(started.token); if (!stats.triggered || stats.liveResources !== 0 || destroyed.get("NanoVDB paged Float32 grid") !== 1) { throw new Error("NanoVDB OOM did not uniquely release the resident buffer"); } - const recovered = createNanoVDBFloat32GridPaged(device, 128 * 1024, 64 * 1024, 64 * 1024); - recovered.uploadPage(0, new ArrayBuffer(64 * 1024)); - recovered.uploadPage(1, new ArrayBuffer(64 * 1024)); - if (!recovered.hasResidentPage(1) || recovered.hasResidentPage(0) || recovered.evictionCount !== 1) throw new Error("NanoVDB resident allocator did not recover on the same device"); + const feedbackStarted = beginOOMFaultSession({ point: "NANOVDB_FEEDBACK_BUFFER", failAfterCount: 1 }); + const feedbackIsolated = proveTokenIsolation(feedbackStarted.session, feedbackStarted.token, "NANOVDB_FEEDBACK_BUFFER"); + const feedbackDestroyed = new Map(); + const bufferLabels = new WeakMap(); + const feedbackTrackedDevice = new Proxy(device, { + get(target, property) { + if (property === "createBuffer") { + return (descriptor: TestGPUBufferDescriptor): GPUBuffer => { + const label = descriptor.label ?? "unlabeled"; + const point: OOMFaultPoint = label.includes("page feedback") + ? "NANOVDB_FEEDBACK_BUFFER" + : label.includes("page table") ? "NANOVDB_PAGE_TABLE" : "NANOVDB_RESIDENT_BUFFER"; + const lease = feedbackStarted.session.reserve(feedbackStarted.token, point, Number(descriptor.size), `nanovdb-feedback-${label}`); + const buffer = target.createBuffer(descriptor); + let released = false; + const tracked = new Proxy(buffer, { + get(bufferTarget, bufferProperty) { + if (bufferProperty === "destroy") { + return () => { + feedbackDestroyed.set(label, (feedbackDestroyed.get(label) ?? 0) + 1); + if (!released) { released = true; lease.release(); } + bufferTarget.destroy(); + }; + } + const current = Reflect.get(bufferTarget, bufferProperty, bufferTarget); + return typeof current === "function" ? current.bind(bufferTarget) : current; + }, + }); + bufferLabels.set(tracked, label); + return tracked; + }; + } + if (property === "queue") { + return new Proxy(target.queue, { + get(queueTarget, queueProperty) { + if (queueProperty === "writeBuffer") { + return (buffer: GPUBuffer, offset: number, data: ArrayBuffer | ArrayBufferView): void => { + if (bufferLabels.get(buffer) === "NanoVDB page feedback") { + feedbackStarted.session.reserve( + feedbackStarted.token, + "NANOVDB_FEEDBACK_BUFFER", + 1, + "nanovdb-feedback-initialization", + ); + } + queueTarget.writeBuffer(buffer, offset, data); + }; + } + const current = Reflect.get(queueTarget, queueProperty, queueTarget); + return typeof current === "function" ? current.bind(queueTarget) : current; + }, + }); + } + const current = Reflect.get(target, property, target); + return typeof current === "function" ? current.bind(target) : current; + }, + }); + let feedbackObservation: OOMFaultObservationIR | undefined; + try { + createNanoVDBPagedRenderResources(feedbackTrackedDevice, 128 * 1024, 64 * 1024, 64 * 1024, 4); + throw new Error("NanoVDB feedback OOM did not reject the resource group"); + } + catch (error) { + feedbackObservation = assertFault(error, "NANOVDB_FEEDBACK_BUFFER"); + } + const feedbackStats = feedbackStarted.session.close(feedbackStarted.token); + for (const label of ["NanoVDB paged Float32 grid", "NanoVDB page table", "NanoVDB page feedback"]) { + if (feedbackDestroyed.get(label) !== 1) throw new Error(`NanoVDB feedback OOM did not release ${label} exactly once`); + } + if (!feedbackStats.triggered || feedbackStats.liveResources !== 0) throw new Error("NanoVDB feedback OOM leaked GPU resources"); + + const recovered = createNanoVDBPagedRenderResources(device, 128 * 1024, 64 * 1024, 64 * 1024, 4); + recovered.grid.uploadPage(0, new ArrayBuffer(64 * 1024)); + recovered.grid.uploadPage(1, new ArrayBuffer(64 * 1024)); + if (!recovered.grid.hasResidentPage(1) || recovered.grid.hasResidentPage(0) || recovered.grid.evictionCount !== 1) throw new Error("NanoVDB resident allocator did not recover on the same device"); + recovered.dispose(); recovered.dispose(); device.destroy(); - if (!observation) throw new Error("NanoVDB OOM observation is missing"); + if (!pageTableObservation || !feedbackObservation) throw new Error("NanoVDB OOM observation is missing"); return { schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, scenario: "NANOVDB_RESIDENT", - faults: [observation], - memory: { beforeBytes: 0, peakBytes: stats.peakBytes, afterBytes: stats.currentBytes, releasedBytes: stats.releasedBytes }, - state: { revisionBefore: 0, revisionAfter: 0, temporaryResourcesBefore: 0, temporaryResourcesPeak: stats.peakResources, temporaryResourcesAfter: stats.liveResources }, - recovery: { recovered: true, sameSession: true, restartedSession: false, tokenIsolated: isolated }, - checks: ["resident-buffer-destroyed-once", "page-table-not-published", "resident-pages-recreated", "same-device-recovers", "lru-eviction-recovers"], + faults: [pageTableObservation, feedbackObservation], + memory: { + beforeBytes: 0, + peakBytes: Math.max(stats.peakBytes, feedbackStats.peakBytes), + afterBytes: stats.currentBytes + feedbackStats.currentBytes, + releasedBytes: stats.releasedBytes + feedbackStats.releasedBytes, + }, + state: { + revisionBefore: 0, + revisionAfter: 0, + temporaryResourcesBefore: 0, + temporaryResourcesPeak: Math.max(stats.peakResources, feedbackStats.peakResources), + temporaryResourcesAfter: stats.liveResources + feedbackStats.liveResources, + }, + recovery: { recovered: true, sameSession: true, restartedSession: false, tokenIsolated: isolated && feedbackIsolated }, + checks: [ + "resident-buffer-destroyed-once", + "page-table-not-published", + "page-table-destroyed-once", + "feedback-buffer-destroyed-once", + "resource-group-dispose-idempotent", + "resident-pages-recreated", + "same-device-recovers", + "lru-eviction-recovers", + ], }; } diff --git a/web/app/src/three-adapter/grease-pencil.ts b/web/app/src/three-adapter/grease-pencil.ts index 4d9b9181..a3a33946 100644 --- a/web/app/src/three-adapter/grease-pencil.ts +++ b/web/app/src/three-adapter/grease-pencil.ts @@ -1,5 +1,6 @@ import { BufferGeometry, + type Camera, Color, Float32BufferAttribute, Group, @@ -7,9 +8,15 @@ import { LineBasicMaterial, Points, PointsMaterial, + Vector3, type Object3D, } from "../vendor/three/three.module.js"; import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil"; +import type { + GreasePencilDrawingScopeIR, + GreasePencilMarqueeCandidateIR, + GreasePencilStablePointRefIR, +} from "../../../protocol/grease-pencil-marquee"; import type { SceneNodeIR } from "../../../protocol/scene-ir"; interface DrawingPreview { @@ -18,13 +25,7 @@ interface DrawingPreview { onion: "NONE" | "PREVIOUS" | "NEXT"; } -export interface GreasePencilPointRef { - dataId: string; - layerId: string; - frame: number; - strokeIndex: number; - pointIndex: number; -} +export type GreasePencilPointRef = GreasePencilStablePointRefIR; export interface GreasePencilPointPreview extends GreasePencilPointRef { position: [number, number, number]; @@ -55,7 +56,7 @@ function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPrevie } function pointKey(point: GreasePencilPointRef): string { - return `${point.dataId}\u0000${point.layerId}\u0000${point.frame}\u0000${point.strokeIndex}\u0000${point.pointIndex}`; + return `${point.drawingId}\u0000${point.strokeId}\u0000${point.pointId}`; } function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, preview: DrawingPreview): number { @@ -101,6 +102,8 @@ function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, pr line.userData.greasePencilPreviewDataId = dataId; line.userData.greasePencilPreviewLayerId = layer.id; line.userData.greasePencilPreviewFrame = preview.frame; + line.userData.greasePencilPreviewDrawingId = preview.drawing.id; + line.userData.greasePencilPreviewStrokeId = stroke.id; line.userData.greasePencilPreviewStrokeIndex = strokeIndex; line.userData.greasePencilPointIndexMap = Array.from({ length: linePointCount }, (_, index) => index % stroke.points!.length); line.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, linePointCount * 3)); @@ -114,8 +117,11 @@ function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, pr points.userData.greasePencilPointDataId = dataId; points.userData.greasePencilPointLayerId = layer.id; points.userData.greasePencilPointFrame = preview.frame; + points.userData.greasePencilPointDrawingId = preview.drawing.id; + points.userData.greasePencilPointStrokeId = stroke.id; points.userData.greasePencilPointStrokeIndex = strokeIndex; points.userData.greasePencilPointIndexMap = Array.from({ length: stroke.points.length }, (_, index) => index); + points.userData.greasePencilPointIdMap = stroke.points.map((point) => point.id); points.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, stroke.points.length * 3)); points.visible = false; (strokeObject ?? group).add(points); @@ -147,9 +153,49 @@ export function greasePencilPointRef(object: Object3D, pointIndex: number): Grea const dataId = object.userData.greasePencilPointDataId; const layerId = object.userData.greasePencilPointLayerId; const frame = object.userData.greasePencilPointFrame; + const drawingId = object.userData.greasePencilPointDrawingId; + const strokeId = object.userData.greasePencilPointStrokeId; const strokeIndex = object.userData.greasePencilPointStrokeIndex; - if (typeof dataId !== "string" || typeof layerId !== "string" || !Number.isSafeInteger(frame) || !Number.isSafeInteger(strokeIndex) || !Number.isSafeInteger(pointIndex) || pointIndex < 0) return null; - return { dataId, layerId, frame, strokeIndex, pointIndex }; + const pointIndexMap = object.userData.greasePencilPointIndexMap as number[] | undefined; + const pointIdMap = object.userData.greasePencilPointIdMap as string[] | undefined; + const stablePointIndex = pointIndexMap?.[pointIndex]; + const pointId = pointIdMap?.[pointIndex]; + if (typeof dataId !== "string" || typeof layerId !== "string" || typeof drawingId !== "string" || + typeof strokeId !== "string" || typeof pointId !== "string" || !Number.isSafeInteger(frame) || + !Number.isSafeInteger(strokeIndex) || !Number.isSafeInteger(stablePointIndex) || + (stablePointIndex ?? -1) < 0) return null; + return { dataId, layerId, frame, drawingId, strokeId, pointId, strokeIndex, pointIndex: stablePointIndex! }; +} + +export function greasePencilMarqueeCandidates( + root: Object3D, + drawing: GreasePencilDrawingScopeIR, + camera: Camera, +): GreasePencilMarqueeCandidateIR[] { + const candidates: GreasePencilMarqueeCandidateIR[] = []; + const worldPosition = new Vector3(); + root.updateMatrixWorld(true); + camera.updateMatrixWorld(true); + root.traverse((object) => { + if (!(object instanceof Points) || object.userData.greasePencilPointDrawingId !== drawing.drawingId || + object.userData.greasePencilPointDataId !== drawing.dataId || + object.userData.greasePencilPointLayerId !== drawing.layerId || + object.userData.greasePencilPointFrame !== drawing.frame) return; + const positions = object.geometry.getAttribute("position"); + for (let index = 0; index < positions.count; index++) { + const point = greasePencilPointRef(object, index); + if (!point) continue; + worldPosition.fromBufferAttribute(positions, index); + object.localToWorld(worldPosition); + worldPosition.project(camera); + if (worldPosition.z < -1 || worldPosition.z > 1) continue; + candidates.push({ + ...point, + viewportPosition: [(worldPosition.x + 1) / 2, (1 - worldPosition.y) / 2], + }); + } + }); + return candidates; } export function applyGreasePencilPointSelection(root: Object3D, selection: readonly GreasePencilPointRef[]): void { @@ -157,11 +203,10 @@ export function applyGreasePencilPointSelection(root: Object3D, selection: reado root.traverse((object) => { if (!(object instanceof Points) || !(object.material instanceof PointsMaterial)) return; const count = object.geometry.getAttribute("position")?.count ?? 0; - const first = greasePencilPointRef(object, 0); - if (!first) return; const colors = new Float32Array(count * 3); for (let pointIndex = 0; pointIndex < count; pointIndex++) { - const active = selected.has(pointKey({ ...first, pointIndex })); + const point = greasePencilPointRef(object, pointIndex); + const active = point ? selected.has(pointKey(point)) : false; colors.set(active ? [1, 0.38, 0.08] : [0.46, 0.73, 1], pointIndex * 3); } object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3)); diff --git a/web/app/src/three-adapter/offscreen-viewport-protocol.ts b/web/app/src/three-adapter/offscreen-viewport-protocol.ts index 6573702f..9bdddd47 100644 --- a/web/app/src/three-adapter/offscreen-viewport-protocol.ts +++ b/web/app/src/three-adapter/offscreen-viewport-protocol.ts @@ -2,10 +2,14 @@ import type { SceneSnapshotIR } from "../../../protocol/scene-ir"; import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-engine"; import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; import type { GPUTextureAsset } from "../../../protocol/render-assets"; +import type { PBRLightingBudgetReport, PBRTextureBudgetReport } from "../../../protocol/render-budget"; import type { NonMeshElementKind } from "./nonmesh"; import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil"; import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction"; import type { NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport"; +import type { ViewportCameraState } from "../../../protocol/viewport-camera"; +import type { GreasePencilDrawingScopeIR, GreasePencilMarqueeBoxIR, GreasePencilMarqueeResultIR } from "../../../protocol/grease-pencil-marquee"; +import type { PaintDepthVisibilityRequestIR, PaintDepthVisibilityResultIR } from "../../../protocol/paint-depth-visibility"; export type OffscreenViewportRequest = | { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number } @@ -13,23 +17,29 @@ export type OffscreenViewportRequest = | { type: "textureAssets"; assets: GPUTextureAsset[] } | { type: "volumeAssets"; assets: NanoVDBViewportAssetIR[] } | { type: "resize"; width: number; height: number; pixelRatio: number } - | { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[] } + | { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[]; greasePencilSelectionRevision: number } | { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode } | { type: "curveHandlePreview"; dataId: string; handles: CurveGizmoHandleIR[] | null } | { type: "greasePencilPointPreview"; dataId: string; layerId: string; frame: number; points: GreasePencilPointPreview[] | null } | { type: "curveGizmoFrame"; dataId: string | null; frame: CurveGizmoFrameIR | null } | { type: "orbit"; deltaX: number; deltaY: number; zoom: number } - | { type: "pick"; x: number; y: number; additive: boolean } + | { type: "pick"; x: number; y: number; additive: boolean; baseSelectionRevision: number } + | { type: "greasePencilMarquee"; drawing: GreasePencilDrawingScopeIR; box: GreasePencilMarqueeBoxIR; baseRevision: number; baseSelectionRevision: number; additive: boolean } + | { type: "paintDepthVisibility"; requestId: string; request: PaintDepthVisibilityRequestIR } | { type: "dispose" }; export type OffscreenViewportResponse = | { type: "ready" } - | { type: "frame"; visiblePixels: number } - | { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number } - | { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[] } + | { type: "frame"; visiblePixels: number; camera?: ViewportCameraState } + | { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number; renderBudget: PBRLightingBudgetReport } + | { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[]; budget: PBRTextureBudgetReport } | { type: "volumeStatus"; status: "none" | "loading" | "ready" | "blocked"; count: number; errorCode?: string } | { type: "selected"; objectId: string; additive: boolean } | { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind } - | { type: "greasePencilPointSelected"; point: GreasePencilPointRef; additive: boolean } + | { type: "greasePencilPointSelected"; point: GreasePencilPointRef; additive: boolean; baseSelectionRevision: number } + | { type: "greasePencilSelectionStatus"; selectionRevision: number; pointIds: string[] } + | { type: "greasePencilMarqueeSelected"; result: GreasePencilMarqueeResultIR; additive: boolean } + | { type: "paintDepthVisibilityResult"; requestId: string; result: PaintDepthVisibilityResultIR } + | { type: "paintDepthVisibilityError"; requestId: string; message: string } | { type: "curveGizmoScreenFrame"; frame: CurveGizmoScreenFrameIR | null } | { type: "error"; message: string }; diff --git a/web/app/src/three-adapter/offscreen-viewport.ts b/web/app/src/three-adapter/offscreen-viewport.ts index ad9ba267..1150fa89 100644 --- a/web/app/src/three-adapter/offscreen-viewport.ts +++ b/web/app/src/three-adapter/offscreen-viewport.ts @@ -11,13 +11,22 @@ import type { NonMeshElementKind } from "./nonmesh"; import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil"; import type { CurveGizmoFrameIR, CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction"; import { cloneNanoVDBViewportAssets, nanoVDBViewportAssetTransferables, type NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport"; +import type { GreasePencilDrawingScopeIR, GreasePencilMarqueeBoxIR, GreasePencilMarqueeResultIR } from "../../../protocol/grease-pencil-marquee"; +import { + validatePaintDepthVisibilityRequest, + validatePaintDepthVisibilityResult, + type PaintDepthVisibilityRequestIR, + type PaintDepthVisibilityResultIR, +} from "../../../protocol/paint-depth-visibility"; export interface ViewportBackend { setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void; setTextureAssets(assets: readonly GPUTextureAsset[]): void; setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void; - setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>, greasePencilPoints?: readonly GreasePencilPointRef[]): void; + setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>, greasePencilPoints?: readonly GreasePencilPointRef[], greasePencilSelectionRevision?: number): void; setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void; + samplePaintVisibility(request: PaintDepthVisibilityRequestIR): Promise; + selectGreasePencilMarquee(drawing: GreasePencilDrawingScopeIR, box: GreasePencilMarqueeBoxIR, baseRevision: number, baseSelectionRevision: number, additive: boolean): void; setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void; setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void; setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void; @@ -41,7 +50,8 @@ export function acquireOffscreenViewportRenderer( canvas: HTMLCanvasElement, onSelect?: (objectId: string, additive: boolean) => void, onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void, - onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void, + onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void, + onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void, ): OffscreenViewportRenderer { const existing = sharedBackends.get(canvas); if (existing) { @@ -50,7 +60,7 @@ export function acquireOffscreenViewportRenderer( existing.references += 1; return existing.renderer; } - const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect); + const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect, onGreasePencilMarqueeSelect); sharedBackends.set(canvas, { renderer, references: 1 }); return renderer; } @@ -73,23 +83,33 @@ export class OffscreenViewportRenderer implements ViewportBackend { private readonly resizeObserver: ResizeObserver; private readonly onSelect?: (objectId: string, additive: boolean) => void; private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void; - private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void; + private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void; + private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void; private pointer: { id: number; x: number; y: number; moved: boolean } | null = null; private lastSnapshot: SceneSnapshotIR | null = null; private lastGeometryBuffers: MeshGeometryBuffer[] | null = null; private lastNonMeshGeometryBuffers: NonMeshGeometryChunk[] | null = null; + private greasePencilSelectionRevision = 0; + private paintDepthRequestSequence = 0; + private readonly pendingPaintDepth = new Map void; + reject: (error: Error) => void; + }>(); constructor( canvas: HTMLCanvasElement, onSelect?: (objectId: string, additive: boolean) => void, onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void, - onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void, + onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void, + onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void, ) { if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable"); this.canvas = canvas; this.onSelect = onSelect; this.onElementSelect = onElementSelect; this.onGreasePencilPointSelect = onGreasePencilPointSelect; + this.onGreasePencilMarqueeSelect = onGreasePencilMarqueeSelect; this.worker = new Worker(new URL("../workers/viewport-render.worker.ts", import.meta.url), { type: "module" }); this.worker.onmessage = (event: MessageEvent) => this.handleMessage(event.data); const offscreen = canvas.transferControlToOffscreen(); @@ -151,6 +171,11 @@ export class OffscreenViewportRenderer implements ViewportBackend { this.canvas.dataset.textureStatus = "none"; this.canvas.dataset.textureLoaded = "0"; this.canvas.dataset.textureBytes = "0"; + this.canvas.dataset.textureBudgetStatus = "ready"; + this.canvas.dataset.textureBudgetCode = ""; + this.canvas.dataset.textureBudgetAssets = "0"; + this.canvas.dataset.textureBudgetPayloadBytes = "0"; + this.canvas.dataset.textureBudgetGpuBytes = "0"; return; } const cloned = assets.map((asset) => ({ ...asset, data: asset.data.slice(0) })); @@ -163,15 +188,35 @@ export class OffscreenViewportRenderer implements ViewportBackend { this.worker.postMessage({ type: "volumeAssets", assets: cloned } satisfies OffscreenViewportRequest, nanoVDBViewportAssetTransferables(cloned)); } - setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>, greasePencilPoints: readonly GreasePencilPointRef[] = []): void { + setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>, greasePencilPoints: readonly GreasePencilPointRef[] = [], greasePencilSelectionRevision = 0): void { const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index })))); - this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints] } satisfies OffscreenViewportRequest); + this.greasePencilSelectionRevision = greasePencilSelectionRevision; + this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints], greasePencilSelectionRevision } satisfies OffscreenViewportRequest); } setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void { this.worker.postMessage({ type: "interaction", editMode, selectionMode } satisfies OffscreenViewportRequest); } + samplePaintVisibility(requestValue: PaintDepthVisibilityRequestIR): Promise { + const request = validatePaintDepthVisibilityRequest(requestValue, this.lastSnapshot?.revision ?? -1); + const requestId = `paint-depth-${++this.paintDepthRequestSequence}`; + return new Promise((resolve, reject) => { + this.pendingPaintDepth.set(requestId, { request, resolve, reject }); + this.worker.postMessage({ type: "paintDepthVisibility", requestId, request } satisfies OffscreenViewportRequest); + }); + } + + selectGreasePencilMarquee( + drawing: GreasePencilDrawingScopeIR, + box: GreasePencilMarqueeBoxIR, + baseRevision: number, + baseSelectionRevision: number, + additive: boolean, + ): void { + this.worker.postMessage({ type: "greasePencilMarquee", drawing, box, baseRevision, baseSelectionRevision, additive } satisfies OffscreenViewportRequest); + } + setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void { this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0"; this.worker.postMessage({ type: "curveHandlePreview", dataId, handles: handles ? handles.map((handle) => ({ ...handle, position: [...handle.position] as [number, number, number] })) : null } satisfies OffscreenViewportRequest); @@ -202,7 +247,12 @@ export class OffscreenViewportRenderer implements ViewportBackend { private pointerDown = (event: PointerEvent): void => { this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false }; - this.canvas.setPointerCapture(event.pointerId); + try { + this.canvas.setPointerCapture(event.pointerId); + } + catch { + // Synthetic test events and browsers without pointer capture still support picking. + } }; private pointerMove = (event: PointerEvent): void => { @@ -221,7 +271,7 @@ export class OffscreenViewportRenderer implements ViewportBackend { const bounds = this.canvas.getBoundingClientRect(); const x = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1; const y = -((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1; - this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey } satisfies OffscreenViewportRequest); + this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey, baseSelectionRevision: this.greasePencilSelectionRevision } satisfies OffscreenViewportRequest); } this.pointer = null; }; @@ -234,20 +284,69 @@ export class OffscreenViewportRenderer implements ViewportBackend { private handleMessage(message: OffscreenViewportResponse): void { if (message.type === "selected") this.onSelect?.(message.objectId, message.additive); else if (message.type === "elementSelected") this.onElementSelect?.(message.meshId, message.mode, message.index, message.additive, message.nonMeshKind); - else if (message.type === "greasePencilPointSelected") this.onGreasePencilPointSelect?.(message.point, message.additive); - else if (message.type === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels); + else if (message.type === "greasePencilPointSelected") this.onGreasePencilPointSelect?.(message.point, message.additive, message.baseSelectionRevision); + else if (message.type === "greasePencilSelectionStatus") { + this.canvas.dataset.greasePencilSelectionRevision = String(message.selectionRevision); + this.canvas.dataset.greasePencilSelectionPointIds = message.pointIds.join(","); + } + else if (message.type === "greasePencilMarqueeSelected") { + this.canvas.dataset.greasePencilMarqueeSelectionRevision = String(message.result.baseSelectionRevision); + this.canvas.dataset.greasePencilMarqueeDrawingId = message.result.drawing.drawingId; + this.canvas.dataset.greasePencilMarqueePointIds = message.result.selectedPoints.map((point) => point.pointId).join(","); + this.canvas.dataset.greasePencilMarqueeStrokeIds = message.result.selectedStrokeIds.join(","); + this.canvas.dataset.greasePencilMarqueeCount = String(message.result.selectedPoints.length); + this.onGreasePencilMarqueeSelect?.(message.result, message.additive); + } + else if (message.type === "paintDepthVisibilityResult") { + const pending = this.pendingPaintDepth.get(message.requestId); + if (!pending) return; + this.pendingPaintDepth.delete(message.requestId); + try { pending.resolve(validatePaintDepthVisibilityResult(message.result, pending.request)); } + catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); } + } + else if (message.type === "paintDepthVisibilityError") { + const pending = this.pendingPaintDepth.get(message.requestId); + if (!pending) return; + this.pendingPaintDepth.delete(message.requestId); + pending.reject(new Error(message.message)); + } + else if (message.type === "frame") { + this.canvas.dataset.rendererPixels = String(message.visiblePixels); + if (message.camera) { + this.canvas.dataset.cameraPosition = message.camera.position.map((value) => Number(value.toFixed(6))).join(","); + this.canvas.dataset.cameraTarget = message.camera.target.map((value) => Number(value.toFixed(6))).join(","); + this.canvas.dataset.cameraYaw = message.camera.yaw.toFixed(6); + this.canvas.dataset.cameraPitch = message.camera.pitch.toFixed(6); + this.canvas.dataset.cameraDistance = message.camera.distance.toFixed(6); + } + } else if (message.type === "snapshotStatus") { this.canvas.dataset.nonMeshCount = String(message.nonMeshCount); this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount); this.canvas.dataset.greasePencilCount = String(message.greasePencilCount); this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount); this.canvas.dataset.greasePencilOnionStrokeCount = String(message.greasePencilOnionStrokeCount); + this.canvas.dataset.renderBudgetBackend = message.renderBudget.backend; + this.canvas.dataset.renderBudgetStatus = message.renderBudget.status.toLowerCase(); + this.canvas.dataset.renderBudgetCode = message.renderBudget.issues[0]?.code ?? ""; + this.canvas.dataset.renderBudgetLights = String(message.renderBudget.requestedLights); + this.canvas.dataset.renderBudgetRenderedLights = String(message.renderBudget.renderedLightNodeIds.length); + this.canvas.dataset.renderBudgetDroppedLights = String(message.renderBudget.droppedLightNodeIds.length); + this.canvas.dataset.renderBudgetShadows = String(message.renderBudget.requestedShadowMaps); + this.canvas.dataset.renderBudgetRenderedShadows = String(message.renderBudget.shadowLightNodeIds.length); + this.canvas.dataset.renderBudgetBlockedShadows = String(message.renderBudget.shadowBlockedLightNodeIds.length); + this.canvas.dataset.renderBudgetShadowMapDimension = String(message.renderBudget.budget.shadowMapDimension); } else if (message.type === "textureStatus") { this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready"; this.canvas.dataset.textureLoaded = String(message.loaded); this.canvas.dataset.textureBytes = String(message.bytes); this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? ""; + this.canvas.dataset.textureBudgetStatus = message.budget.status.toLowerCase(); + this.canvas.dataset.textureBudgetCode = message.budget.issues[0]?.code ?? ""; + this.canvas.dataset.textureBudgetAssets = String(message.budget.requestedAssets); + this.canvas.dataset.textureBudgetPayloadBytes = String(message.budget.payloadBytes); + this.canvas.dataset.textureBudgetGpuBytes = String(message.budget.decodedGPUBytes); } else if (message.type === "volumeStatus") { this.canvas.dataset.volumeStatus = message.status; @@ -269,5 +368,7 @@ export class OffscreenViewportRenderer implements ViewportBackend { this.canvas.removeEventListener("wheel", this.wheel); this.worker.postMessage({ type: "dispose" } satisfies OffscreenViewportRequest); this.worker.terminate(); + for (const pending of this.pendingPaintDepth.values()) pending.reject(new Error("PAINT_DEPTH_UNAVAILABLE: Offscreen viewport disposed")); + this.pendingPaintDepth.clear(); } } diff --git a/web/app/src/three-adapter/paint-depth-visibility.ts b/web/app/src/three-adapter/paint-depth-visibility.ts new file mode 100644 index 00000000..00865702 --- /dev/null +++ b/web/app/src/three-adapter/paint-depth-visibility.ts @@ -0,0 +1,171 @@ +import { + Color, + InstancedMesh, + Matrix4, + MeshDepthMaterial, + RGBADepthPacking, + Vector2, + Vector3, + WebGLRenderTarget, + type Object3D, + type PerspectiveCamera, + type Scene, + type WebGLRenderer, +} from "../vendor/three/three.module.js"; +import { + PAINT_DEPTH_VISIBILITY_BUDGET, + PaintDepthVisibilityError, + validatePaintDepthVisibilityResult, + type PaintDepthVisibilityBackend, + type PaintDepthVisibilityRequestIR, + type PaintDepthVisibilityResultIR, +} from "../../../protocol/paint-depth-visibility"; + +const PACK_DOWNSCALE = 255 / 256; +const DEPTH_TOLERANCE_RATIO = 0.002; + +function unpackRGBADepth(pixels: Uint8Array, offset: number): number { + return (pixels[offset] / 255) * PACK_DOWNSCALE + + (pixels[offset + 1] / 255) * (PACK_DOWNSCALE / 256) + + (pixels[offset + 2] / 255) * (PACK_DOWNSCALE / 65_536) + + (pixels[offset + 3] / 255) / 16_777_216; +} + +function perspectiveDistance(depth: number, near: number, far: number): number { + const viewZ = (near * far) / ((far - near) * depth - far); + return -viewZ; +} + +function depthTargetSize(renderer: WebGLRenderer): { width: number; height: number } { + const size = renderer.getDrawingBufferSize(new Vector2()); + const sourceWidth = Math.max(1, Math.floor(size.x)); + const sourceHeight = Math.max(1, Math.floor(size.y)); + const maxPixels = Math.floor(PAINT_DEPTH_VISIBILITY_BUDGET.maxReadbackBytes / 4); + const scale = Math.min( + 1, + PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension / sourceWidth, + PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension / sourceHeight, + Math.sqrt(maxPixels / (sourceWidth * sourceHeight)), + ); + return { + width: Math.max(1, Math.floor(sourceWidth * scale)), + height: Math.max(1, Math.floor(sourceHeight * scale)), + }; +} + +function candidateMatrix(object: Object3D, objectId: string): Matrix4 { + object.updateWorldMatrix(true, false); + if (!(object instanceof InstancedMesh)) return object.matrixWorld.clone(); + const instanceIds = object.userData.instanceNodeIds as string[] | undefined; + const instanceIndex = instanceIds?.indexOf(objectId) ?? -1; + if (instanceIndex < 0) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint object instance is unavailable"); + const instance = new Matrix4(); + object.getMatrixAt(instanceIndex, instance); + return new Matrix4().multiplyMatrices(object.matrixWorld, instance); +} + +export function samplePaintDepthVisibilityGPU({ + renderer, + scene, + camera, + object, + request, + backend, +}: { + renderer: WebGLRenderer; + scene: Scene; + camera: PerspectiveCamera; + object: Object3D; + request: PaintDepthVisibilityRequestIR; + backend: PaintDepthVisibilityBackend; +}): PaintDepthVisibilityResultIR { + if (!renderer.capabilities.isWebGL2) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint visibility requires a WebGL2 depth pass"); + const meshId = object.userData.meshId; + const sourcePositions = object.userData.sourcePositions as number[] | undefined; + if (meshId !== request.meshId || !sourcePositions || sourcePositions.length % 3 !== 0) { + throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint source geometry is unavailable"); + } + const vertexCount = sourcePositions.length / 3; + if (request.vertexIndices.some((index) => index >= vertexCount)) { + throw new PaintDepthVisibilityError("PAINT_SCHEMA_INVALID", "Paint depth request references an unknown vertex"); + } + + const { width, height } = depthTargetSize(renderer); + const target = new WebGLRenderTarget(width, height, { depthBuffer: true, stencilBuffer: false }); + const depthMaterial = new MeshDepthMaterial({ depthPacking: RGBADepthPacking }); + const pixels = new Uint8Array(width * height * 4); + const previousTarget = renderer.getRenderTarget(); + const previousOverride = scene.overrideMaterial; + const previousBackground = scene.background; + const previousClearColor = renderer.getClearColor(new Color()).clone(); + const previousClearAlpha = renderer.getClearAlpha(); + const hidden: Object3D[] = []; + + try { + scene.traverse((candidate) => { + const renderable = candidate as Object3D & { isLine?: boolean; isPoints?: boolean; isSprite?: boolean }; + if (candidate.visible && (renderable.isLine || renderable.isPoints || renderable.isSprite || candidate.userData.nanoVDBVolume)) { + hidden.push(candidate); + candidate.visible = false; + } + }); + scene.background = null; + scene.overrideMaterial = depthMaterial; + renderer.setClearColor(0xffffff, 1); + renderer.setRenderTarget(target); + renderer.clear(true, true, true); + camera.updateMatrixWorld(true); + scene.updateMatrixWorld(true); + renderer.render(scene, camera); + renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels); + } + catch (error) { + throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", error instanceof Error ? error.message : "GPU depth readback failed"); + } + finally { + renderer.setRenderTarget(previousTarget); + renderer.setClearColor(previousClearColor, previousClearAlpha); + scene.overrideMaterial = previousOverride; + scene.background = previousBackground; + for (const candidate of hidden) candidate.visible = true; + depthMaterial.dispose(); + target.dispose(); + } + + let occluderPixelCount = 0; + for (let offset = 0; offset < pixels.length; offset += 4) { + if (unpackRGBADepth(pixels, offset) < 1 - 1e-7) occluderPixelCount++; + } + if (occluderPixelCount === 0) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "GPU depth pass produced no mesh coverage"); + + const modelMatrix = candidateMatrix(object, request.objectId); + const visibleVertexIndices: number[] = []; + for (const index of request.vertexIndices) { + const sourceOffset = index * 3; + const world = new Vector3( + sourcePositions[sourceOffset], + sourcePositions[sourceOffset + 2], + -sourcePositions[sourceOffset + 1], + ).applyMatrix4(modelMatrix); + const viewDistance = -world.clone().applyMatrix4(camera.matrixWorldInverse).z; + const projected = world.project(camera); + if (projected.x < -1 || projected.x > 1 || projected.y < -1 || projected.y > 1 || projected.z < -1 || projected.z > 1 || viewDistance <= 0) continue; + const x = Math.max(0, Math.min(width - 1, Math.round((projected.x * 0.5 + 0.5) * (width - 1)))); + const y = Math.max(0, Math.min(height - 1, Math.round((projected.y * 0.5 + 0.5) * (height - 1)))); + const depth = unpackRGBADepth(pixels, (y * width + x) * 4); + const sampledDistance = perspectiveDistance(depth, camera.near, camera.far); + const tolerance = Math.max(1e-4, viewDistance * DEPTH_TOLERANCE_RATIO); + if (viewDistance <= sampledDistance + tolerance) visibleVertexIndices.push(index); + } + + return validatePaintDepthVisibilityResult({ + ...request, + backend, + source: "GPU_RGBA_DEPTH_READBACK", + width, + height, + depthReadbackBytes: pixels.byteLength, + occluderPixelCount, + visibleVertexIndices, + }, request); +} diff --git a/web/app/src/three-adapter/pbr.ts b/web/app/src/three-adapter/pbr.ts index 0f0bb388..94fd9cca 100644 --- a/web/app/src/three-adapter/pbr.ts +++ b/web/app/src/three-adapter/pbr.ts @@ -6,6 +6,7 @@ import { MeshPhysicalMaterial, Object3D, PCFShadowMap, + PerspectiveCamera, PointLight, RectAreaLight, SRGBColorSpace, @@ -14,11 +15,14 @@ import { type Light, type WebGLRenderer, } from "../vendor/three/three.module.js"; -import type { LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir"; +import type { CameraIR, LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir"; +import { compileMaterialGraph, type ShaderCompileContext, type ShaderCompileReport } from "../../../protocol/shader-compiler"; +import { PBR_RENDER_BUDGETS } from "../../../protocol/render-budget"; export const PBR_PROFILE = "physical-v1"; export const PBR_TONE_MAPPING = "aces"; export const PBR_SHADOW_PROFILE = "pcf-1024"; +export const PBR_SHADOW_MAP_DIMENSION = PBR_RENDER_BUDGETS.THREE_WEBGL2.shadowMapDimension; function clamp(value: number | undefined, minimum: number, maximum: number, fallback: number): number { return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value as number)) : fallback; @@ -32,23 +36,36 @@ export function configurePBRRenderer(renderer: WebGLRenderer, exposure = 0): voi renderer.shadowMap.type = PCFShadowMap; } -export function createPBRMaterial(definition?: MaterialIR, active = false): MeshPhysicalMaterial { - const baseColor = definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]); - const emission = definition?.emissionColor ?? [0, 0, 0, 1]; - const alpha = clamp(definition?.alpha ?? baseColor[3], 0, 1, 1); - const transmission = clamp(definition?.transmissionWeight, 0, 1, 0); +export function configurePBRCamera(camera: PerspectiveCamera, definition: CameraIR): void { + const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm; + const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI; + camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov; + camera.near = Math.max(0.0001, definition.near); + camera.far = Math.max(camera.near + 0.001, definition.far); + camera.filmGauge = sensor; + camera.filmOffset = definition.shift[0] * sensor; + camera.updateProjectionMatrix(); +} + +export function createPBRMaterial(definition?: MaterialIR, active = false, shaderContext: ShaderCompileContext = {}): MeshPhysicalMaterial { + const compileReport = definition?.nodes?.length ? compileMaterialGraph(definition, shaderContext) : undefined; + const compiled = compileReport?.status === "COMPILED" ? compileReport.material : undefined; + const baseColor = compiled?.baseColor ?? definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]); + const emission = compiled?.emissionColor ?? definition?.emissionColor ?? [0, 0, 0, 1]; + const alpha = clamp(compiled?.alpha ?? definition?.alpha ?? baseColor[3], 0, 1, 1); + const transmission = clamp(compiled?.transmissionWeight ?? definition?.transmissionWeight, 0, 1, 0); const material = new MeshPhysicalMaterial({ color: new Color().setRGB(baseColor[0], baseColor[1], baseColor[2]), - roughness: clamp(definition?.roughness, 0, 1, 0.45), - metalness: clamp(definition?.metallic, 0, 1, 0.05), - ior: clamp(definition?.ior, 1, 2.333, 1.45), + roughness: clamp(compiled?.roughness ?? definition?.roughness, 0, 1, 0.45), + metalness: clamp(compiled?.metallic ?? definition?.metallic, 0, 1, 0.05), + ior: clamp(compiled?.ior ?? definition?.ior, 1, 2.333, 1.45), // Blender's neutral Specular IOR Level is 0.5; Three's neutral multiplier is 1.0. - specularIntensity: clamp((definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1), - clearcoat: clamp(definition?.coatWeight, 0, 1, 0), - clearcoatRoughness: clamp(definition?.coatRoughness, 0, 1, 0.03), + specularIntensity: clamp((compiled?.specularIORLevel ?? definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1), + clearcoat: clamp(compiled?.coatWeight ?? definition?.coatWeight, 0, 1, 0), + clearcoatRoughness: clamp(compiled?.coatRoughness ?? definition?.coatRoughness, 0, 1, 0.03), transmission, emissive: new Color().setRGB(emission[0], emission[1], emission[2]), - emissiveIntensity: clamp(definition?.emissionStrength, 0, 1_000_000, 1), + emissiveIntensity: clamp(compiled?.emissionStrength ?? definition?.emissionStrength, 0, 1_000_000, 1), opacity: alpha, transparent: alpha < 0.999, depthWrite: alpha >= 0.999, @@ -58,9 +75,47 @@ export function createPBRMaterial(definition?: MaterialIR, active = false): Mesh material.userData.baseEmissive = material.emissive.getHex(); material.userData.baseEmissiveIntensity = material.emissiveIntensity; material.userData.pbrProfile = PBR_PROFILE; + if (compileReport) { + material.userData.shaderCompile = compileReport as ShaderCompileReport; + material.userData.shaderCompileTextures = compileReport.status === "COMPILED" ? compileReport.textureBindings : []; + } return material; } +export interface PBRMaterialPipelineUpdate { + material: MeshPhysicalMaterial; + report?: ShaderCompileReport; + replaced: boolean; +} + +/** Keeps the last compiled material alive when a later graph fails closed. */ +export class PBRMaterialPipeline { + private current: MeshPhysicalMaterial | null = null; + + get material(): MeshPhysicalMaterial | null { + return this.current; + } + + update(definition?: MaterialIR, active = false, shaderContext: ShaderCompileContext = {}): PBRMaterialPipelineUpdate { + const candidate = createPBRMaterial(definition, active, shaderContext); + const report = candidate.userData.shaderCompile as ShaderCompileReport | undefined; + if (report?.status === "BLOCKED" && this.current) { + this.current.userData.shaderCompileFailure = report; + candidate.dispose(); + return { material: this.current, report, replaced: false }; + } + const previous = this.current; + this.current = candidate; + previous?.dispose(); + return { material: candidate, report, replaced: previous !== null }; + } + + dispose(): void { + this.current?.dispose(); + this.current = null; + } +} + export function setPBRMaterialSelected(material: MeshPhysicalMaterial, selected: boolean): void { const baseEmissive = typeof material.userData.baseEmissive === "number" ? material.userData.baseEmissive : 0; const baseIntensity = typeof material.userData.baseEmissiveIntensity === "number" ? material.userData.baseEmissiveIntensity : 1; @@ -108,7 +163,12 @@ export function createPBRLight(definition: LightIR): Light { return light; } -export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Object3D): void { +export function configurePBRLight( + light: Light, + node: SceneNodeIR, + parent: Object3D, + options: { shadowEnabled?: boolean; shadowMapDimension?: number } = {}, +): void { const [x, y, z] = node.transform.translation; light.position.set(x, z, -y); light.rotation.set(node.transform.rotationEuler[0], node.transform.rotationEuler[2], -node.transform.rotationEuler[1]); @@ -117,13 +177,15 @@ export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Objec light.decay = 2; } light.userData.blenderCastsShadow = definitionCastsShadow(light); - if ((light instanceof DirectionalLight || light instanceof SpotLight) && light.userData.blenderCastsShadow) { + const shadowEnabled = options.shadowEnabled ?? light.userData.blenderCastsShadow; + light.userData.pbrShadowBudgetBlocked = light.userData.blenderCastsShadow && !shadowEnabled; + if ((light instanceof DirectionalLight || light instanceof SpotLight) && shadowEnabled) { const target = new Object3D(); const forward = new Vector3(0, -1, 0).applyEuler(light.rotation); target.position.copy(light.position).add(forward); light.target = target; light.castShadow = true; - light.shadow.mapSize.set(1024, 1024); + light.shadow.mapSize.set(options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION, options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION); light.shadow.bias = -0.0005; light.shadow.normalBias = 0.03; light.shadow.camera.near = 0.05; @@ -136,9 +198,9 @@ export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Objec } parent.add(target); } - else if (light instanceof PointLight && light.userData.blenderCastsShadow) { + else if (light instanceof PointLight && shadowEnabled) { light.castShadow = true; - light.shadow.mapSize.set(1024, 1024); + light.shadow.mapSize.set(options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION, options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION); light.shadow.bias = -0.0005; light.shadow.normalBias = 0.03; light.shadow.camera.near = 0.05; diff --git a/web/app/src/three-adapter/render-image-comparison.ts b/web/app/src/three-adapter/render-image-comparison.ts new file mode 100644 index 00000000..7d9e636d --- /dev/null +++ b/web/app/src/three-adapter/render-image-comparison.ts @@ -0,0 +1,9 @@ +export { + compareRenderImages, + MAX_RENDER_COMPARISON_DIMENSION, + MAX_RENDER_COMPARISON_PIXELS, + RENDER_IMAGE_COMPARISON_SCHEMA_VERSION, + RENDER_REFERENCE_MISMATCH_CODE, + type RenderImageComparisonIR, + type RenderImageComparisonThresholdsIR, +} from "../../../protocol/render-image-comparison"; diff --git a/web/app/src/three-adapter/render-routing.ts b/web/app/src/three-adapter/render-routing.ts new file mode 100644 index 00000000..2651390e --- /dev/null +++ b/web/app/src/three-adapter/render-routing.ts @@ -0,0 +1,10 @@ +export { + routeRenderExecution, + RENDER_ROUTING_SCHEMA_VERSION, + type RenderRoutingBackend, + type RenderRoutingContextIR, + type RenderRoutingEngine, + type RenderRoutingRequestIR, + type RenderRoutingResultIR, + type RenderRoutingTarget, +} from "../../../protocol/render-routing"; diff --git a/web/app/src/three-adapter/texture-assets.ts b/web/app/src/three-adapter/texture-assets.ts index 24eb1a57..4efff5a6 100644 --- a/web/app/src/three-adapter/texture-assets.ts +++ b/web/app/src/three-adapter/texture-assets.ts @@ -11,6 +11,7 @@ import { } from "../vendor/three/three.module.js"; import type { MaterialIR, SceneSnapshotIR, WorldIR } from "../../../protocol/scene-ir"; import { RenderAssetValidationError, validateGPUTextureAsset, type GPUTextureAsset, type GPUTextureColorSpace, type GPUTextureUsage } from "../../../protocol/render-assets"; +import { planPBRTextureBudget, type PBRDeviceLimits, type PBRRenderBackend, type PBRTextureBudgetReport } from "../../../protocol/render-budget"; export interface TextureUploadStatus { loaded: number; @@ -18,6 +19,7 @@ export interface TextureUploadStatus { bytes: number; errors: string[]; errorCodes: string[]; + budget: PBRTextureBudgetReport; } function key(imageId: string, usage: GPUTextureUsage): string { @@ -48,6 +50,11 @@ export class GPUTextureStore { private readonly udimTileCounts = new Map(); private revision = 0; + constructor( + private readonly backend: PBRRenderBackend = "THREE_WEBGL2", + private readonly deviceLimits: PBRDeviceLimits = {}, + ) {} + getRevision(): number { return this.revision; } @@ -61,7 +68,20 @@ export class GPUTextureStore { } async upload(assets: readonly GPUTextureAsset[]): Promise { - const status: TextureUploadStatus = { loaded: 0, rejected: 0, bytes: 0, errors: [], errorCodes: [] }; + const candidate = new Map(this.assets); + for (const asset of assets) { + const lookupUsage = asset.usage === "UDIM_TILE" ? "BASE_COLOR" : asset.usage; + candidate.set(key(asset.imageId, lookupUsage), asset); + } + const budget = planPBRTextureBudget([...candidate.values()], this.backend, this.deviceLimits); + const status: TextureUploadStatus = { loaded: 0, rejected: 0, bytes: 0, errors: [], errorCodes: [], budget }; + if (budget.status === "BLOCKED") { + status.rejected = assets.length; + status.errors.push(...budget.issues.map((issue) => issue.message)); + status.errorCodes.push(...budget.issues.map((issue) => issue.code)); + this.revision += 1; + return status; + } const incomingUDIMCounts = new Map(); for (const asset of assets) if (asset.usage === "UDIM_TILE") incomingUDIMCounts.set(asset.imageId, (incomingUDIMCounts.get(asset.imageId) ?? 0) + 1); for (const asset of assets) { @@ -92,12 +112,17 @@ export class GPUTextureStore { applyMaterial(material: MeshPhysicalMaterial, definition?: MaterialIR): void { if (!definition) return; - const baseImageId = definition.imageIds?.find((imageId) => imageId !== definition.normalImageId) ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId && node.imageId !== definition.normalImageId)?.imageId; + const compile = material.userData.shaderCompile as { status?: string; textureBindings?: Array<{ imageId: string; usage: "BASE_COLOR" | "NORMAL" }> } | undefined; + const baseImageId = compile?.status === "COMPILED" + ? compile.textureBindings?.find((binding) => binding.usage === "BASE_COLOR")?.imageId + : definition.imageIds?.find((imageId) => imageId !== definition.normalImageId) ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId && node.imageId !== definition.normalImageId)?.imageId; if (baseImageId) { const texture = this.get(baseImageId, "BASE_COLOR"); if (texture) material.map = texture; } - const normalImageId = definition.normalImageId ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId)?.imageId; + const normalImageId = compile?.status === "COMPILED" + ? compile.textureBindings?.find((binding) => binding.usage === "NORMAL")?.imageId + : definition.normalImageId ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId)?.imageId; if (normalImageId) { const texture = this.get(normalImageId, "NORMAL"); if (texture) material.normalMap = texture; diff --git a/web/app/src/three-adapter/viewport.ts b/web/app/src/three-adapter/viewport.ts index 49c25fe9..90f5cc29 100644 --- a/web/app/src/three-adapter/viewport.ts +++ b/web/app/src/three-adapter/viewport.ts @@ -30,10 +30,12 @@ import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } fro import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod"; import { configurePBRLight, + configurePBRCamera, configurePBRRenderer, createPBRLight, createPBRMaterial, PBR_PROFILE, + PBR_SHADOW_MAP_DIMENSION, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING, setPBRMaterialSelected, @@ -41,6 +43,7 @@ import { import { GPUTextureStore } from "./texture-assets"; import type { GPUTextureAsset } from "../../../protocol/render-assets"; import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets"; +import { planPBRLightingBudget, type PBRLightingBudgetReport } from "../../../protocol/render-budget"; import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh"; import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction"; @@ -52,10 +55,20 @@ import { applyGreasePencilPointPreview, applyGreasePencilTransform, createGreasePencilObject, + greasePencilMarqueeCandidates, greasePencilPointRef, type GreasePencilPointRef, type GreasePencilPointPreview, } from "./grease-pencil"; +import { + selectGreasePencilMarquee, + type GreasePencilDrawingScopeIR, + type GreasePencilMarqueeBoxIR, + type GreasePencilMarqueeResultIR, +} from "../../../protocol/grease-pencil-marquee"; +import { VIEWPORT_DEFAULT_ORBIT, VIEWPORT_ORBIT_MAX_DISTANCE, VIEWPORT_ORBIT_MIN_DISTANCE, VIEWPORT_ORBIT_ROTATE_SENSITIVITY, VIEWPORT_ORBIT_ZOOM_SENSITIVITY, orbitPosition, orbitStateFromPosition } from "../../../protocol/viewport-camera"; +import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR, type PaintDepthVisibilityResultIR } from "../../../protocol/paint-depth-visibility"; +import { samplePaintDepthVisibilityGPU } from "./paint-depth-visibility"; export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map { const groups = new Map(); @@ -86,14 +99,16 @@ export class ViewportRenderer { private readonly objectByBlenderId = new Map(); private readonly instanceIndexByBlenderId = new Map(); private readonly lodAdapter = new ThreeLODAdapter(); - private readonly textureStore = new GPUTextureStore(); + private readonly textureStore = new GPUTextureStore("THREE_WEBGL2"); private readonly raycaster = new Raycaster(); private readonly pointer = new Vector2(); private readonly onSelect?: (objectId: string, additive: boolean) => void; private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void; - private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void; + private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void; + private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void; private editMode = false; private selectionMode: MeshElementMode = "FACE"; + private greasePencilSelectionRevision = 0; private curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null; private curveGizmoScreenFrame = ""; private volumeAssets: NanoVDBViewportAssetIR[] = []; @@ -105,12 +120,14 @@ export class ViewportRenderer { canvas: HTMLCanvasElement, onSelect?: (objectId: string, additive: boolean) => void, onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void, - onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void, + onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void, + onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void, ) { this.canvas = canvas; this.onSelect = onSelect; this.onElementSelect = onElementSelect; this.onGreasePencilPointSelect = onGreasePencilPointSelect; + this.onGreasePencilMarqueeSelect = onGreasePencilMarqueeSelect; this.volumeRenderSession = new NanoVDBViewportRenderSession(() => { if (this.disposed) return; this.volumeRenderCache.clear(); @@ -129,16 +146,20 @@ export class ViewportRenderer { this.canvas.dataset.deviceStatus = "ready"; this.scene = new Scene(); this.camera = new PerspectiveCamera(45, 1, 0.01, 1000); - this.camera.position.set(4.5, -4.5, 3.5); + this.camera.position.set(...orbitPosition(VIEWPORT_DEFAULT_ORBIT)); this.controls = new OrbitControls(this.camera, canvas); - this.controls.target.set(0, 0, 0); - this.controls.enableDamping = true; + this.controls.target.set(...VIEWPORT_DEFAULT_ORBIT.target); + this.controls.enableDamping = false; + this.controls.enablePan = false; + this.controls.minDistance = VIEWPORT_ORBIT_MIN_DISTANCE; + this.controls.maxDistance = VIEWPORT_ORBIT_MAX_DISTANCE; + this.controls.zoomSpeed = VIEWPORT_ORBIT_ZOOM_SENSITIVITY / (0.01 * -Math.log(0.95)); this.scene.add(new HemisphereLight(0xf2f5ff, 0x3a4149, 0.55)); const keyLight = new DirectionalLight(0xffffff, 2.5); keyLight.position.set(4, -5, 8); keyLight.castShadow = true; - keyLight.shadow.mapSize.set(1024, 1024); + keyLight.shadow.mapSize.set(PBR_SHADOW_MAP_DIMENSION, PBR_SHADOW_MAP_DIMENSION); keyLight.shadow.bias = -0.0005; keyLight.shadow.normalBias = 0.03; this.scene.add(keyLight, keyLight.target); @@ -152,6 +173,8 @@ export class ViewportRenderer { this.canvas.addEventListener("webglcontextlost", this.handleContextLost); this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored); this.resize(); + this.controls.update(); + this.publishCameraState(); this.renderLoop(); } @@ -176,7 +199,9 @@ export class ViewportRenderer { this.clearImportedScene(); this.applyWorld(snapshot); this.applyCamera(snapshot); - this.populateLights(snapshot); + const lightingBudget = planPBRLightingBudget(snapshot, "THREE_WEBGL2"); + this.publishLightingBudget(lightingBudget); + this.populateLights(snapshot, lightingBudget); this.populateNonMesh(snapshot, nonMeshGeometryBuffers); this.populateGreasePencils(snapshot); const meshes = new Map(snapshot.meshes.map((mesh) => [mesh.id, mesh])); @@ -340,6 +365,11 @@ export class ViewportRenderer { this.canvas.dataset.textureStatus = "none"; this.canvas.dataset.textureLoaded = "0"; this.canvas.dataset.textureBytes = "0"; + this.canvas.dataset.textureBudgetStatus = "ready"; + this.canvas.dataset.textureBudgetCode = ""; + this.canvas.dataset.textureBudgetAssets = "0"; + this.canvas.dataset.textureBudgetPayloadBytes = "0"; + this.canvas.dataset.textureBudgetGpuBytes = "0"; return; } void this.textureStore.upload(assets).then((status) => { @@ -347,6 +377,11 @@ export class ViewportRenderer { this.canvas.dataset.textureLoaded = String(status.loaded); this.canvas.dataset.textureBytes = String(status.bytes); this.canvas.dataset.textureErrorCode = status.errorCodes[0] ?? ""; + this.canvas.dataset.textureBudgetStatus = status.budget.status.toLowerCase(); + this.canvas.dataset.textureBudgetCode = status.budget.issues[0]?.code ?? ""; + this.canvas.dataset.textureBudgetAssets = String(status.budget.requestedAssets); + this.canvas.dataset.textureBudgetPayloadBytes = String(status.budget.payloadBytes); + this.canvas.dataset.textureBudgetGpuBytes = String(status.budget.decodedGPUBytes); if (!this.currentSnapshot) return; this.textureStore.applySnapshotMaterials(this.importedRoot, this.currentSnapshot); const worldId = this.currentSnapshot.scenes[0]?.worldId; @@ -449,6 +484,7 @@ export class ViewportRenderer { objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>, greasePencilPoints: readonly GreasePencilPointRef[] = [], + greasePencilSelectionRevision = 0, ): void { const visitedInstances = new Set(); for (const [objectId, object] of this.objectByBlenderId) { @@ -471,6 +507,9 @@ export class ViewportRenderer { } applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map()); applyGreasePencilPointSelection(this.importedRoot, greasePencilPoints); + this.greasePencilSelectionRevision = greasePencilSelectionRevision; + this.canvas.dataset.greasePencilSelectionRevision = String(greasePencilSelectionRevision); + this.canvas.dataset.greasePencilSelectionPointIds = greasePencilPoints.map((point) => point.pointId).join(","); } setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void { @@ -481,6 +520,44 @@ export class ViewportRenderer { }); } + async samplePaintVisibility(requestValue: PaintDepthVisibilityRequestIR): Promise { + const request = validatePaintDepthVisibilityRequest(requestValue, this.currentSnapshot?.revision ?? -1); + const node = this.currentSnapshot?.nodes.find((candidate) => candidate.id === request.objectId && candidate.dataId === request.meshId && candidate.visible); + const object = node ? this.objectByBlenderId.get(node.id) : undefined; + if (!object) throw new Error("PAINT_DEPTH_UNAVAILABLE: Paint object is not available in the current viewport"); + return samplePaintDepthVisibilityGPU({ + renderer: this.renderer, + scene: this.scene, + camera: this.camera, + object, + request, + backend: "MAIN_THREAD_WEBGL2", + }); + } + + selectGreasePencilMarquee( + drawing: GreasePencilDrawingScopeIR, + box: GreasePencilMarqueeBoxIR, + baseRevision: number, + baseSelectionRevision: number, + additive: boolean, + ): void { + const result = selectGreasePencilMarquee({ + schemaVersion: 1, + baseRevision, + baseSelectionRevision, + drawing, + box, + candidates: greasePencilMarqueeCandidates(this.importedRoot, drawing, this.camera), + }, this.currentSnapshot?.revision ?? -1); + this.canvas.dataset.greasePencilMarqueeSelectionRevision = String(result.baseSelectionRevision); + this.canvas.dataset.greasePencilMarqueeDrawingId = result.drawing.drawingId; + this.canvas.dataset.greasePencilMarqueePointIds = result.selectedPoints.map((point) => point.pointId).join(","); + this.canvas.dataset.greasePencilMarqueeStrokeIds = result.selectedStrokeIds.join(","); + this.canvas.dataset.greasePencilMarqueeCount = String(result.selectedPoints.length); + this.onGreasePencilMarqueeSelect?.(result, additive); + } + setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void { applyCurveHandlePreview(this.importedRoot, dataId, handles); this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0"; @@ -675,24 +752,35 @@ export class ViewportRenderer { const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA"); const definition = snapshot.cameras.find((camera) => camera.id === cameraNode?.dataId); if (!cameraNode || !definition) return; - const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm; - const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI; - this.camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov; - this.camera.near = Math.max(0.0001, definition.near); - this.camera.far = Math.max(this.camera.near + 0.001, definition.far); - this.camera.filmGauge = sensor; - this.camera.filmOffset = definition.shift[0] * sensor; - this.camera.updateProjectionMatrix(); + configurePBRCamera(this.camera, definition); } - private populateLights(snapshot: SceneSnapshotIR): void { + private publishLightingBudget(report: PBRLightingBudgetReport): void { + this.canvas.dataset.renderBudgetBackend = report.backend; + this.canvas.dataset.renderBudgetStatus = report.status.toLowerCase(); + this.canvas.dataset.renderBudgetCode = report.issues[0]?.code ?? ""; + this.canvas.dataset.renderBudgetLights = String(report.requestedLights); + this.canvas.dataset.renderBudgetRenderedLights = String(report.renderedLightNodeIds.length); + this.canvas.dataset.renderBudgetDroppedLights = String(report.droppedLightNodeIds.length); + this.canvas.dataset.renderBudgetShadows = String(report.requestedShadowMaps); + this.canvas.dataset.renderBudgetRenderedShadows = String(report.shadowLightNodeIds.length); + this.canvas.dataset.renderBudgetBlockedShadows = String(report.shadowBlockedLightNodeIds.length); + this.canvas.dataset.renderBudgetShadowMapDimension = String(report.budget.shadowMapDimension); + } + + private populateLights(snapshot: SceneSnapshotIR, budget = planPBRLightingBudget(snapshot, "THREE_WEBGL2")): void { const lights = new Map(snapshot.lights.map((light) => [light.id, light])); + const rendered = new Set(budget.renderedLightNodeIds); + const shadowed = new Set(budget.shadowLightNodeIds); for (const node of snapshot.nodes) { - if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue; + if (node.type !== "LIGHT" || !node.visible || !node.dataId || !rendered.has(node.id)) continue; const definition = lights.get(node.dataId); if (!definition) continue; const light = createPBRLight(definition); - configurePBRLight(light, node, this.importedLights); + configurePBRLight(light, node, this.importedLights, { + shadowEnabled: shadowed.has(node.id), + shadowMapDimension: budget.budget.shadowMapDimension, + }); light.name = node.name; light.userData.sceneNodeId = node.id; light.userData.blenderId = node.id; @@ -707,7 +795,18 @@ export class ViewportRenderer { const height = Math.max(1, this.canvas.clientHeight); this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); + this.controls.rotateSpeed = VIEWPORT_ORBIT_ROTATE_SENSITIVITY * height / (2 * Math.PI); this.renderer.setSize(width, height, false); + this.publishCameraState(); + } + + private publishCameraState(): void { + const orbit = orbitStateFromPosition(this.camera.position.toArray(), this.controls.target.toArray()); + this.canvas.dataset.cameraPosition = this.camera.position.toArray().map((value) => Number(value.toFixed(6))).join(","); + this.canvas.dataset.cameraTarget = orbit.target.map((value) => Number(value.toFixed(6))).join(","); + this.canvas.dataset.cameraYaw = orbit.yaw.toFixed(6); + this.canvas.dataset.cameraPitch = orbit.pitch.toFixed(6); + this.canvas.dataset.cameraDistance = orbit.distance.toFixed(6); } private handleClick = (event: MouseEvent): void => { @@ -724,7 +823,7 @@ export class ViewportRenderer { : undefined; if (greasePencilHit?.index !== undefined) { const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index); - if (point) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey); + if (point) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey, this.greasePencilSelectionRevision); return; } const preferredNonMeshHit = this.editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined; @@ -791,6 +890,7 @@ export class ViewportRenderer { if (this.disposed) return; if (!this.contextLost) { this.controls.update(); + this.publishCameraState(); this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight)); this.renderer.render(this.scene, this.camera); this.publishCurveGizmoFrame(); diff --git a/web/app/src/vendor/blender/web_engine.js b/web/app/src/vendor/blender/web_engine.js index 25c22ee2..8616e241 100644 --- a/web/app/src/vendor/blender/web_engine.js +++ b/web/app/src/vendor/blender/web_engine.js @@ -6,7 +6,7 @@ var Module = (() => { async function(moduleArg = {}) { var moduleRtn; -var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});["_malloc","_free","_web_engine_create","_web_engine_destroy","_web_engine_get_memory_stats","_web_engine_get_live_handles","_web_engine_get_allocated_bytes","_web_engine_open_blend","_web_engine_apply_command","_web_engine_undo","_web_engine_redo","_web_engine_get_scene_snapshot","_web_engine_get_scene_metadata","_web_engine_get_scene_geometry","_web_engine_get_scene_delta","_web_engine_get_packed_asset","_web_engine_evaluate_depsgraph","_web_engine_save_blend","_web_engine_free_buffer","_web_engine_last_error_code","_web_engine_last_error_message","_web_engine_decimate_apply","_memory","___indirect_function_table","___set_stack_limits","onRuntimeInitialized"].forEach(prop=>{if(!Object.getOwnPropertyDescriptor(readyPromise,prop)){Object.defineProperty(readyPromise,prop,{get:()=>abort("You are getting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js"),set:()=>abort("You are setting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js")})}});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");let dirname=import.meta.url;if(dirname.startsWith("data:")){dirname="/"}var require=createRequire(dirname)}var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){if(typeof process=="undefined"||!process.release||process.release.name!=="node")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");var nodeVersion=process.versions.node;var numericVersion=nodeVersion.split(".").slice(0,3);numericVersion=numericVersion[0]*1e4+numericVersion[1]*100+numericVersion[2].split("-")[0]*1;if(numericVersion<16e4){throw new Error("This emscripten-generated code requires node v16.0.0 (detected v"+nodeVersion+")")}var fs=require("fs");var nodePath=require("path");if(!import.meta.url.startsWith("data:")){scriptDirectory=nodePath.dirname(require("url").fileURLToPath(import.meta.url))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);var ret=fs.readFileSync(filename);assert(ret.buffer);return ret};readAsync=(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return new Promise((resolve,reject)=>{fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)reject(err);else resolve(binary?data.buffer:data)})})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_SHELL){if(typeof process=="object"&&typeof require==="function"||typeof window=="object"||typeof importScripts=="function")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)")}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}if(!(typeof window=="object"||typeof importScripts=="function"))throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>{assert(!isFileURI(url),"readAsync does not work with file:// URLs");return fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}}else{throw new Error("environment detection error")}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;checkIncomingModuleAPI();if(Module["arguments"])arguments_=Module["arguments"];legacyModuleProp("arguments","arguments_");if(Module["thisProgram"])thisProgram=Module["thisProgram"];legacyModuleProp("thisProgram","thisProgram");assert(typeof Module["memoryInitializerPrefixURL"]=="undefined","Module.memoryInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["pthreadMainPrefixURL"]=="undefined","Module.pthreadMainPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["cdInitializerPrefixURL"]=="undefined","Module.cdInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["filePackagePrefixURL"]=="undefined","Module.filePackagePrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["read"]=="undefined","Module.read option was removed");assert(typeof Module["readAsync"]=="undefined","Module.readAsync option was removed (modify readAsync in JS)");assert(typeof Module["readBinary"]=="undefined","Module.readBinary option was removed (modify readBinary in JS)");assert(typeof Module["setWindowTitle"]=="undefined","Module.setWindowTitle option was removed (modify emscripten_set_window_title in JS)");assert(typeof Module["TOTAL_MEMORY"]=="undefined","Module.TOTAL_MEMORY has been renamed Module.INITIAL_MEMORY");legacyModuleProp("asm","wasmExports");legacyModuleProp("readAsync","readAsync");legacyModuleProp("readBinary","readBinary");legacyModuleProp("setWindowTitle","setWindowTitle");assert(!ENVIRONMENT_IS_SHELL,"shell environment detected but not enabled at build time. Add `shell` to `-sENVIRONMENT` to enable.");var wasmBinary=Module["wasmBinary"];legacyModuleProp("wasmBinary","wasmBinary");if(typeof WebAssembly!="object"){err("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort("Assertion failed"+(text?": "+text:""))}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}assert(!Module["STACK_SIZE"],"STACK_SIZE can no longer be set at runtime. Use -sSTACK_SIZE at link time");assert(typeof Int32Array!="undefined"&&typeof Float64Array!=="undefined"&&Int32Array.prototype.subarray!=undefined&&Int32Array.prototype.set!=undefined,"JS engine does not provide full typed array support");assert(!Module["wasmMemory"],"Use of `wasmMemory` detected. Use -sIMPORTED_MEMORY to define wasmMemory externally");assert(!Module["INITIAL_MEMORY"],"Detected runtime INITIAL_MEMORY setting. Use -sIMPORTED_MEMORY to define wasmMemory dynamically");function writeStackCookie(){var max=_emscripten_stack_get_end();assert((max&3)==0);if(max==0){max+=4}HEAPU32[max>>2]=34821223;checkInt32(34821223);HEAPU32[max+4>>2]=2310721022;checkInt32(2310721022);HEAPU32[0>>2]=1668509029;checkInt32(1668509029)}function checkStackCookie(){if(ABORT)return;var max=_emscripten_stack_get_end();if(max==0){max+=4}var cookie1=HEAPU32[max>>2];var cookie2=HEAPU32[max+4>>2];if(cookie1!=34821223||cookie2!=2310721022){abort(`Stack overflow! Stack cookie has been overwritten at ${ptrToString(max)}, expected hex dwords 0x89BACDFE and 0x2135467, but received ${ptrToString(cookie2)} ${ptrToString(cookie1)}`)}if(HEAPU32[0>>2]!=1668509029){abort("Runtime error: The application has corrupted its heap memory area (address zero)!")}}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){assert(!runtimeInitialized);runtimeInitialized=true;checkStackCookie();setStackLimits();if(!Module["noFSInit"]&&!FS.initialized)FS.init();FS.ignorePermissions=false;TTY.init();callRuntimeCallbacks(__ATINIT__)}function postRun(){checkStackCookie();var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}assert(Math.imul,"This browser does not support Math.imul(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.fround,"This browser does not support Math.fround(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.clz32,"This browser does not support Math.clz32(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.trunc,"This browser does not support Math.trunc(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;var runDependencyTracking={};function getUniqueRunDependency(id){var orig=id;while(1){if(!runDependencyTracking[id])return id;id=orig+Math.random()}}function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies);if(id){assert(!runDependencyTracking[id]);runDependencyTracking[id]=1;if(runDependencyWatcher===null&&typeof setInterval!="undefined"){runDependencyWatcher=setInterval(()=>{if(ABORT){clearInterval(runDependencyWatcher);runDependencyWatcher=null;return}var shown=false;for(var dep in runDependencyTracking){if(!shown){shown=true;err("still waiting on run dependencies:")}err(`dependency: ${dep}`)}if(shown){err("(end of list)")}},1e4)}}else{err("warning: run dependency added without ID")}}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(id){assert(runDependencyTracking[id]);delete runDependencyTracking[id]}else{err("warning: run dependency removed without ID")}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);var isFileURI=filename=>filename.startsWith("file://");function createExportWrapper(name,nargs){return(...args)=>{assert(runtimeInitialized,`native function \`${name}\` called before runtime initialization`);var f=wasmExports[name];assert(f,`exported native function \`${name}\` not found`);assert(args.length<=nargs,`native function \`${name}\` called with ${args.length} args but expects ${nargs}`);return f(...args)}}function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);if(isFileURI(wasmBinaryFile)){err(`warning: Loading from a file URI (${wasmBinaryFile}) is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing`)}abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&!ENVIRONMENT_IS_NODE&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["memory"];assert(wasmMemory,"memory not found in wasm exports");updateMemoryViews();wasmTable=wasmExports["__indirect_function_table"];assert(wasmTable,"table not found in wasm exports");addOnInit(wasmExports["__wasm_call_ctors"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");var trueModule=Module;function receiveInstantiationResult(result){assert(Module===trueModule,"the Module object should not be replaced during async compilation - perhaps the order of HTML elements is wrong?");trueModule=null;receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var tempDouble;var tempI64;(()=>{var h16=new Int16Array(1);var h8=new Int8Array(h16.buffer);h16[0]=25459;if(h8[0]!==115||h8[1]!==99)throw"Runtime error: expected the system to be little-endian! (Run with -sSUPPORT_BIG_ENDIAN to bypass)"})();if(Module["ENVIRONMENT"]){throw new Error("Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option (for example, -sENVIRONMENT=web or -sENVIRONMENT=node)")}function legacyModuleProp(prop,newName,incoming=true){if(!Object.getOwnPropertyDescriptor(Module,prop)){Object.defineProperty(Module,prop,{configurable:true,get(){let extra=incoming?" (the initial value can be provided on Module, but after startup the value is only looked for on a local variable of that name)":"";abort(`\`Module.${prop}\` has been replaced by \`${newName}\``+extra)}})}}function ignoredModuleProp(prop){if(Object.getOwnPropertyDescriptor(Module,prop)){abort(`\`Module.${prop}\` was supplied but \`${prop}\` not included in INCOMING_MODULE_JS_API`)}}function isExportedByForceFilesystem(name){return name==="FS_createPath"||name==="FS_createDataFile"||name==="FS_createPreloadedFile"||name==="FS_unlink"||name==="addRunDependency"||name==="FS_createLazyFile"||name==="FS_createDevice"||name==="removeRunDependency"}function hookGlobalSymbolAccess(sym,func){if(typeof globalThis!="undefined"&&!Object.getOwnPropertyDescriptor(globalThis,sym)){Object.defineProperty(globalThis,sym,{configurable:true,get(){func();return undefined}})}}function missingGlobal(sym,msg){hookGlobalSymbolAccess(sym,()=>{warnOnce(`\`${sym}\` is not longer defined by emscripten. ${msg}`)})}missingGlobal("buffer","Please use HEAP8.buffer or wasmMemory.buffer");missingGlobal("asm","Please use wasmExports instead");function missingLibrarySymbol(sym){hookGlobalSymbolAccess(sym,()=>{var msg=`\`${sym}\` is a library symbol and not included by default; add it to your library.js __deps or to DEFAULT_LIBRARY_FUNCS_TO_INCLUDE on the command line`;var librarySymbol=sym;if(!librarySymbol.startsWith("_")){librarySymbol="$"+sym}msg+=` (e.g. -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE='${librarySymbol}')`;if(isExportedByForceFilesystem(sym)){msg+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you"}warnOnce(msg)});unexportedRuntimeSymbol(sym)}function unexportedRuntimeSymbol(sym){if(!Object.getOwnPropertyDescriptor(Module,sym)){Object.defineProperty(Module,sym,{configurable:true,get(){var msg=`'${sym}' was not exported. add it to EXPORTED_RUNTIME_METHODS (see the Emscripten FAQ)`;if(isExportedByForceFilesystem(sym)){msg+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you"}abort(msg)}})}}var MAX_UINT8=2**8-1;var MAX_UINT16=2**16-1;var MAX_UINT32=2**32-1;var MAX_UINT53=2**53-1;var MAX_UINT64=2**64-1;var MIN_INT8=-(2**(8-1));var MIN_INT16=-(2**(16-1));var MIN_INT32=-(2**(32-1));var MIN_INT53=-(2**(53-1));var MIN_INT64=-(2**(64-1));function checkInt(value,bits,min,max){assert(Number.isInteger(Number(value)),`attempt to write non-integer (${value}) into integer heap`);assert(value<=max,`value (${value}) too large to write as ${bits}-bit value`);assert(value>=min,`value (${value}) too small to write as ${bits}-bit value`)}var checkInt8=value=>checkInt(value,8,MIN_INT8,MAX_UINT8);var checkInt16=value=>checkInt(value,16,MIN_INT16,MAX_UINT16);var checkInt32=value=>checkInt(value,32,MIN_INT32,MAX_UINT32);var checkInt64=value=>checkInt(value,64,MIN_INT64,MAX_UINT64);function dbg(...args){console.warn(...args)}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var ptrToString=ptr=>{assert(typeof ptr==="number");ptr>>>=0;return"0x"+ptr.toString(16).padStart(8,"0")};var setStackLimits=()=>{var stackLow=_emscripten_stack_get_base();var stackHigh=_emscripten_stack_get_end();___set_stack_limits(stackLow,stackHigh)};var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var warnOnce=text=>{warnOnce.shown||={};if(!warnOnce.shown[text]){warnOnce.shown[text]=1;if(ENVIRONMENT_IS_NODE)text="warning: "+text;err(text)}};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>{assert(typeof ptr=="number",`UTF8ToString expects a number (got ${typeof ptr})`);return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""};var ___assert_fail=(condition,filename,line,func)=>{abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])};var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}assert(wasmTable.get(funcPtr)==func,"JavaScript-side Wasm function table mirror is out of date!");return func};var ___call_sighandler=(fp,sig)=>getWasmTableEntry(fp)(sig);var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);assert(exceptionCaught.length>0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught;checkInt8(caught)}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown;checkInt8(rethrown)}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var ___handle_stack_overflow=requested=>{var base=_emscripten_stack_get_base();var end=_emscripten_stack_get_end();abort(`stack overflow (Attempt to set SP to ${ptrToString(requested)}`+`, with stack limits [${ptrToString(end)} - ${ptrToString(base)}`+"]). If you require more stack space build with -sSTACK_SIZE=")};var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path==="/")return"/";path=PATH.normalize(path);path=path.replace(/\/$/,"");var lastSlash=path.lastIndexOf("/");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:(...paths)=>PATH.normalize(paths.join("/")),join2:(l,r)=>PATH.normalize(l+"/"+r)};var initRandomFill=()=>{if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else if(ENVIRONMENT_IS_NODE){try{var crypto_module=require("crypto");var randomFillSync=crypto_module["randomFillSync"];if(randomFillSync){return view=>crypto_module["randomFillSync"](view)}var randomBytes=crypto_module["randomBytes"];return view=>(view.set(randomBytes(view.byteLength)),view)}catch(e){}}abort("no cryptographic support found for randomDevice. consider polyfilling it if you want to use something insecure like Math.random(), e.g. put this in a --pre-js: var crypto = { getRandomValues: (array) => { for (var i = 0; i < array.length; i++) array[i] = (Math.random()*256)|0 } };")};var randomFill=view=>(randomFill=initRandomFill())(view);var PATH_FS={resolve:(...args)=>{var resolvedPath="",resolvedAbsolute=false;for(var i=args.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?args[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{assert(typeof str==="string",`stringToUTF8Array expects a string (got ${typeof str})`);if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;if(u>1114111)warnOnce("Invalid Unicode code point "+ptrToString(u)+" encountered when serializing a JS string to a UTF-8 string in wasm memory! (Valid unicode code points should be in range 0-0x10FFFF).");heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var FS_stdin_getChar=()=>{if(!FS_stdin_getChar_buffer.length){var result=null;if(ENVIRONMENT_IS_NODE){var BUFSIZE=256;var buf=Buffer.alloc(BUFSIZE);var bytesRead=0;var fd=process.stdin.fd;try{bytesRead=fs.readSync(fd,buf,0,BUFSIZE)}catch(e){if(e.toString().includes("EOF"))bytesRead=0;else throw e}if(bytesRead>0){result=buf.slice(0,bytesRead).toString("utf-8")}}else if(typeof window!="undefined"&&typeof window.prompt=="function"){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else{}if(!result){return null}FS_stdin_getChar_buffer=intArrayFromString(result,true)}return FS_stdin_getChar_buffer.shift()};var TTY={ttys:[],init(){},shutdown(){},register(dev,ops){TTY.ttys[dev]={input:[],output:[],ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close(stream){stream.tty.ops.fsync(stream.tty)},fsync(stream){stream.tty.ops.fsync(stream.tty)},read(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output));tty.output=[]}},ioctl_tcgets(tty){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(tty,optional_actions,data){return 0},ioctl_tiocgwinsz(tty){return[24,80]}},default_tty1_ops:{put_char(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output));tty.output=[]}}}};var zeroMemory=(address,size)=>{HEAPU8.fill(0,address,address+size)};var alignMemory=(size,alignment)=>{assert(alignment,"alignment argument is required");return Math.ceil(size/alignment)*alignment};var mmapAlloc=size=>{size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(ptr)zeroMemory(ptr,size);return ptr};var MEMFS={ops_table:null,mount(mount){return MEMFS.createNode(null,"/",16384|511,0)},createNode(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}MEMFS.ops_table||={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}};var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup(parent,name){throw FS.genericErrors[44]},mknod(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp},unlink(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir(node){var entries=[".",".."];for(var key of Object.keys(node.contents)){entries.push(key)}return entries},symlink(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);assert(size>=0);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i0||position+length{var dep=!noRunDep?getUniqueRunDependency(`al ${url}`):"";readAsync(url).then(arrayBuffer=>{assert(arrayBuffer,`Loading data file "${url}" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},err=>{if(onerror){onerror()}else{throw`Loading data file "${url}" failed.`}});if(dep)addRunDependency(dep)};var FS_createDataFile=(parent,name,fileData,canRead,canWrite,canOwn)=>{FS.createDataFile(parent,name,fileData,canRead,canWrite,canOwn)};var preloadPlugins=Module["preloadPlugins"]||[];var FS_handledByPreloadPlugin=(byteArray,fullname,finish,onerror)=>{if(typeof Browser!="undefined")Browser.init();var handled=false;preloadPlugins.forEach(plugin=>{if(handled)return;if(plugin["canHandle"](fullname)){plugin["handle"](byteArray,fullname,finish,onerror);handled=true}});return handled};var FS_createPreloadedFile=(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish)=>{var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){preFinish?.();if(!dontCreateFile){FS_createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}onload?.();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{onerror?.();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url=="string"){asyncLoad(url,processData,onerror)}else{processData(url)}};var FS_modeStringToFlags=str=>{var flagModes={r:0,"r+":2,w:512|64|1,"w+":512|64|2,a:1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags};var FS_getMode=(canRead,canWrite)=>{var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode};var strError=errno=>UTF8ToString(_strerror(errno));var ERRNO_CODES={EPERM:63,ENOENT:44,ESRCH:71,EINTR:27,EIO:29,ENXIO:60,E2BIG:1,ENOEXEC:45,EBADF:8,ECHILD:12,EAGAIN:6,EWOULDBLOCK:6,ENOMEM:48,EACCES:2,EFAULT:21,ENOTBLK:105,EBUSY:10,EEXIST:20,EXDEV:75,ENODEV:43,ENOTDIR:54,EISDIR:31,EINVAL:28,ENFILE:41,EMFILE:33,ENOTTY:59,ETXTBSY:74,EFBIG:22,ENOSPC:51,ESPIPE:70,EROFS:69,EMLINK:34,EPIPE:64,EDOM:18,ERANGE:68,ENOMSG:49,EIDRM:24,ECHRNG:106,EL2NSYNC:156,EL3HLT:107,EL3RST:108,ELNRNG:109,EUNATCH:110,ENOCSI:111,EL2HLT:112,EDEADLK:16,ENOLCK:46,EBADE:113,EBADR:114,EXFULL:115,ENOANO:104,EBADRQC:103,EBADSLT:102,EDEADLOCK:16,EBFONT:101,ENOSTR:100,ENODATA:116,ETIME:117,ENOSR:118,ENONET:119,ENOPKG:120,EREMOTE:121,ENOLINK:47,EADV:122,ESRMNT:123,ECOMM:124,EPROTO:65,EMULTIHOP:36,EDOTDOT:125,EBADMSG:9,ENOTUNIQ:126,EBADFD:127,EREMCHG:128,ELIBACC:129,ELIBBAD:130,ELIBSCN:131,ELIBMAX:132,ELIBEXEC:133,ENOSYS:52,ENOTEMPTY:55,ENAMETOOLONG:37,ELOOP:32,EOPNOTSUPP:138,EPFNOSUPPORT:139,ECONNRESET:15,ENOBUFS:42,EAFNOSUPPORT:5,EPROTOTYPE:67,ENOTSOCK:57,ENOPROTOOPT:50,ESHUTDOWN:140,ECONNREFUSED:14,EADDRINUSE:3,ECONNABORTED:13,ENETUNREACH:40,ENETDOWN:38,ETIMEDOUT:73,EHOSTDOWN:142,EHOSTUNREACH:23,EINPROGRESS:26,EALREADY:7,EDESTADDRREQ:17,EMSGSIZE:35,EPROTONOSUPPORT:66,ESOCKTNOSUPPORT:137,EADDRNOTAVAIL:4,ENETRESET:39,EISCONN:30,ENOTCONN:53,ETOOMANYREFS:141,EUSERS:136,EDQUOT:19,ESTALE:72,ENOTSUP:138,ENOMEDIUM:148,EILSEQ:25,EOVERFLOW:61,ECANCELED:11,ENOTRECOVERABLE:56,EOWNERDEAD:62,ESTRPIPE:135};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,ErrnoError:class extends Error{constructor(errno){super(runtimeInitialized?strError(errno):"");this.name="ErrnoError";this.errno=errno;for(var key in ERRNO_CODES){if(ERRNO_CODES[key]===errno){this.code=key;break}}}},genericErrors:{},filesystems:null,syncFSRequests:0,readFiles:{},FSStream:class{constructor(){this.shared={}}get object(){return this.node}set object(val){this.node=val}get isRead(){return(this.flags&2097155)!==1}get isWrite(){return(this.flags&2097155)!==0}get isAppend(){return this.flags&1024}get flags(){return this.shared.flags}set flags(val){this.shared.flags=val}get position(){return this.shared.position}set position(val){this.shared.position=val}},FSNode:class{constructor(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev;this.readMode=292|73;this.writeMode=146}get read(){return(this.mode&this.readMode)===this.readMode}set read(val){val?this.mode|=this.readMode:this.mode&=~this.readMode}get write(){return(this.mode&this.writeMode)===this.writeMode}set write(val){val?this.mode|=this.writeMode:this.mode&=~this.writeMode}get isFolder(){return FS.isDir(this.mode)}get isDevice(){return FS.isChrdev(this.mode)}},lookupPath(path,opts={}){path=PATH_FS.resolve(path);if(!path)return{path:"",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath(node){var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!=="/"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName(parentid,name){var hash=0;for(var i=0;i>>0)%FS.nameTable.length},hashAddNode(node){var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode(node){var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode(parent,name){var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode(parent,name,mode,rdev){assert(typeof parent=="object");var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode(node){FS.hashRemoveNode(node)},isRoot(node){return node===node.parent},isMountpoint(node){return!!node.mounted},isFile(mode){return(mode&61440)===32768},isDir(mode){return(mode&61440)===16384},isLink(mode){return(mode&61440)===40960},isChrdev(mode){return(mode&61440)===8192},isBlkdev(mode){return(mode&61440)===24576},isFIFO(mode){return(mode&61440)===4096},isSocket(mode){return(mode&49152)===49152},flagsToPermissionString(flag){var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions(node,perms){if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}else if(perms.includes("w")&&!(node.mode&146)){return 2}else if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup(dir){if(!FS.isDir(dir.mode))return 54;var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate(dir,name){try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete(dir,name,isdir){var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen(node,flags){if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!=="r"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd(){for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStreamChecked(fd){var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}return stream},getStream:fd=>FS.streams[fd],createStream(stream,fd=-1){assert(fd>=-1);stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream(fd){FS.streams[fd]=null},dupStream(origStream,fd=-1){var stream=FS.createStream(origStream,fd);stream.stream_ops?.dup?.(stream);return stream},chrdev_stream_ops:{open(stream){var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;stream.stream_ops.open?.(stream)},llseek(){throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice(dev,ops){FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts(mount){var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push(...m.mounts)}return mounts},syncfs(populate,callback){if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){assert(FS.syncFSRequests>0);FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount(type,opts,mountpoint){if(typeof type=="string"){throw type}var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type,opts,mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount(mountpoint){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);assert(idx!==-1);node.mount.mounts.splice(idx,1)},lookup(parent,name){return parent.node_ops.lookup(parent,name)},mknod(path,mode,dev){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name==="."||name===".."){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create(path,mode){mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir(path,mode){mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree(path,mode){var dirs=path.split("/");var d="";for(var i=0;i=0);if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write(stream,buffer,offset,length,position,canOwn){assert(offset>=0);if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate(stream,offset,length){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap(stream,length,position,prot,flags){if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}if(!length){throw new FS.ErrnoError(28)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync(stream,buffer,offset,length,mmapFlags){assert(offset>=0);if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},ioctl(stream,cmd,arg){if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile(path,opts={}){opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){throw new Error(`Invalid encoding type "${opts.encoding}"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){ret=UTF8ArrayToString(buf)}else if(opts.encoding==="binary"){ret=buf}FS.close(stream);return ret},writeFile(path,data,opts={}){opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir(path){var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories(){FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices(){FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories(){FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount(){var node=FS.createNode(proc_self,"fd",16384|511,73);node.node_ops={lookup(parent,name){var fd=+name;var stream=FS.getStreamChecked(fd);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},"/proc/self/fd")},createStandardStreams(input,output,error){if(input){FS.createDevice("/dev","stdin",input)}else{FS.symlink("/dev/tty","/dev/stdin")}if(output){FS.createDevice("/dev","stdout",null,output)}else{FS.symlink("/dev/tty","/dev/stdout")}if(error){FS.createDevice("/dev","stderr",null,error)}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1);assert(stdin.fd===0,`invalid handle for stdin (${stdin.fd})`);assert(stdout.fd===1,`invalid handle for stdout (${stdout.fd})`);assert(stderr.fd===2,`invalid handle for stderr (${stderr.fd})`)},staticInit(){[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack=""});FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={MEMFS}},init(input,output,error){assert(!FS.initialized,"FS.init was previously called. If you want to initialize later with custom parameters, remove any earlier calls (note that one is automatically added to the generated code)");FS.initialized=true;input??=Module["stdin"];output??=Module["stdout"];error??=Module["stderr"];FS.createStandardStreams(input,output,error)},quit(){FS.initialized=false;_fflush(0);for(var i=0;ithis.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]}setDataGetter(getter){this.getter=getter}cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)throw new Error("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")throw new Error("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true}get length(){if(!this.lengthKnown){this.cacheLength()}return this._length}get chunkSize(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}if(typeof XMLHttpRequest!="undefined"){if(!ENVIRONMENT_IS_WORKER)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var lazyArray=new LazyUint8Array;var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=(...args)=>{FS.forceLoadFile(node);return fn(...args)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);assert(size>=0);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr,allocated:true}};node.stream_ops=stream_ops;return node},absolutePath(){abort("FS.absolutePath has been removed; use PATH_FS.resolve instead")},createFolder(){abort("FS.createFolder has been removed; use FS.mkdir instead")},createLink(){abort("FS.createLink has been removed; use FS.symlink instead")},joinPath(){abort("FS.joinPath has been removed; use PATH.join instead")},mmapAlloc(){abort("FS.mmapAlloc has been replaced by the top level function mmapAlloc")},standardizePath(){abort("FS.standardizePath has been removed; use PATH.normalize instead")}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat(func,path,buf){var stat=func(path);HEAP32[buf>>2]=stat.dev;checkInt32(stat.dev);HEAP32[buf+4>>2]=stat.mode;checkInt32(stat.mode);HEAPU32[buf+8>>2]=stat.nlink;checkInt32(stat.nlink);HEAP32[buf+12>>2]=stat.uid;checkInt32(stat.uid);HEAP32[buf+16>>2]=stat.gid;checkInt32(stat.gid);HEAP32[buf+20>>2]=stat.rdev;checkInt32(stat.rdev);tempI64=[stat.size>>>0,(tempDouble=stat.size,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+24>>2]=tempI64[0],HEAP32[buf+28>>2]=tempI64[1];checkInt64(stat.size);HEAP32[buf+32>>2]=4096;checkInt32(4096);HEAP32[buf+36>>2]=stat.blocks;checkInt32(stat.blocks);var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();tempI64=[Math.floor(atime/1e3)>>>0,(tempDouble=Math.floor(atime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+40>>2]=tempI64[0],HEAP32[buf+44>>2]=tempI64[1];checkInt64(Math.floor(atime/1e3));HEAPU32[buf+48>>2]=atime%1e3*1e3*1e3;checkInt32(atime%1e3*1e3*1e3);tempI64=[Math.floor(mtime/1e3)>>>0,(tempDouble=Math.floor(mtime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+56>>2]=tempI64[0],HEAP32[buf+60>>2]=tempI64[1];checkInt64(Math.floor(mtime/1e3));HEAPU32[buf+64>>2]=mtime%1e3*1e3*1e3;checkInt32(mtime%1e3*1e3*1e3);tempI64=[Math.floor(ctime/1e3)>>>0,(tempDouble=Math.floor(ctime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+72>>2]=tempI64[0],HEAP32[buf+76>>2]=tempI64[1];checkInt64(Math.floor(ctime/1e3));HEAPU32[buf+80>>2]=ctime%1e3*1e3*1e3;checkInt32(ctime%1e3*1e3*1e3);tempI64=[stat.ino>>>0,(tempDouble=stat.ino,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+88>>2]=tempI64[0],HEAP32[buf+92>>2]=tempI64[1];checkInt64(stat.ino);return 0},doMsync(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},getStreamFromFD(fd){var stream=FS.getStreamChecked(fd);return stream},varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);assert(flags===0||flags==512);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function syscallGetVarargI(){assert(SYSCALLS.varargs!=undefined);var ret=HEAP32[+SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret}var syscallGetVarargP=syscallGetVarargI;function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=syscallGetVarargI();if(arg<0){return-28}while(FS.streams[arg]){arg++}var newStream;newStream=FS.dupStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=syscallGetVarargI();stream.flags|=arg;return 0}case 12:{var arg=syscallGetVarargP();var offset=0;HEAP16[arg+offset>>1]=2;checkInt16(2);return 0}case 13:case 14:return 0}return-28}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var stringToUTF8=(str,outPtr,maxBytesToWrite)=>{assert(typeof maxBytesToWrite=="number","stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!");return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)};function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);stream.getdents||=FS.readdir(stream.path);var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx>>0,(tempDouble=id,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[dirp+pos>>2]=tempI64[0],HEAP32[dirp+pos+4>>2]=tempI64[1];checkInt64(id);tempI64=[(idx+1)*struct_size>>>0,(tempDouble=(idx+1)*struct_size,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[dirp+pos+8>>2]=tempI64[0],HEAP32[dirp+pos+12>>2]=tempI64[1];checkInt64((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;checkInt16(280);HEAP8[dirp+pos+18]=type;checkInt8(type);stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:{if(!stream.tty)return-59;return 0}case 21505:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcgets){var termios=stream.tty.ops.ioctl_tcgets(stream);var argp=syscallGetVarargP();HEAP32[argp>>2]=termios.c_iflag||0;checkInt32(termios.c_iflag||0);HEAP32[argp+4>>2]=termios.c_oflag||0;checkInt32(termios.c_oflag||0);HEAP32[argp+8>>2]=termios.c_cflag||0;checkInt32(termios.c_cflag||0);HEAP32[argp+12>>2]=termios.c_lflag||0;checkInt32(termios.c_lflag||0);for(var i=0;i<32;i++){HEAP8[argp+i+17]=termios.c_cc[i]||0;checkInt8(termios.c_cc[i]||0)}return 0}return 0}case 21510:case 21511:case 21512:{if(!stream.tty)return-59;return 0}case 21506:case 21507:case 21508:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcsets){var argp=syscallGetVarargP();var c_iflag=HEAP32[argp>>2];var c_oflag=HEAP32[argp+4>>2];var c_cflag=HEAP32[argp+8>>2];var c_lflag=HEAP32[argp+12>>2];var c_cc=[];for(var i=0;i<32;i++){c_cc.push(HEAP8[argp+i+17])}return stream.tty.ops.ioctl_tcsets(stream.tty,op,{c_iflag,c_oflag,c_cflag,c_lflag,c_cc})}return 0}case 21519:{if(!stream.tty)return-59;var argp=syscallGetVarargP();HEAP32[argp>>2]=0;checkInt32(0);return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=syscallGetVarargP();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tiocgwinsz){var winsize=stream.tty.ops.ioctl_tiocgwinsz(stream.tty);var argp=syscallGetVarargP();HEAP16[argp>>1]=winsize[0];checkInt16(winsize[0]);HEAP16[argp+2>>1]=winsize[1];checkInt16(winsize[1])}return 0}case 21524:{if(!stream.tty)return-59;return 0}case 21515:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]==="/")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;assert(!flags,`unknown flags in __syscall_newfstatat: ${flags}`);path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?syscallGetVarargI():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort("Invalid flags passed to unlinkat")}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __abort_js=()=>{abort("native code called abort()")};var nowIsMonotonic=1;var __emscripten_get_now_is_monotonic=()=>nowIsMonotonic;var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var __emscripten_runtime_keepalive_clear=()=>{noExitRuntime=false;runtimeKeepaliveCounter=0};var __emscripten_throw_longjmp=()=>{throw Infinity};var convertI32PairToI53Checked=(lo,hi)=>{assert(lo==lo>>>0||lo==(lo|0));assert(hi===(hi|0));return hi+2097152>>>0<4194305-!!lo?(lo>>>0)+hi*4294967296:NaN};function __mmap_js(len,prot,flags,fd,offset_low,offset_high,allocated,addr){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,offset,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;checkInt32(res.allocated);HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset_low,offset_high){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __tzset_js=(timezone,daylight,std_name,dst_name)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;checkInt32(stdTimezoneOffset*60);HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);checkInt32(Number(winterOffset!=summerOffset));var extractZone=timezoneOffset=>{var sign=timezoneOffset>=0?"-":"+";var absOffset=Math.abs(timezoneOffset);var hours=String(Math.floor(absOffset/60)).padStart(2,"0");var minutes=String(absOffset%60).padStart(2,"0");return`UTC${sign}${hours}${minutes}`};var winterName=extractZone(winterOffset);var summerName=extractZone(summerOffset);assert(winterName);assert(summerName);assert(lengthBytesUTF8(winterName)<=16,`timezone name truncated to fit in TZNAME_MAX (${winterName})`);assert(lengthBytesUTF8(summerName)<=16,`timezone name truncated to fit in TZNAME_MAX (${summerName})`);if(summerOffsetDate.now();var getHeapMax=()=>2147483648;var _emscripten_get_heap_max=()=>getHeapMax();var _emscripten_get_now=()=>performance.now();var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){err(`growMemory: Attempted to grow heap from ${b.byteLength} bytes to ${size} bytes, but got error: ${e}`)}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;assert(requestedSize>oldSize);var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){err(`Cannot enlarge memory, requested ${requestedSize} bytes, but the limit is ${maxHeapSize} bytes!`);return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var t0=_emscripten_get_now();var replacement=growMemory(newSize);var t1=_emscripten_get_now();dbg(`Heap resize call from ${oldSize} to ${newSize} took ${t1-t0} msecs. Success: ${!!replacement}`);if(replacement){return true}}err(`Failed to grow the heap from ${oldSize} bytes to ${newSize} bytes, not enough memory!`);return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:lang,_:getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var stringToAscii=(str,buffer)=>{for(var i=0;i{var bufSize=0;getEnvStrings().forEach((string,i)=>{var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;checkInt32(ptr);stringToAscii(string,ptr);bufSize+=string.length+1});return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;checkInt32(strings.length);var bufSize=0;strings.forEach(string=>bufSize+=string.length+1);HEAPU32[penviron_buf_size>>2]=bufSize;checkInt32(bufSize);return 0};function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf]=type;checkInt8(type);HEAP16[pbuf+2>>1]=flags;checkInt16(flags);tempI64=[rightsBase>>>0,(tempDouble=rightsBase,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[pbuf+8>>2]=tempI64[0],HEAP32[pbuf+12>>2]=tempI64[1];checkInt64(rightsBase);tempI64=[rightsInheriting>>>0,(tempDouble=rightsInheriting,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[pbuf+16>>2]=tempI64[0],HEAP32[pbuf+20>>2]=tempI64[1];checkInt64(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doReadv=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;checkInt32(num);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_seek(fd,offset_low,offset_high,whence,newOffset){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);tempI64=[stream.position>>>0,(tempDouble=stream.position,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[newOffset>>2]=tempI64[0],HEAP32[newOffset+4>>2]=tempI64[1];checkInt64(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doWritev=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;checkInt32(num);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var _getentropy=(buffer,size)=>{randomFill(HEAPU8.subarray(buffer,buffer+size));return 0};var _llvm_eh_typeid_for=type=>type;var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var getCFunc=ident=>{var func=Module["_"+ident];assert(func,"Cannot call unknown function "+ident+", make sure it is exported");return func};var writeArrayToMemory=(array,buffer)=>{assert(array.length>=0,"writeArrayToMemory array must have a length (should be an array or typed array)");HEAP8.set(array,buffer)};var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;assert(returnType!=="array",'Return type should not be "array".');if(args){for(var i=0;i(...args)=>ccall(ident,returnType,argTypes,args,opts);FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();function checkIncomingModuleAPI(){ignoredModuleProp("fetchSettings")}var wasmImports={__assert_fail:___assert_fail,__call_sighandler:___call_sighandler,__cxa_begin_catch:___cxa_begin_catch,__cxa_end_catch:___cxa_end_catch,__cxa_find_matching_catch_2:___cxa_find_matching_catch_2,__cxa_find_matching_catch_3:___cxa_find_matching_catch_3,__cxa_rethrow:___cxa_rethrow,__cxa_throw:___cxa_throw,__handle_stack_overflow:___handle_stack_overflow,__resumeException:___resumeException,__syscall_faccessat:___syscall_faccessat,__syscall_fcntl64:___syscall_fcntl64,__syscall_fstat64:___syscall_fstat64,__syscall_getdents64:___syscall_getdents64,__syscall_ioctl:___syscall_ioctl,__syscall_lstat64:___syscall_lstat64,__syscall_mkdirat:___syscall_mkdirat,__syscall_newfstatat:___syscall_newfstatat,__syscall_openat:___syscall_openat,__syscall_rmdir:___syscall_rmdir,__syscall_stat64:___syscall_stat64,__syscall_unlinkat:___syscall_unlinkat,_abort_js:__abort_js,_emscripten_get_now_is_monotonic:__emscripten_get_now_is_monotonic,_emscripten_memcpy_js:__emscripten_memcpy_js,_emscripten_runtime_keepalive_clear:__emscripten_runtime_keepalive_clear,_emscripten_throw_longjmp:__emscripten_throw_longjmp,_mmap_js:__mmap_js,_munmap_js:__munmap_js,_tzset_js:__tzset_js,emscripten_date_now:_emscripten_date_now,emscripten_get_heap_max:_emscripten_get_heap_max,emscripten_get_now:_emscripten_get_now,emscripten_resize_heap:_emscripten_resize_heap,environ_get:_environ_get,environ_sizes_get:_environ_sizes_get,fd_close:_fd_close,fd_fdstat_get:_fd_fdstat_get,fd_read:_fd_read,fd_seek:_fd_seek,fd_write:_fd_write,getentropy:_getentropy,invoke_d,invoke_diii,invoke_fi,invoke_fif,invoke_fifff,invoke_fii,invoke_fiii,invoke_fiiii,invoke_fiiiiii,invoke_fiiiiiii,invoke_fij,invoke_i,invoke_idiii,invoke_ii,invoke_iif,invoke_iifi,invoke_iifii,invoke_iifiiiii,invoke_iii,invoke_iiid,invoke_iiidii,invoke_iiif,invoke_iiiffi,invoke_iiii,invoke_iiiidi,invoke_iiiiffifffffffi,invoke_iiiifiii,invoke_iiiii,invoke_iiiiif,invoke_iiiiifi,invoke_iiiiii,invoke_iiiiiifi,invoke_iiiiiififiiiififiiiiiiiiiiiiiiiiiiii,invoke_iiiiiii,invoke_iiiiiiifii,invoke_iiiiiiii,invoke_iiiiiiiii,invoke_iiiiiiiiiffi,invoke_iiiiiiiiii,invoke_iiiiiiiiiii,invoke_iiiiiiiiiiii,invoke_iiiiiiiiiiiif,invoke_iiiiiiiiiiiiiii,invoke_iiiiiiij,invoke_iiiiiij,invoke_iiiiij,invoke_iiiij,invoke_iiij,invoke_iiiji,invoke_iiijii,invoke_iiijiii,invoke_iij,invoke_iijiii,invoke_iijj,invoke_iijji,invoke_iijjiii,invoke_ij,invoke_ijjiii,invoke_j,invoke_ji,invoke_jii,invoke_jiii,invoke_jiji,invoke_v,invoke_vdiii,invoke_vi,invoke_vid,invoke_vif,invoke_vififiif,invoke_vifii,invoke_vifiiifiiiiiiiiiiiiifiiii,invoke_vii,invoke_viid,invoke_viif,invoke_viiff,invoke_viifi,invoke_viifiii,invoke_viii,invoke_viiid,invoke_viiif,invoke_viiiffii,invoke_viiiffiiii,invoke_viiifi,invoke_viiii,invoke_viiiif,invoke_viiiii,invoke_viiiiif,invoke_viiiiii,invoke_viiiiiid,invoke_viiiiiif,invoke_viiiiiifi,invoke_viiiiiifif,invoke_viiiiiifii,invoke_viiiiiii,invoke_viiiiiiidiiii,invoke_viiiiiiii,invoke_viiiiiiiif,invoke_viiiiiiiii,invoke_viiiiiiiiii,invoke_viiiiiiiiiidii,invoke_viiiiiiiiiii,invoke_viiiiiiiiiiii,invoke_viiiiiiiiiiiiii,invoke_viiiiiiiiiiiiiiii,invoke_viiiiij,invoke_viiiiiji,invoke_viiiiji,invoke_viiiijiiifi,invoke_viiij,invoke_viiiji,invoke_viiijii,invoke_viiijjji,invoke_viij,invoke_viiji,invoke_viijii,invoke_viijj,invoke_vij,invoke_viji,invoke_vijif,invoke_vijii,invoke_vijj,invoke_vjjiii,llvm_eh_typeid_for:_llvm_eh_typeid_for,proc_exit:_proc_exit};var wasmExports=createWasm();var ___wasm_call_ctors=createExportWrapper("__wasm_call_ctors",0);var _web_engine_create=Module["_web_engine_create"]=createExportWrapper("web_engine_create",0);var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=createExportWrapper("web_engine_get_memory_stats",1);var _web_engine_destroy=Module["_web_engine_destroy"]=createExportWrapper("web_engine_destroy",1);var _fflush=createExportWrapper("fflush",1);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=createExportWrapper("web_engine_get_live_handles",0);var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=createExportWrapper("web_engine_get_allocated_bytes",0);var _web_engine_open_blend=Module["_web_engine_open_blend"]=createExportWrapper("web_engine_open_blend",3);var _web_engine_apply_command=Module["_web_engine_apply_command"]=createExportWrapper("web_engine_apply_command",3);var _web_engine_decimate_apply=Module["_web_engine_decimate_apply"]=createExportWrapper("web_engine_decimate_apply",35);var _web_engine_undo=Module["_web_engine_undo"]=createExportWrapper("web_engine_undo",1);var _web_engine_redo=Module["_web_engine_redo"]=createExportWrapper("web_engine_redo",1);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=createExportWrapper("web_engine_get_scene_snapshot",3);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=createExportWrapper("web_engine_get_scene_metadata",3);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=createExportWrapper("web_engine_get_scene_geometry",3);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=createExportWrapper("web_engine_get_scene_delta",3);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=createExportWrapper("web_engine_get_packed_asset",5);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=createExportWrapper("web_engine_evaluate_depsgraph",3);var _web_engine_save_blend=Module["_web_engine_save_blend"]=createExportWrapper("web_engine_save_blend",3);var _malloc=Module["_malloc"]=createExportWrapper("malloc",1);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=createExportWrapper("web_engine_free_buffer",1);var _free=Module["_free"]=createExportWrapper("free",1);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=createExportWrapper("web_engine_last_error_code",0);var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=createExportWrapper("web_engine_last_error_message",0);var _strerror=createExportWrapper("strerror",1);var _emscripten_builtin_memalign=createExportWrapper("emscripten_builtin_memalign",2);var _setThrew=createExportWrapper("setThrew",2);var __emscripten_tempret_set=createExportWrapper("_emscripten_tempret_set",1);var __emscripten_tempret_get=createExportWrapper("_emscripten_tempret_get",0);var _emscripten_stack_init=()=>(_emscripten_stack_init=wasmExports["emscripten_stack_init"])();var _emscripten_stack_get_free=()=>(_emscripten_stack_get_free=wasmExports["emscripten_stack_get_free"])();var _emscripten_stack_get_base=()=>(_emscripten_stack_get_base=wasmExports["emscripten_stack_get_base"])();var _emscripten_stack_get_end=()=>(_emscripten_stack_get_end=wasmExports["emscripten_stack_get_end"])();var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["_emscripten_stack_restore"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["_emscripten_stack_alloc"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["emscripten_stack_get_current"])();var ___cxa_decrement_exception_refcount=createExportWrapper("__cxa_decrement_exception_refcount",1);var ___cxa_increment_exception_refcount=createExportWrapper("__cxa_increment_exception_refcount",1);var ___cxa_can_catch=createExportWrapper("__cxa_can_catch",3);var ___cxa_get_exception_ptr=createExportWrapper("__cxa_get_exception_ptr",1);var ___set_stack_limits=Module["___set_stack_limits"]=createExportWrapper("__set_stack_limits",2);var dynCall_viij=Module["dynCall_viij"]=createExportWrapper("dynCall_viij",5);var dynCall_viiji=Module["dynCall_viiji"]=createExportWrapper("dynCall_viiji",6);var dynCall_viijii=Module["dynCall_viijii"]=createExportWrapper("dynCall_viijii",7);var dynCall_vij=Module["dynCall_vij"]=createExportWrapper("dynCall_vij",4);var dynCall_viiiijiiifi=Module["dynCall_viiiijiiifi"]=createExportWrapper("dynCall_viiiijiiifi",12);var dynCall_viiijjji=Module["dynCall_viiijjji"]=createExportWrapper("dynCall_viiijjji",11);var dynCall_viiiiji=Module["dynCall_viiiiji"]=createExportWrapper("dynCall_viiiiji",8);var dynCall_viiiji=Module["dynCall_viiiji"]=createExportWrapper("dynCall_viiiji",7);var dynCall_ij=Module["dynCall_ij"]=createExportWrapper("dynCall_ij",3);var dynCall_viji=Module["dynCall_viji"]=createExportWrapper("dynCall_viji",5);var dynCall_iij=Module["dynCall_iij"]=createExportWrapper("dynCall_iij",4);var dynCall_fij=Module["dynCall_fij"]=createExportWrapper("dynCall_fij",4);var dynCall_vijf=Module["dynCall_vijf"]=createExportWrapper("dynCall_vijf",5);var dynCall_jiii=Module["dynCall_jiii"]=createExportWrapper("dynCall_jiii",4);var dynCall_viiij=Module["dynCall_viiij"]=createExportWrapper("dynCall_viiij",6);var dynCall_iiijii=Module["dynCall_iiijii"]=createExportWrapper("dynCall_iiijii",7);var dynCall_iiijiii=Module["dynCall_iiijiii"]=createExportWrapper("dynCall_iiijiii",8);var dynCall_iiij=Module["dynCall_iiij"]=createExportWrapper("dynCall_iiij",5);var dynCall_vjjiii=Module["dynCall_vjjiii"]=createExportWrapper("dynCall_vjjiii",8);var dynCall_ijjiii=Module["dynCall_ijjiii"]=createExportWrapper("dynCall_ijjiii",8);var dynCall_iijiii=Module["dynCall_iijiii"]=createExportWrapper("dynCall_iijiii",7);var dynCall_iijjiii=Module["dynCall_iijjiii"]=createExportWrapper("dynCall_iijjiii",9);var dynCall_iiiji=Module["dynCall_iiiji"]=createExportWrapper("dynCall_iiiji",6);var dynCall_jii=Module["dynCall_jii"]=createExportWrapper("dynCall_jii",3);var dynCall_iijj=Module["dynCall_iijj"]=createExportWrapper("dynCall_iijj",6);var dynCall_ji=Module["dynCall_ji"]=createExportWrapper("dynCall_ji",2);var dynCall_vijii=Module["dynCall_vijii"]=createExportWrapper("dynCall_vijii",6);var dynCall_vijif=Module["dynCall_vijif"]=createExportWrapper("dynCall_vijif",6);var dynCall_viiiiij=Module["dynCall_viiiiij"]=createExportWrapper("dynCall_viiiiij",8);var dynCall_viiiiiji=Module["dynCall_viiiiiji"]=createExportWrapper("dynCall_viiiiiji",9);var dynCall_iiiij=Module["dynCall_iiiij"]=createExportWrapper("dynCall_iiiij",6);var dynCall_vijj=Module["dynCall_vijj"]=createExportWrapper("dynCall_vijj",6);var dynCall_iijji=Module["dynCall_iijji"]=createExportWrapper("dynCall_iijji",7);var dynCall_j=Module["dynCall_j"]=createExportWrapper("dynCall_j",1);var dynCall_iiiiiiij=Module["dynCall_iiiiiiij"]=createExportWrapper("dynCall_iiiiiiij",9);var dynCall_viijj=Module["dynCall_viijj"]=createExportWrapper("dynCall_viijj",7);var dynCall_iiiiij=Module["dynCall_iiiiij"]=createExportWrapper("dynCall_iiiiij",7);var dynCall_viiijii=Module["dynCall_viiijii"]=createExportWrapper("dynCall_viiijii",8);var dynCall_jij=Module["dynCall_jij"]=createExportWrapper("dynCall_jij",4);var dynCall_vijji=Module["dynCall_vijji"]=createExportWrapper("dynCall_vijji",7);var dynCall_iiiiiij=Module["dynCall_iiiiiij"]=createExportWrapper("dynCall_iiiiiij",8);var dynCall_jiji=Module["dynCall_jiji"]=createExportWrapper("dynCall_jiji",5);var dynCall_iiiiijj=Module["dynCall_iiiiijj"]=createExportWrapper("dynCall_iiiiijj",9);var dynCall_iiiiiijj=Module["dynCall_iiiiiijj"]=createExportWrapper("dynCall_iiiiiijj",10);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vif(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fi(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiif(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_diii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiffi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiififiiiififiiiiiiiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vififiif(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vifii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viif(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viifi(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiif(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fif(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifi(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vdiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_idiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viifiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiif(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiifiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_d(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fifff(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiifi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiifi(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vid(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiff(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iif(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiffiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiif(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vifiiifiiiiiiiiiiiiifiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiiif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiid(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiidiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiidii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiidi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viid(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiidii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiid(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiffii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiid(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viij(index,a1,a2,a3,a4){var sp=stackSave();try{dynCall_viij(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2,a3){var sp=stackSave();try{return dynCall_iij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ij(index,a1,a2){var sp=stackSave();try{return dynCall_ij(index,a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fij(index,a1,a2,a3){var sp=stackSave();try{return dynCall_fij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiji(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiji(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viijii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viijii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vij(index,a1,a2,a3){var sp=stackSave();try{dynCall_vij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiijiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{dynCall_viiiijiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiijjji(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{dynCall_viiijjji(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiji(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiiiji(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiji(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viiiji(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiijii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iiijii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiijiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_iiijiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiij(index,a1,a2,a3,a4){var sp=stackSave();try{return dynCall_iiij(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viji(index,a1,a2,a3,a4){var sp=stackSave();try{dynCall_viji(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijii(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiji(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iiiji(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iijiii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijjiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return dynCall_iijjiii(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vjjiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_vjjiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ijjiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_ijjiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iijj(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiij(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiiiij(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijif(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiji(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{dynCall_viiiiiji(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijji(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iijji(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijj(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viijj(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viijj(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ji(index,a1){var sp=stackSave();try{return dynCall_ji(index,a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiij(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iiiiij(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iiiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_j(index){var sp=stackSave();try{return dynCall_j(index)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiij(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return dynCall_iiiiiiij(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiijii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiijii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jii(index,a1,a2){var sp=stackSave();try{return dynCall_jii(index,a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiij(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_iiiiiij(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiji(index,a1,a2,a3,a4){var sp=stackSave();try{return dynCall_jiji(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["UTF8ToString"]=UTF8ToString;var missingLibrarySymbols=["writeI53ToI64","writeI53ToI64Clamped","writeI53ToI64Signaling","writeI53ToU64Clamped","writeI53ToU64Signaling","readI53FromI64","readI53FromU64","convertI32PairToI53","convertU32PairToI53","getTempRet0","exitJS","inetPton4","inetNtop4","inetPton6","inetNtop6","readSockaddr","writeSockaddr","emscriptenLog","readEmAsmArgs","jstoi_q","listenOnce","autoResumeAudioContext","dynCallLegacy","getDynCaller","dynCall","handleException","runtimeKeepalivePush","runtimeKeepalivePop","callUserCallback","maybeExit","asmjsMangle","HandleAllocator","getNativeTypeSize","STACK_SIZE","STACK_ALIGN","POINTER_SIZE","ASSERTIONS","uleb128Encode","sigToWasmTypes","generateFuncType","convertJsFunctionToWasm","getEmptyTableSlot","updateTableMap","getFunctionAddress","addFunction","removeFunction","reallyNegative","unSign","strLen","reSign","formatString","intArrayToString","AsciiToString","UTF16ToString","stringToUTF16","lengthBytesUTF16","UTF32ToString","stringToUTF32","lengthBytesUTF32","stringToNewUTF8","registerKeyEventCallback","maybeCStringToJsString","findEventTarget","getBoundingClientRect","fillMouseEventData","registerMouseEventCallback","registerWheelEventCallback","registerUiEventCallback","registerFocusEventCallback","fillDeviceOrientationEventData","registerDeviceOrientationEventCallback","fillDeviceMotionEventData","registerDeviceMotionEventCallback","screenOrientation","fillOrientationChangeEventData","registerOrientationChangeEventCallback","fillFullscreenChangeEventData","registerFullscreenChangeEventCallback","JSEvents_requestFullscreen","JSEvents_resizeCanvasForFullscreen","registerRestoreOldStyle","hideEverythingExceptGivenElement","restoreHiddenElements","setLetterbox","softFullscreenResizeWebGLRenderTarget","doRequestFullscreen","fillPointerlockChangeEventData","registerPointerlockChangeEventCallback","registerPointerlockErrorEventCallback","requestPointerLock","fillVisibilityChangeEventData","registerVisibilityChangeEventCallback","registerTouchEventCallback","fillGamepadEventData","registerGamepadEventCallback","registerBeforeUnloadEventCallback","fillBatteryEventData","battery","registerBatteryEventCallback","setCanvasElementSize","getCanvasElementSize","jsStackTrace","getCallstack","convertPCtoSourceLocation","checkWasiClock","wasiRightsToMuslOFlags","wasiOFlagsToMuslOFlags","createDyncallWrapper","safeSetTimeout","setImmediateWrapped","clearImmediateWrapped","polyfillSetImmediate","registerPostMainLoop","registerPreMainLoop","getPromise","makePromise","idsToPromises","makePromiseCallback","Browser_asyncPrepareDataCounter","safeRequestAnimationFrame","isLeapYear","ydayFromDate","arraySum","addDays","getSocketFromFD","getSocketAddress","FS_unlink","FS_mkdirTree","_setNetworkCallback","heapObjectForWebGLType","toTypedArrayIndex","webgl_enable_ANGLE_instanced_arrays","webgl_enable_OES_vertex_array_object","webgl_enable_WEBGL_draw_buffers","webgl_enable_WEBGL_multi_draw","webgl_enable_EXT_polygon_offset_clamp","webgl_enable_EXT_clip_control","webgl_enable_WEBGL_polygon_mode","emscriptenWebGLGet","computeUnpackAlignedImageSize","colorChannelsInGlTextureFormat","emscriptenWebGLGetTexPixelData","emscriptenWebGLGetUniform","webglGetUniformLocation","webglPrepareUniformLocationsBeforeFirstUse","webglGetLeftBracePos","emscriptenWebGLGetVertexAttrib","__glGetActiveAttribOrUniform","writeGLArray","registerWebGlEventCallback","runAndAbortIfError","ALLOC_NORMAL","ALLOC_STACK","allocate","writeStringToMemory","writeAsciiToMemory","setErrNo","demangle","stackTrace"];missingLibrarySymbols.forEach(missingLibrarySymbol);var unexportedSymbols=["run","addOnPreRun","addOnInit","addOnPreMain","addOnExit","addOnPostRun","addRunDependency","removeRunDependency","out","err","callMain","abort","wasmMemory","wasmExports","writeStackCookie","checkStackCookie","convertI32PairToI53Checked","stackSave","stackRestore","stackAlloc","setTempRet0","ptrToString","zeroMemory","getHeapMax","growMemory","ENV","setStackLimits","ERRNO_CODES","strError","DNS","Protocols","Sockets","initRandomFill","randomFill","timers","warnOnce","readEmAsmArgsArray","jstoi_s","getExecutableName","keepRuntimeAlive","asyncLoad","alignMemory","mmapAlloc","wasmTable","noExitRuntime","getCFunc","freeTableIndexes","functionsInTableMap","setValue","getValue","PATH","PATH_FS","UTF8Decoder","UTF8ArrayToString","stringToUTF8Array","stringToUTF8","lengthBytesUTF8","intArrayFromString","stringToAscii","UTF16Decoder","stringToUTF8OnStack","writeArrayToMemory","JSEvents","specialHTMLTargets","findCanvasEventTarget","currentFullscreenStrategy","restoreOldWindowedStyle","UNWIND_CACHE","ExitStatus","getEnvStrings","doReadv","doWritev","promiseMap","uncaughtExceptionCount","exceptionLast","exceptionCaught","ExceptionInfo","findMatchingCatch","Browser","getPreloadedImageData__data","wget","MONTH_DAYS_REGULAR","MONTH_DAYS_LEAP","MONTH_DAYS_REGULAR_CUMULATIVE","MONTH_DAYS_LEAP_CUMULATIVE","SYSCALLS","preloadPlugins","FS_createPreloadedFile","FS_modeStringToFlags","FS_getMode","FS_stdin_getChar_buffer","FS_stdin_getChar","FS_createPath","FS_createDevice","FS_readFile","FS","FS_createDataFile","FS_createLazyFile","MEMFS","TTY","PIPEFS","SOCKFS","tempFixedLengthArray","miniTempWebGLFloatBuffers","miniTempWebGLIntBuffers","GL","AL","GLUT","EGL","GLEW","IDBStore","SDL","SDL_gfx","allocateUTF8","allocateUTF8OnStack","print","printErr"];unexportedSymbols.forEach(unexportedRuntimeSymbol);var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function stackCheckInit(){_emscripten_stack_init();writeStackCookie()}function run(){if(runDependencies>0){return}stackCheckInit();if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();assert(!Module["_main"],'compiled without a main, but one is present. if you added it from JS, use Module["onRuntimeInitialized"]');postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}checkStackCookie()}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise;for(const prop of Object.keys(Module)){if(!(prop in moduleArg)){Object.defineProperty(moduleArg,prop,{configurable:true,get(){abort(`Access to module property ('${prop}') is no longer possible via the module constructor argument; Instead, use the result of the module constructor.`)}})}} +var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});["_malloc","_free","_web_engine_create","_web_engine_destroy","_web_engine_get_memory_stats","_web_engine_get_live_handles","_web_engine_get_allocated_bytes","_web_engine_open_blend","_web_engine_apply_command","_web_engine_undo","_web_engine_redo","_web_engine_get_scene_snapshot","_web_engine_get_scene_metadata","_web_engine_get_scene_geometry","_web_engine_get_scene_delta","_web_engine_get_packed_asset","_web_engine_evaluate_depsgraph","_web_engine_save_blend","_web_engine_free_buffer","_web_engine_last_error_code","_web_engine_last_error_message","_web_engine_decimate_apply","_memory","___indirect_function_table","___set_stack_limits","onRuntimeInitialized"].forEach(prop=>{if(!Object.getOwnPropertyDescriptor(readyPromise,prop)){Object.defineProperty(readyPromise,prop,{get:()=>abort("You are getting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js"),set:()=>abort("You are setting "+prop+" on the Promise object, instead of the instance. Use .then() to get called back with the instance, see the MODULARIZE docs in src/settings.js")})}});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");let dirname=import.meta.url;if(dirname.startsWith("data:")){dirname="/"}var require=createRequire(dirname)}var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){if(typeof process=="undefined"||!process.release||process.release.name!=="node")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");var nodeVersion=process.versions.node;var numericVersion=nodeVersion.split(".").slice(0,3);numericVersion=numericVersion[0]*1e4+numericVersion[1]*100+numericVersion[2].split("-")[0]*1;if(numericVersion<16e4){throw new Error("This emscripten-generated code requires node v16.0.0 (detected v"+nodeVersion+")")}var fs=require("fs");var nodePath=require("path");if(!import.meta.url.startsWith("data:")){scriptDirectory=nodePath.dirname(require("url").fileURLToPath(import.meta.url))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);var ret=fs.readFileSync(filename);assert(ret.buffer);return ret};readAsync=(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return new Promise((resolve,reject)=>{fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)reject(err);else resolve(binary?data.buffer:data)})})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_SHELL){if(typeof process=="object"&&typeof require==="function"||typeof window=="object"||typeof importScripts=="function")throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)")}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}if(!(typeof window=="object"||typeof importScripts=="function"))throw new Error("not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)");{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>{assert(!isFileURI(url),"readAsync does not work with file:// URLs");return fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}}else{throw new Error("environment detection error")}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;checkIncomingModuleAPI();if(Module["arguments"])arguments_=Module["arguments"];legacyModuleProp("arguments","arguments_");if(Module["thisProgram"])thisProgram=Module["thisProgram"];legacyModuleProp("thisProgram","thisProgram");assert(typeof Module["memoryInitializerPrefixURL"]=="undefined","Module.memoryInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["pthreadMainPrefixURL"]=="undefined","Module.pthreadMainPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["cdInitializerPrefixURL"]=="undefined","Module.cdInitializerPrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["filePackagePrefixURL"]=="undefined","Module.filePackagePrefixURL option was removed, use Module.locateFile instead");assert(typeof Module["read"]=="undefined","Module.read option was removed");assert(typeof Module["readAsync"]=="undefined","Module.readAsync option was removed (modify readAsync in JS)");assert(typeof Module["readBinary"]=="undefined","Module.readBinary option was removed (modify readBinary in JS)");assert(typeof Module["setWindowTitle"]=="undefined","Module.setWindowTitle option was removed (modify emscripten_set_window_title in JS)");assert(typeof Module["TOTAL_MEMORY"]=="undefined","Module.TOTAL_MEMORY has been renamed Module.INITIAL_MEMORY");legacyModuleProp("asm","wasmExports");legacyModuleProp("readAsync","readAsync");legacyModuleProp("readBinary","readBinary");legacyModuleProp("setWindowTitle","setWindowTitle");assert(!ENVIRONMENT_IS_SHELL,"shell environment detected but not enabled at build time. Add `shell` to `-sENVIRONMENT` to enable.");var wasmBinary=Module["wasmBinary"];legacyModuleProp("wasmBinary","wasmBinary");if(typeof WebAssembly!="object"){err("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort("Assertion failed"+(text?": "+text:""))}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}assert(!Module["STACK_SIZE"],"STACK_SIZE can no longer be set at runtime. Use -sSTACK_SIZE at link time");assert(typeof Int32Array!="undefined"&&typeof Float64Array!=="undefined"&&Int32Array.prototype.subarray!=undefined&&Int32Array.prototype.set!=undefined,"JS engine does not provide full typed array support");assert(!Module["wasmMemory"],"Use of `wasmMemory` detected. Use -sIMPORTED_MEMORY to define wasmMemory externally");assert(!Module["INITIAL_MEMORY"],"Detected runtime INITIAL_MEMORY setting. Use -sIMPORTED_MEMORY to define wasmMemory dynamically");function writeStackCookie(){var max=_emscripten_stack_get_end();assert((max&3)==0);if(max==0){max+=4}HEAPU32[max>>2]=34821223;checkInt32(34821223);HEAPU32[max+4>>2]=2310721022;checkInt32(2310721022);HEAPU32[0>>2]=1668509029;checkInt32(1668509029)}function checkStackCookie(){if(ABORT)return;var max=_emscripten_stack_get_end();if(max==0){max+=4}var cookie1=HEAPU32[max>>2];var cookie2=HEAPU32[max+4>>2];if(cookie1!=34821223||cookie2!=2310721022){abort(`Stack overflow! Stack cookie has been overwritten at ${ptrToString(max)}, expected hex dwords 0x89BACDFE and 0x2135467, but received ${ptrToString(cookie2)} ${ptrToString(cookie1)}`)}if(HEAPU32[0>>2]!=1668509029){abort("Runtime error: The application has corrupted its heap memory area (address zero)!")}}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){assert(!runtimeInitialized);runtimeInitialized=true;checkStackCookie();setStackLimits();if(!Module["noFSInit"]&&!FS.initialized)FS.init();FS.ignorePermissions=false;TTY.init();callRuntimeCallbacks(__ATINIT__)}function postRun(){checkStackCookie();var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}assert(Math.imul,"This browser does not support Math.imul(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.fround,"This browser does not support Math.fround(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.clz32,"This browser does not support Math.clz32(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");assert(Math.trunc,"This browser does not support Math.trunc(), build with LEGACY_VM_SUPPORT or POLYFILL_OLD_MATH_FUNCTIONS to add in a polyfill");var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;var runDependencyTracking={};function getUniqueRunDependency(id){var orig=id;while(1){if(!runDependencyTracking[id])return id;id=orig+Math.random()}}function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies);if(id){assert(!runDependencyTracking[id]);runDependencyTracking[id]=1;if(runDependencyWatcher===null&&typeof setInterval!="undefined"){runDependencyWatcher=setInterval(()=>{if(ABORT){clearInterval(runDependencyWatcher);runDependencyWatcher=null;return}var shown=false;for(var dep in runDependencyTracking){if(!shown){shown=true;err("still waiting on run dependencies:")}err(`dependency: ${dep}`)}if(shown){err("(end of list)")}},1e4)}}else{err("warning: run dependency added without ID")}}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(id){assert(runDependencyTracking[id]);delete runDependencyTracking[id]}else{err("warning: run dependency removed without ID")}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);var isFileURI=filename=>filename.startsWith("file://");function createExportWrapper(name,nargs){return(...args)=>{assert(runtimeInitialized,`native function \`${name}\` called before runtime initialization`);var f=wasmExports[name];assert(f,`exported native function \`${name}\` not found`);assert(args.length<=nargs,`native function \`${name}\` called with ${args.length} args but expects ${nargs}`);return f(...args)}}function findWasmBinary(){if(Module["locateFile"]){var f="web_engine.wasm";if(!isDataURI(f)){return locateFile(f)}return f}return new URL("web_engine.wasm",import.meta.url).href}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);if(isFileURI(wasmBinaryFile)){err(`warning: Loading from a file URI (${wasmBinaryFile}) is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing`)}abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&!ENVIRONMENT_IS_NODE&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["memory"];assert(wasmMemory,"memory not found in wasm exports");updateMemoryViews();wasmTable=wasmExports["__indirect_function_table"];assert(wasmTable,"table not found in wasm exports");addOnInit(wasmExports["__wasm_call_ctors"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");var trueModule=Module;function receiveInstantiationResult(result){assert(Module===trueModule,"the Module object should not be replaced during async compilation - perhaps the order of HTML elements is wrong?");trueModule=null;receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var tempDouble;var tempI64;(()=>{var h16=new Int16Array(1);var h8=new Int8Array(h16.buffer);h16[0]=25459;if(h8[0]!==115||h8[1]!==99)throw"Runtime error: expected the system to be little-endian! (Run with -sSUPPORT_BIG_ENDIAN to bypass)"})();if(Module["ENVIRONMENT"]){throw new Error("Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option (for example, -sENVIRONMENT=web or -sENVIRONMENT=node)")}function legacyModuleProp(prop,newName,incoming=true){if(!Object.getOwnPropertyDescriptor(Module,prop)){Object.defineProperty(Module,prop,{configurable:true,get(){let extra=incoming?" (the initial value can be provided on Module, but after startup the value is only looked for on a local variable of that name)":"";abort(`\`Module.${prop}\` has been replaced by \`${newName}\``+extra)}})}}function ignoredModuleProp(prop){if(Object.getOwnPropertyDescriptor(Module,prop)){abort(`\`Module.${prop}\` was supplied but \`${prop}\` not included in INCOMING_MODULE_JS_API`)}}function isExportedByForceFilesystem(name){return name==="FS_createPath"||name==="FS_createDataFile"||name==="FS_createPreloadedFile"||name==="FS_unlink"||name==="addRunDependency"||name==="FS_createLazyFile"||name==="FS_createDevice"||name==="removeRunDependency"}function hookGlobalSymbolAccess(sym,func){if(typeof globalThis!="undefined"&&!Object.getOwnPropertyDescriptor(globalThis,sym)){Object.defineProperty(globalThis,sym,{configurable:true,get(){func();return undefined}})}}function missingGlobal(sym,msg){hookGlobalSymbolAccess(sym,()=>{warnOnce(`\`${sym}\` is not longer defined by emscripten. ${msg}`)})}missingGlobal("buffer","Please use HEAP8.buffer or wasmMemory.buffer");missingGlobal("asm","Please use wasmExports instead");function missingLibrarySymbol(sym){hookGlobalSymbolAccess(sym,()=>{var msg=`\`${sym}\` is a library symbol and not included by default; add it to your library.js __deps or to DEFAULT_LIBRARY_FUNCS_TO_INCLUDE on the command line`;var librarySymbol=sym;if(!librarySymbol.startsWith("_")){librarySymbol="$"+sym}msg+=` (e.g. -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE='${librarySymbol}')`;if(isExportedByForceFilesystem(sym)){msg+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you"}warnOnce(msg)});unexportedRuntimeSymbol(sym)}function unexportedRuntimeSymbol(sym){if(!Object.getOwnPropertyDescriptor(Module,sym)){Object.defineProperty(Module,sym,{configurable:true,get(){var msg=`'${sym}' was not exported. add it to EXPORTED_RUNTIME_METHODS (see the Emscripten FAQ)`;if(isExportedByForceFilesystem(sym)){msg+=". Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you"}abort(msg)}})}}var MAX_UINT8=2**8-1;var MAX_UINT16=2**16-1;var MAX_UINT32=2**32-1;var MAX_UINT53=2**53-1;var MAX_UINT64=2**64-1;var MIN_INT8=-(2**(8-1));var MIN_INT16=-(2**(16-1));var MIN_INT32=-(2**(32-1));var MIN_INT53=-(2**(53-1));var MIN_INT64=-(2**(64-1));function checkInt(value,bits,min,max){assert(Number.isInteger(Number(value)),`attempt to write non-integer (${value}) into integer heap`);assert(value<=max,`value (${value}) too large to write as ${bits}-bit value`);assert(value>=min,`value (${value}) too small to write as ${bits}-bit value`)}var checkInt8=value=>checkInt(value,8,MIN_INT8,MAX_UINT8);var checkInt16=value=>checkInt(value,16,MIN_INT16,MAX_UINT16);var checkInt32=value=>checkInt(value,32,MIN_INT32,MAX_UINT32);var checkInt64=value=>checkInt(value,64,MIN_INT64,MAX_UINT64);function dbg(...args){console.warn(...args)}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var ptrToString=ptr=>{assert(typeof ptr==="number");ptr>>>=0;return"0x"+ptr.toString(16).padStart(8,"0")};var setStackLimits=()=>{var stackLow=_emscripten_stack_get_base();var stackHigh=_emscripten_stack_get_end();___set_stack_limits(stackLow,stackHigh)};var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var warnOnce=text=>{warnOnce.shown||={};if(!warnOnce.shown[text]){warnOnce.shown[text]=1;if(ENVIRONMENT_IS_NODE)text="warning: "+text;err(text)}};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>{assert(typeof ptr=="number",`UTF8ToString expects a number (got ${typeof ptr})`);return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""};var ___assert_fail=(condition,filename,line,func)=>{abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])};var wasmTableMirror=[];var wasmTable;var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}assert(wasmTable.get(funcPtr)==func,"JavaScript-side Wasm function table mirror is out of date!");return func};var ___call_sighandler=(fp,sig)=>getWasmTableEntry(fp)(sig);var exceptionCaught=[];var uncaughtExceptionCount=0;var ___cxa_begin_catch=ptr=>{var info=new ExceptionInfo(ptr);if(!info.get_caught()){info.set_caught(true);uncaughtExceptionCount--}info.set_rethrown(false);exceptionCaught.push(info);___cxa_increment_exception_refcount(ptr);return ___cxa_get_exception_ptr(ptr)};var exceptionLast=0;var ___cxa_end_catch=()=>{_setThrew(0,0);assert(exceptionCaught.length>0);var info=exceptionCaught.pop();___cxa_decrement_exception_refcount(info.excPtr);exceptionLast=0};class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught;checkInt8(caught)}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown;checkInt8(rethrown)}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var ___resumeException=ptr=>{if(!exceptionLast){exceptionLast=ptr}assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var setTempRet0=val=>__emscripten_tempret_set(val);var findMatchingCatch=args=>{var thrown=exceptionLast;if(!thrown){setTempRet0(0);return 0}var info=new ExceptionInfo(thrown);info.set_adjusted_ptr(thrown);var thrownType=info.get_type();if(!thrownType){setTempRet0(0);return thrown}for(var caughtType of args){if(caughtType===0||caughtType===thrownType){break}var adjusted_ptr_addr=info.ptr+16;if(___cxa_can_catch(caughtType,thrownType,adjusted_ptr_addr)){setTempRet0(caughtType);return thrown}}setTempRet0(thrownType);return thrown};var ___cxa_find_matching_catch_2=()=>findMatchingCatch([]);var ___cxa_find_matching_catch_3=arg0=>findMatchingCatch([arg0]);var ___cxa_rethrow=()=>{var info=exceptionCaught.pop();if(!info){abort("no exception to throw")}var ptr=info.excPtr;if(!info.get_rethrown()){exceptionCaught.push(info);info.set_rethrown(true);info.set_caught(false);uncaughtExceptionCount++}exceptionLast=ptr;assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;assert(false,"Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.")};var ___handle_stack_overflow=requested=>{var base=_emscripten_stack_get_base();var end=_emscripten_stack_get_end();abort(`stack overflow (Attempt to set SP to ${ptrToString(requested)}`+`, with stack limits [${ptrToString(end)} - ${ptrToString(base)}`+"]). If you require more stack space build with -sSTACK_SIZE=")};var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path==="/")return"/";path=PATH.normalize(path);path=path.replace(/\/$/,"");var lastSlash=path.lastIndexOf("/");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:(...paths)=>PATH.normalize(paths.join("/")),join2:(l,r)=>PATH.normalize(l+"/"+r)};var initRandomFill=()=>{if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else if(ENVIRONMENT_IS_NODE){try{var crypto_module=require("crypto");var randomFillSync=crypto_module["randomFillSync"];if(randomFillSync){return view=>crypto_module["randomFillSync"](view)}var randomBytes=crypto_module["randomBytes"];return view=>(view.set(randomBytes(view.byteLength)),view)}catch(e){}}abort("no cryptographic support found for randomDevice. consider polyfilling it if you want to use something insecure like Math.random(), e.g. put this in a --pre-js: var crypto = { getRandomValues: (array) => { for (var i = 0; i < array.length; i++) array[i] = (Math.random()*256)|0 } };")};var randomFill=view=>(randomFill=initRandomFill())(view);var PATH_FS={resolve:(...args)=>{var resolvedPath="",resolvedAbsolute=false;for(var i=args.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?args[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{assert(typeof str==="string",`stringToUTF8Array expects a string (got ${typeof str})`);if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;if(u>1114111)warnOnce("Invalid Unicode code point "+ptrToString(u)+" encountered when serializing a JS string to a UTF-8 string in wasm memory! (Valid unicode code points should be in range 0-0x10FFFF).");heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var FS_stdin_getChar=()=>{if(!FS_stdin_getChar_buffer.length){var result=null;if(ENVIRONMENT_IS_NODE){var BUFSIZE=256;var buf=Buffer.alloc(BUFSIZE);var bytesRead=0;var fd=process.stdin.fd;try{bytesRead=fs.readSync(fd,buf,0,BUFSIZE)}catch(e){if(e.toString().includes("EOF"))bytesRead=0;else throw e}if(bytesRead>0){result=buf.slice(0,bytesRead).toString("utf-8")}}else if(typeof window!="undefined"&&typeof window.prompt=="function"){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else{}if(!result){return null}FS_stdin_getChar_buffer=intArrayFromString(result,true)}return FS_stdin_getChar_buffer.shift()};var TTY={ttys:[],init(){},shutdown(){},register(dev,ops){TTY.ttys[dev]={input:[],output:[],ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close(stream){stream.tty.ops.fsync(stream.tty)},fsync(stream){stream.tty.ops.fsync(stream.tty)},read(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output));tty.output=[]}},ioctl_tcgets(tty){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(tty,optional_actions,data){return 0},ioctl_tiocgwinsz(tty){return[24,80]}},default_tty1_ops:{put_char(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output));tty.output=[]}}}};var zeroMemory=(address,size)=>{HEAPU8.fill(0,address,address+size)};var alignMemory=(size,alignment)=>{assert(alignment,"alignment argument is required");return Math.ceil(size/alignment)*alignment};var mmapAlloc=size=>{size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(ptr)zeroMemory(ptr,size);return ptr};var MEMFS={ops_table:null,mount(mount){return MEMFS.createNode(null,"/",16384|511,0)},createNode(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}MEMFS.ops_table||={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}};var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup(parent,name){throw FS.genericErrors[44]},mknod(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp},unlink(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir(node){var entries=[".",".."];for(var key of Object.keys(node.contents)){entries.push(key)}return entries},symlink(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);assert(size>=0);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i0||position+length{var dep=!noRunDep?getUniqueRunDependency(`al ${url}`):"";readAsync(url).then(arrayBuffer=>{assert(arrayBuffer,`Loading data file "${url}" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},err=>{if(onerror){onerror()}else{throw`Loading data file "${url}" failed.`}});if(dep)addRunDependency(dep)};var FS_createDataFile=(parent,name,fileData,canRead,canWrite,canOwn)=>{FS.createDataFile(parent,name,fileData,canRead,canWrite,canOwn)};var preloadPlugins=Module["preloadPlugins"]||[];var FS_handledByPreloadPlugin=(byteArray,fullname,finish,onerror)=>{if(typeof Browser!="undefined")Browser.init();var handled=false;preloadPlugins.forEach(plugin=>{if(handled)return;if(plugin["canHandle"](fullname)){plugin["handle"](byteArray,fullname,finish,onerror);handled=true}});return handled};var FS_createPreloadedFile=(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish)=>{var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){preFinish?.();if(!dontCreateFile){FS_createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}onload?.();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{onerror?.();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url=="string"){asyncLoad(url,processData,onerror)}else{processData(url)}};var FS_modeStringToFlags=str=>{var flagModes={r:0,"r+":2,w:512|64|1,"w+":512|64|2,a:1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags};var FS_getMode=(canRead,canWrite)=>{var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode};var strError=errno=>UTF8ToString(_strerror(errno));var ERRNO_CODES={EPERM:63,ENOENT:44,ESRCH:71,EINTR:27,EIO:29,ENXIO:60,E2BIG:1,ENOEXEC:45,EBADF:8,ECHILD:12,EAGAIN:6,EWOULDBLOCK:6,ENOMEM:48,EACCES:2,EFAULT:21,ENOTBLK:105,EBUSY:10,EEXIST:20,EXDEV:75,ENODEV:43,ENOTDIR:54,EISDIR:31,EINVAL:28,ENFILE:41,EMFILE:33,ENOTTY:59,ETXTBSY:74,EFBIG:22,ENOSPC:51,ESPIPE:70,EROFS:69,EMLINK:34,EPIPE:64,EDOM:18,ERANGE:68,ENOMSG:49,EIDRM:24,ECHRNG:106,EL2NSYNC:156,EL3HLT:107,EL3RST:108,ELNRNG:109,EUNATCH:110,ENOCSI:111,EL2HLT:112,EDEADLK:16,ENOLCK:46,EBADE:113,EBADR:114,EXFULL:115,ENOANO:104,EBADRQC:103,EBADSLT:102,EDEADLOCK:16,EBFONT:101,ENOSTR:100,ENODATA:116,ETIME:117,ENOSR:118,ENONET:119,ENOPKG:120,EREMOTE:121,ENOLINK:47,EADV:122,ESRMNT:123,ECOMM:124,EPROTO:65,EMULTIHOP:36,EDOTDOT:125,EBADMSG:9,ENOTUNIQ:126,EBADFD:127,EREMCHG:128,ELIBACC:129,ELIBBAD:130,ELIBSCN:131,ELIBMAX:132,ELIBEXEC:133,ENOSYS:52,ENOTEMPTY:55,ENAMETOOLONG:37,ELOOP:32,EOPNOTSUPP:138,EPFNOSUPPORT:139,ECONNRESET:15,ENOBUFS:42,EAFNOSUPPORT:5,EPROTOTYPE:67,ENOTSOCK:57,ENOPROTOOPT:50,ESHUTDOWN:140,ECONNREFUSED:14,EADDRINUSE:3,ECONNABORTED:13,ENETUNREACH:40,ENETDOWN:38,ETIMEDOUT:73,EHOSTDOWN:142,EHOSTUNREACH:23,EINPROGRESS:26,EALREADY:7,EDESTADDRREQ:17,EMSGSIZE:35,EPROTONOSUPPORT:66,ESOCKTNOSUPPORT:137,EADDRNOTAVAIL:4,ENETRESET:39,EISCONN:30,ENOTCONN:53,ETOOMANYREFS:141,EUSERS:136,EDQUOT:19,ESTALE:72,ENOTSUP:138,ENOMEDIUM:148,EILSEQ:25,EOVERFLOW:61,ECANCELED:11,ENOTRECOVERABLE:56,EOWNERDEAD:62,ESTRPIPE:135};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,ErrnoError:class extends Error{constructor(errno){super(runtimeInitialized?strError(errno):"");this.name="ErrnoError";this.errno=errno;for(var key in ERRNO_CODES){if(ERRNO_CODES[key]===errno){this.code=key;break}}}},genericErrors:{},filesystems:null,syncFSRequests:0,readFiles:{},FSStream:class{constructor(){this.shared={}}get object(){return this.node}set object(val){this.node=val}get isRead(){return(this.flags&2097155)!==1}get isWrite(){return(this.flags&2097155)!==0}get isAppend(){return this.flags&1024}get flags(){return this.shared.flags}set flags(val){this.shared.flags=val}get position(){return this.shared.position}set position(val){this.shared.position=val}},FSNode:class{constructor(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev;this.readMode=292|73;this.writeMode=146}get read(){return(this.mode&this.readMode)===this.readMode}set read(val){val?this.mode|=this.readMode:this.mode&=~this.readMode}get write(){return(this.mode&this.writeMode)===this.writeMode}set write(val){val?this.mode|=this.writeMode:this.mode&=~this.writeMode}get isFolder(){return FS.isDir(this.mode)}get isDevice(){return FS.isChrdev(this.mode)}},lookupPath(path,opts={}){path=PATH_FS.resolve(path);if(!path)return{path:"",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath(node){var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!=="/"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName(parentid,name){var hash=0;for(var i=0;i>>0)%FS.nameTable.length},hashAddNode(node){var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode(node){var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode(parent,name){var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode(parent,name,mode,rdev){assert(typeof parent=="object");var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode(node){FS.hashRemoveNode(node)},isRoot(node){return node===node.parent},isMountpoint(node){return!!node.mounted},isFile(mode){return(mode&61440)===32768},isDir(mode){return(mode&61440)===16384},isLink(mode){return(mode&61440)===40960},isChrdev(mode){return(mode&61440)===8192},isBlkdev(mode){return(mode&61440)===24576},isFIFO(mode){return(mode&61440)===4096},isSocket(mode){return(mode&49152)===49152},flagsToPermissionString(flag){var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions(node,perms){if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}else if(perms.includes("w")&&!(node.mode&146)){return 2}else if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup(dir){if(!FS.isDir(dir.mode))return 54;var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate(dir,name){try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete(dir,name,isdir){var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen(node,flags){if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!=="r"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd(){for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStreamChecked(fd){var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}return stream},getStream:fd=>FS.streams[fd],createStream(stream,fd=-1){assert(fd>=-1);stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream(fd){FS.streams[fd]=null},dupStream(origStream,fd=-1){var stream=FS.createStream(origStream,fd);stream.stream_ops?.dup?.(stream);return stream},chrdev_stream_ops:{open(stream){var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;stream.stream_ops.open?.(stream)},llseek(){throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice(dev,ops){FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts(mount){var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push(...m.mounts)}return mounts},syncfs(populate,callback){if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){assert(FS.syncFSRequests>0);FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount(type,opts,mountpoint){if(typeof type=="string"){throw type}var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type,opts,mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount(mountpoint){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);assert(idx!==-1);node.mount.mounts.splice(idx,1)},lookup(parent,name){return parent.node_ops.lookup(parent,name)},mknod(path,mode,dev){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name==="."||name===".."){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create(path,mode){mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir(path,mode){mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree(path,mode){var dirs=path.split("/");var d="";for(var i=0;i=0);if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write(stream,buffer,offset,length,position,canOwn){assert(offset>=0);if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate(stream,offset,length){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap(stream,length,position,prot,flags){if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}if(!length){throw new FS.ErrnoError(28)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync(stream,buffer,offset,length,mmapFlags){assert(offset>=0);if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},ioctl(stream,cmd,arg){if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile(path,opts={}){opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){throw new Error(`Invalid encoding type "${opts.encoding}"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){ret=UTF8ArrayToString(buf)}else if(opts.encoding==="binary"){ret=buf}FS.close(stream);return ret},writeFile(path,data,opts={}){opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir(path){var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories(){FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices(){FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories(){FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount(){var node=FS.createNode(proc_self,"fd",16384|511,73);node.node_ops={lookup(parent,name){var fd=+name;var stream=FS.getStreamChecked(fd);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},"/proc/self/fd")},createStandardStreams(input,output,error){if(input){FS.createDevice("/dev","stdin",input)}else{FS.symlink("/dev/tty","/dev/stdin")}if(output){FS.createDevice("/dev","stdout",null,output)}else{FS.symlink("/dev/tty","/dev/stdout")}if(error){FS.createDevice("/dev","stderr",null,error)}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1);assert(stdin.fd===0,`invalid handle for stdin (${stdin.fd})`);assert(stdout.fd===1,`invalid handle for stdout (${stdout.fd})`);assert(stderr.fd===2,`invalid handle for stderr (${stderr.fd})`)},staticInit(){[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack=""});FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={MEMFS}},init(input,output,error){assert(!FS.initialized,"FS.init was previously called. If you want to initialize later with custom parameters, remove any earlier calls (note that one is automatically added to the generated code)");FS.initialized=true;input??=Module["stdin"];output??=Module["stdout"];error??=Module["stderr"];FS.createStandardStreams(input,output,error)},quit(){FS.initialized=false;_fflush(0);for(var i=0;ithis.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]}setDataGetter(getter){this.getter=getter}cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)throw new Error("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")throw new Error("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true}get length(){if(!this.lengthKnown){this.cacheLength()}return this._length}get chunkSize(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}if(typeof XMLHttpRequest!="undefined"){if(!ENVIRONMENT_IS_WORKER)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var lazyArray=new LazyUint8Array;var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=(...args)=>{FS.forceLoadFile(node);return fn(...args)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);assert(size>=0);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr,allocated:true}};node.stream_ops=stream_ops;return node},absolutePath(){abort("FS.absolutePath has been removed; use PATH_FS.resolve instead")},createFolder(){abort("FS.createFolder has been removed; use FS.mkdir instead")},createLink(){abort("FS.createLink has been removed; use FS.symlink instead")},joinPath(){abort("FS.joinPath has been removed; use PATH.join instead")},mmapAlloc(){abort("FS.mmapAlloc has been replaced by the top level function mmapAlloc")},standardizePath(){abort("FS.standardizePath has been removed; use PATH.normalize instead")}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat(func,path,buf){var stat=func(path);HEAP32[buf>>2]=stat.dev;checkInt32(stat.dev);HEAP32[buf+4>>2]=stat.mode;checkInt32(stat.mode);HEAPU32[buf+8>>2]=stat.nlink;checkInt32(stat.nlink);HEAP32[buf+12>>2]=stat.uid;checkInt32(stat.uid);HEAP32[buf+16>>2]=stat.gid;checkInt32(stat.gid);HEAP32[buf+20>>2]=stat.rdev;checkInt32(stat.rdev);tempI64=[stat.size>>>0,(tempDouble=stat.size,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+24>>2]=tempI64[0],HEAP32[buf+28>>2]=tempI64[1];checkInt64(stat.size);HEAP32[buf+32>>2]=4096;checkInt32(4096);HEAP32[buf+36>>2]=stat.blocks;checkInt32(stat.blocks);var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();tempI64=[Math.floor(atime/1e3)>>>0,(tempDouble=Math.floor(atime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+40>>2]=tempI64[0],HEAP32[buf+44>>2]=tempI64[1];checkInt64(Math.floor(atime/1e3));HEAPU32[buf+48>>2]=atime%1e3*1e3*1e3;checkInt32(atime%1e3*1e3*1e3);tempI64=[Math.floor(mtime/1e3)>>>0,(tempDouble=Math.floor(mtime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+56>>2]=tempI64[0],HEAP32[buf+60>>2]=tempI64[1];checkInt64(Math.floor(mtime/1e3));HEAPU32[buf+64>>2]=mtime%1e3*1e3*1e3;checkInt32(mtime%1e3*1e3*1e3);tempI64=[Math.floor(ctime/1e3)>>>0,(tempDouble=Math.floor(ctime/1e3),+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+72>>2]=tempI64[0],HEAP32[buf+76>>2]=tempI64[1];checkInt64(Math.floor(ctime/1e3));HEAPU32[buf+80>>2]=ctime%1e3*1e3*1e3;checkInt32(ctime%1e3*1e3*1e3);tempI64=[stat.ino>>>0,(tempDouble=stat.ino,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[buf+88>>2]=tempI64[0],HEAP32[buf+92>>2]=tempI64[1];checkInt64(stat.ino);return 0},doMsync(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},getStreamFromFD(fd){var stream=FS.getStreamChecked(fd);return stream},varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);assert(flags===0||flags==512);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function syscallGetVarargI(){assert(SYSCALLS.varargs!=undefined);var ret=HEAP32[+SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret}var syscallGetVarargP=syscallGetVarargI;function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=syscallGetVarargI();if(arg<0){return-28}while(FS.streams[arg]){arg++}var newStream;newStream=FS.dupStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=syscallGetVarargI();stream.flags|=arg;return 0}case 12:{var arg=syscallGetVarargP();var offset=0;HEAP16[arg+offset>>1]=2;checkInt16(2);return 0}case 13:case 14:return 0}return-28}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var stringToUTF8=(str,outPtr,maxBytesToWrite)=>{assert(typeof maxBytesToWrite=="number","stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!");return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)};function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);stream.getdents||=FS.readdir(stream.path);var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx>>0,(tempDouble=id,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[dirp+pos>>2]=tempI64[0],HEAP32[dirp+pos+4>>2]=tempI64[1];checkInt64(id);tempI64=[(idx+1)*struct_size>>>0,(tempDouble=(idx+1)*struct_size,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[dirp+pos+8>>2]=tempI64[0],HEAP32[dirp+pos+12>>2]=tempI64[1];checkInt64((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;checkInt16(280);HEAP8[dirp+pos+18]=type;checkInt8(type);stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:{if(!stream.tty)return-59;return 0}case 21505:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcgets){var termios=stream.tty.ops.ioctl_tcgets(stream);var argp=syscallGetVarargP();HEAP32[argp>>2]=termios.c_iflag||0;checkInt32(termios.c_iflag||0);HEAP32[argp+4>>2]=termios.c_oflag||0;checkInt32(termios.c_oflag||0);HEAP32[argp+8>>2]=termios.c_cflag||0;checkInt32(termios.c_cflag||0);HEAP32[argp+12>>2]=termios.c_lflag||0;checkInt32(termios.c_lflag||0);for(var i=0;i<32;i++){HEAP8[argp+i+17]=termios.c_cc[i]||0;checkInt8(termios.c_cc[i]||0)}return 0}return 0}case 21510:case 21511:case 21512:{if(!stream.tty)return-59;return 0}case 21506:case 21507:case 21508:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcsets){var argp=syscallGetVarargP();var c_iflag=HEAP32[argp>>2];var c_oflag=HEAP32[argp+4>>2];var c_cflag=HEAP32[argp+8>>2];var c_lflag=HEAP32[argp+12>>2];var c_cc=[];for(var i=0;i<32;i++){c_cc.push(HEAP8[argp+i+17])}return stream.tty.ops.ioctl_tcsets(stream.tty,op,{c_iflag,c_oflag,c_cflag,c_lflag,c_cc})}return 0}case 21519:{if(!stream.tty)return-59;var argp=syscallGetVarargP();HEAP32[argp>>2]=0;checkInt32(0);return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=syscallGetVarargP();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tiocgwinsz){var winsize=stream.tty.ops.ioctl_tiocgwinsz(stream.tty);var argp=syscallGetVarargP();HEAP16[argp>>1]=winsize[0];checkInt16(winsize[0]);HEAP16[argp+2>>1]=winsize[1];checkInt16(winsize[1])}return 0}case 21524:{if(!stream.tty)return-59;return 0}case 21515:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]==="/")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;assert(!flags,`unknown flags in __syscall_newfstatat: ${flags}`);path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?syscallGetVarargI():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort("Invalid flags passed to unlinkat")}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __abort_js=()=>{abort("native code called abort()")};var nowIsMonotonic=1;var __emscripten_get_now_is_monotonic=()=>nowIsMonotonic;var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var __emscripten_runtime_keepalive_clear=()=>{noExitRuntime=false;runtimeKeepaliveCounter=0};var __emscripten_throw_longjmp=()=>{throw Infinity};var convertI32PairToI53Checked=(lo,hi)=>{assert(lo==lo>>>0||lo==(lo|0));assert(hi===(hi|0));return hi+2097152>>>0<4194305-!!lo?(lo>>>0)+hi*4294967296:NaN};function __mmap_js(len,prot,flags,fd,offset_low,offset_high,allocated,addr){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,offset,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;checkInt32(res.allocated);HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset_low,offset_high){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __tzset_js=(timezone,daylight,std_name,dst_name)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;checkInt32(stdTimezoneOffset*60);HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);checkInt32(Number(winterOffset!=summerOffset));var extractZone=timezoneOffset=>{var sign=timezoneOffset>=0?"-":"+";var absOffset=Math.abs(timezoneOffset);var hours=String(Math.floor(absOffset/60)).padStart(2,"0");var minutes=String(absOffset%60).padStart(2,"0");return`UTC${sign}${hours}${minutes}`};var winterName=extractZone(winterOffset);var summerName=extractZone(summerOffset);assert(winterName);assert(summerName);assert(lengthBytesUTF8(winterName)<=16,`timezone name truncated to fit in TZNAME_MAX (${winterName})`);assert(lengthBytesUTF8(summerName)<=16,`timezone name truncated to fit in TZNAME_MAX (${summerName})`);if(summerOffsetDate.now();var getHeapMax=()=>2147483648;var _emscripten_get_heap_max=()=>getHeapMax();var _emscripten_get_now=()=>performance.now();var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){err(`growMemory: Attempted to grow heap from ${b.byteLength} bytes to ${size} bytes, but got error: ${e}`)}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;assert(requestedSize>oldSize);var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){err(`Cannot enlarge memory, requested ${requestedSize} bytes, but the limit is ${maxHeapSize} bytes!`);return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var t0=_emscripten_get_now();var replacement=growMemory(newSize);var t1=_emscripten_get_now();dbg(`Heap resize call from ${oldSize} to ${newSize} took ${t1-t0} msecs. Success: ${!!replacement}`);if(replacement){return true}}err(`Failed to grow the heap from ${oldSize} bytes to ${newSize} bytes, not enough memory!`);return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:lang,_:getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var stringToAscii=(str,buffer)=>{for(var i=0;i{var bufSize=0;getEnvStrings().forEach((string,i)=>{var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;checkInt32(ptr);stringToAscii(string,ptr);bufSize+=string.length+1});return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;checkInt32(strings.length);var bufSize=0;strings.forEach(string=>bufSize+=string.length+1);HEAPU32[penviron_buf_size>>2]=bufSize;checkInt32(bufSize);return 0};function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf]=type;checkInt8(type);HEAP16[pbuf+2>>1]=flags;checkInt16(flags);tempI64=[rightsBase>>>0,(tempDouble=rightsBase,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[pbuf+8>>2]=tempI64[0],HEAP32[pbuf+12>>2]=tempI64[1];checkInt64(rightsBase);tempI64=[rightsInheriting>>>0,(tempDouble=rightsInheriting,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[pbuf+16>>2]=tempI64[0],HEAP32[pbuf+20>>2]=tempI64[1];checkInt64(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doReadv=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;checkInt32(num);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_seek(fd,offset_low,offset_high,whence,newOffset){var offset=convertI32PairToI53Checked(offset_low,offset_high);try{if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);tempI64=[stream.position>>>0,(tempDouble=stream.position,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[newOffset>>2]=tempI64[0],HEAP32[newOffset+4>>2]=tempI64[1];checkInt64(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doWritev=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;checkInt32(num);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var _getentropy=(buffer,size)=>{randomFill(HEAPU8.subarray(buffer,buffer+size));return 0};var _llvm_eh_typeid_for=type=>type;var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var getCFunc=ident=>{var func=Module["_"+ident];assert(func,"Cannot call unknown function "+ident+", make sure it is exported");return func};var writeArrayToMemory=(array,buffer)=>{assert(array.length>=0,"writeArrayToMemory array must have a length (should be an array or typed array)");HEAP8.set(array,buffer)};var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;assert(returnType!=="array",'Return type should not be "array".');if(args){for(var i=0;i(...args)=>ccall(ident,returnType,argTypes,args,opts);FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();function checkIncomingModuleAPI(){ignoredModuleProp("fetchSettings")}var wasmImports={__assert_fail:___assert_fail,__call_sighandler:___call_sighandler,__cxa_begin_catch:___cxa_begin_catch,__cxa_end_catch:___cxa_end_catch,__cxa_find_matching_catch_2:___cxa_find_matching_catch_2,__cxa_find_matching_catch_3:___cxa_find_matching_catch_3,__cxa_rethrow:___cxa_rethrow,__cxa_throw:___cxa_throw,__handle_stack_overflow:___handle_stack_overflow,__resumeException:___resumeException,__syscall_faccessat:___syscall_faccessat,__syscall_fcntl64:___syscall_fcntl64,__syscall_fstat64:___syscall_fstat64,__syscall_getdents64:___syscall_getdents64,__syscall_ioctl:___syscall_ioctl,__syscall_lstat64:___syscall_lstat64,__syscall_mkdirat:___syscall_mkdirat,__syscall_newfstatat:___syscall_newfstatat,__syscall_openat:___syscall_openat,__syscall_rmdir:___syscall_rmdir,__syscall_stat64:___syscall_stat64,__syscall_unlinkat:___syscall_unlinkat,_abort_js:__abort_js,_emscripten_get_now_is_monotonic:__emscripten_get_now_is_monotonic,_emscripten_memcpy_js:__emscripten_memcpy_js,_emscripten_runtime_keepalive_clear:__emscripten_runtime_keepalive_clear,_emscripten_throw_longjmp:__emscripten_throw_longjmp,_mmap_js:__mmap_js,_munmap_js:__munmap_js,_tzset_js:__tzset_js,emscripten_date_now:_emscripten_date_now,emscripten_get_heap_max:_emscripten_get_heap_max,emscripten_get_now:_emscripten_get_now,emscripten_resize_heap:_emscripten_resize_heap,environ_get:_environ_get,environ_sizes_get:_environ_sizes_get,fd_close:_fd_close,fd_fdstat_get:_fd_fdstat_get,fd_read:_fd_read,fd_seek:_fd_seek,fd_write:_fd_write,getentropy:_getentropy,invoke_d,invoke_diii,invoke_fi,invoke_fif,invoke_fifff,invoke_fii,invoke_fiii,invoke_fiiii,invoke_fiiiiii,invoke_fiiiiiii,invoke_fij,invoke_i,invoke_idiii,invoke_ii,invoke_iif,invoke_iifi,invoke_iifii,invoke_iifiiiii,invoke_iii,invoke_iiid,invoke_iiidii,invoke_iiif,invoke_iiiffi,invoke_iiii,invoke_iiiidi,invoke_iiiiffifffffffi,invoke_iiiifiii,invoke_iiiii,invoke_iiiiif,invoke_iiiiifi,invoke_iiiiii,invoke_iiiiiifi,invoke_iiiiiififiiiififiiiiiiiiiiiiiiiiiiii,invoke_iiiiiii,invoke_iiiiiiifii,invoke_iiiiiiii,invoke_iiiiiiiii,invoke_iiiiiiiiiffi,invoke_iiiiiiiiii,invoke_iiiiiiiiiifi,invoke_iiiiiiiiiii,invoke_iiiiiiiiiiii,invoke_iiiiiiiiiiiif,invoke_iiiiiiiiiiiiiii,invoke_iiiiiiij,invoke_iiiiiij,invoke_iiiiij,invoke_iiiij,invoke_iiij,invoke_iiiji,invoke_iiijii,invoke_iiijiii,invoke_iij,invoke_iijiii,invoke_iijj,invoke_iijji,invoke_iijjiii,invoke_ij,invoke_ijjiii,invoke_j,invoke_ji,invoke_jii,invoke_jiii,invoke_jiji,invoke_v,invoke_vdiii,invoke_vi,invoke_vid,invoke_vif,invoke_vififiif,invoke_vifii,invoke_vifiiifiiiiiiiiiiiiifiiii,invoke_vii,invoke_viid,invoke_viif,invoke_viiff,invoke_viifi,invoke_viifiii,invoke_viii,invoke_viiid,invoke_viiif,invoke_viiiffii,invoke_viiiffiiii,invoke_viiifi,invoke_viiii,invoke_viiiif,invoke_viiiii,invoke_viiiiif,invoke_viiiiii,invoke_viiiiiid,invoke_viiiiiif,invoke_viiiiiifi,invoke_viiiiiifif,invoke_viiiiiifii,invoke_viiiiiii,invoke_viiiiiiidiiii,invoke_viiiiiiii,invoke_viiiiiiiif,invoke_viiiiiiiii,invoke_viiiiiiiiii,invoke_viiiiiiiiiidii,invoke_viiiiiiiiiii,invoke_viiiiiiiiiiii,invoke_viiiiiiiiiiiiii,invoke_viiiiiiiiiiiiiiii,invoke_viiiiij,invoke_viiiiiji,invoke_viiiiji,invoke_viiiijiiifi,invoke_viiij,invoke_viiiji,invoke_viiijii,invoke_viiijjji,invoke_viij,invoke_viiji,invoke_viijii,invoke_viijj,invoke_vij,invoke_viji,invoke_vijif,invoke_vijii,invoke_vijj,invoke_vjjiii,llvm_eh_typeid_for:_llvm_eh_typeid_for,proc_exit:_proc_exit};var wasmExports=createWasm();var ___wasm_call_ctors=createExportWrapper("__wasm_call_ctors",0);var _web_engine_create=Module["_web_engine_create"]=createExportWrapper("web_engine_create",0);var _web_engine_get_memory_stats=Module["_web_engine_get_memory_stats"]=createExportWrapper("web_engine_get_memory_stats",1);var _web_engine_destroy=Module["_web_engine_destroy"]=createExportWrapper("web_engine_destroy",1);var _fflush=createExportWrapper("fflush",1);var _web_engine_get_live_handles=Module["_web_engine_get_live_handles"]=createExportWrapper("web_engine_get_live_handles",0);var _web_engine_get_allocated_bytes=Module["_web_engine_get_allocated_bytes"]=createExportWrapper("web_engine_get_allocated_bytes",0);var _web_engine_open_blend=Module["_web_engine_open_blend"]=createExportWrapper("web_engine_open_blend",3);var _web_engine_apply_command=Module["_web_engine_apply_command"]=createExportWrapper("web_engine_apply_command",3);var _web_engine_decimate_apply=Module["_web_engine_decimate_apply"]=createExportWrapper("web_engine_decimate_apply",35);var _web_engine_undo=Module["_web_engine_undo"]=createExportWrapper("web_engine_undo",1);var _web_engine_redo=Module["_web_engine_redo"]=createExportWrapper("web_engine_redo",1);var _web_engine_get_scene_snapshot=Module["_web_engine_get_scene_snapshot"]=createExportWrapper("web_engine_get_scene_snapshot",3);var _web_engine_get_scene_metadata=Module["_web_engine_get_scene_metadata"]=createExportWrapper("web_engine_get_scene_metadata",3);var _web_engine_get_scene_geometry=Module["_web_engine_get_scene_geometry"]=createExportWrapper("web_engine_get_scene_geometry",3);var _web_engine_get_scene_delta=Module["_web_engine_get_scene_delta"]=createExportWrapper("web_engine_get_scene_delta",3);var _web_engine_get_packed_asset=Module["_web_engine_get_packed_asset"]=createExportWrapper("web_engine_get_packed_asset",5);var _web_engine_evaluate_depsgraph=Module["_web_engine_evaluate_depsgraph"]=createExportWrapper("web_engine_evaluate_depsgraph",3);var _web_engine_save_blend=Module["_web_engine_save_blend"]=createExportWrapper("web_engine_save_blend",3);var _malloc=Module["_malloc"]=createExportWrapper("malloc",1);var _web_engine_free_buffer=Module["_web_engine_free_buffer"]=createExportWrapper("web_engine_free_buffer",1);var _free=Module["_free"]=createExportWrapper("free",1);var _web_engine_last_error_code=Module["_web_engine_last_error_code"]=createExportWrapper("web_engine_last_error_code",0);var _web_engine_last_error_message=Module["_web_engine_last_error_message"]=createExportWrapper("web_engine_last_error_message",0);var _strerror=createExportWrapper("strerror",1);var _emscripten_builtin_memalign=createExportWrapper("emscripten_builtin_memalign",2);var _setThrew=createExportWrapper("setThrew",2);var __emscripten_tempret_set=createExportWrapper("_emscripten_tempret_set",1);var __emscripten_tempret_get=createExportWrapper("_emscripten_tempret_get",0);var _emscripten_stack_init=()=>(_emscripten_stack_init=wasmExports["emscripten_stack_init"])();var _emscripten_stack_get_free=()=>(_emscripten_stack_get_free=wasmExports["emscripten_stack_get_free"])();var _emscripten_stack_get_base=()=>(_emscripten_stack_get_base=wasmExports["emscripten_stack_get_base"])();var _emscripten_stack_get_end=()=>(_emscripten_stack_get_end=wasmExports["emscripten_stack_get_end"])();var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["_emscripten_stack_restore"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["_emscripten_stack_alloc"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["emscripten_stack_get_current"])();var ___cxa_decrement_exception_refcount=createExportWrapper("__cxa_decrement_exception_refcount",1);var ___cxa_increment_exception_refcount=createExportWrapper("__cxa_increment_exception_refcount",1);var ___cxa_can_catch=createExportWrapper("__cxa_can_catch",3);var ___cxa_get_exception_ptr=createExportWrapper("__cxa_get_exception_ptr",1);var ___set_stack_limits=Module["___set_stack_limits"]=createExportWrapper("__set_stack_limits",2);var dynCall_viij=Module["dynCall_viij"]=createExportWrapper("dynCall_viij",5);var dynCall_viiji=Module["dynCall_viiji"]=createExportWrapper("dynCall_viiji",6);var dynCall_viijii=Module["dynCall_viijii"]=createExportWrapper("dynCall_viijii",7);var dynCall_vij=Module["dynCall_vij"]=createExportWrapper("dynCall_vij",4);var dynCall_viiiijiiifi=Module["dynCall_viiiijiiifi"]=createExportWrapper("dynCall_viiiijiiifi",12);var dynCall_viiijjji=Module["dynCall_viiijjji"]=createExportWrapper("dynCall_viiijjji",11);var dynCall_viiiiji=Module["dynCall_viiiiji"]=createExportWrapper("dynCall_viiiiji",8);var dynCall_viiiji=Module["dynCall_viiiji"]=createExportWrapper("dynCall_viiiji",7);var dynCall_ij=Module["dynCall_ij"]=createExportWrapper("dynCall_ij",3);var dynCall_viji=Module["dynCall_viji"]=createExportWrapper("dynCall_viji",5);var dynCall_iij=Module["dynCall_iij"]=createExportWrapper("dynCall_iij",4);var dynCall_fij=Module["dynCall_fij"]=createExportWrapper("dynCall_fij",4);var dynCall_vijf=Module["dynCall_vijf"]=createExportWrapper("dynCall_vijf",5);var dynCall_jiii=Module["dynCall_jiii"]=createExportWrapper("dynCall_jiii",4);var dynCall_viiij=Module["dynCall_viiij"]=createExportWrapper("dynCall_viiij",6);var dynCall_iiijii=Module["dynCall_iiijii"]=createExportWrapper("dynCall_iiijii",7);var dynCall_iiijiii=Module["dynCall_iiijiii"]=createExportWrapper("dynCall_iiijiii",8);var dynCall_iiij=Module["dynCall_iiij"]=createExportWrapper("dynCall_iiij",5);var dynCall_vjjiii=Module["dynCall_vjjiii"]=createExportWrapper("dynCall_vjjiii",8);var dynCall_ijjiii=Module["dynCall_ijjiii"]=createExportWrapper("dynCall_ijjiii",8);var dynCall_iijiii=Module["dynCall_iijiii"]=createExportWrapper("dynCall_iijiii",7);var dynCall_iijjiii=Module["dynCall_iijjiii"]=createExportWrapper("dynCall_iijjiii",9);var dynCall_iiiji=Module["dynCall_iiiji"]=createExportWrapper("dynCall_iiiji",6);var dynCall_jii=Module["dynCall_jii"]=createExportWrapper("dynCall_jii",3);var dynCall_iijj=Module["dynCall_iijj"]=createExportWrapper("dynCall_iijj",6);var dynCall_ji=Module["dynCall_ji"]=createExportWrapper("dynCall_ji",2);var dynCall_vijii=Module["dynCall_vijii"]=createExportWrapper("dynCall_vijii",6);var dynCall_vijif=Module["dynCall_vijif"]=createExportWrapper("dynCall_vijif",6);var dynCall_viiiiij=Module["dynCall_viiiiij"]=createExportWrapper("dynCall_viiiiij",8);var dynCall_viiiiiji=Module["dynCall_viiiiiji"]=createExportWrapper("dynCall_viiiiiji",9);var dynCall_iiiij=Module["dynCall_iiiij"]=createExportWrapper("dynCall_iiiij",6);var dynCall_vijj=Module["dynCall_vijj"]=createExportWrapper("dynCall_vijj",6);var dynCall_iijji=Module["dynCall_iijji"]=createExportWrapper("dynCall_iijji",7);var dynCall_j=Module["dynCall_j"]=createExportWrapper("dynCall_j",1);var dynCall_iiiiiiij=Module["dynCall_iiiiiiij"]=createExportWrapper("dynCall_iiiiiiij",9);var dynCall_viijj=Module["dynCall_viijj"]=createExportWrapper("dynCall_viijj",7);var dynCall_iiiiij=Module["dynCall_iiiiij"]=createExportWrapper("dynCall_iiiiij",7);var dynCall_viiijii=Module["dynCall_viiijii"]=createExportWrapper("dynCall_viiijii",8);var dynCall_jij=Module["dynCall_jij"]=createExportWrapper("dynCall_jij",4);var dynCall_vijji=Module["dynCall_vijji"]=createExportWrapper("dynCall_vijji",7);var dynCall_iiiiiij=Module["dynCall_iiiiiij"]=createExportWrapper("dynCall_iiiiiij",8);var dynCall_jiji=Module["dynCall_jiji"]=createExportWrapper("dynCall_jiji",5);var dynCall_iiiiijj=Module["dynCall_iiiiijj"]=createExportWrapper("dynCall_iiiiijj",9);var dynCall_iiiiiijj=Module["dynCall_iiiiiijj"]=createExportWrapper("dynCall_iiiiiijj",10);function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vif(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fi(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiif(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_diii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viifiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiffi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiffifffffffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiifi(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiififiiiififiiiiiiiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vififiif(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vifii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viif(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viifi(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiif(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fif(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifi(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vdiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_idiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiif(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiifiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_d(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fifff(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiifi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fiiiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiifi(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vid(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiff(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iif(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiffi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiffiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iifiiiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiifif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiif(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vifiiifiiiiiiiiiiiiifiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiiif(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiid(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiidiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiidii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiidi(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viid(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiidii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiid(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiffii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiid(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_fij(index,a1,a2,a3){var sp=stackSave();try{return dynCall_fij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viij(index,a1,a2,a3,a4){var sp=stackSave();try{dynCall_viij(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2,a3){var sp=stackSave();try{return dynCall_iij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ij(index,a1,a2){var sp=stackSave();try{return dynCall_ij(index,a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vij(index,a1,a2,a3){var sp=stackSave();try{dynCall_vij(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiii(index,a1,a2,a3){var sp=stackSave();try{return dynCall_jiii(index,a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiji(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiji(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viijii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viijii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiijiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11){var sp=stackSave();try{dynCall_viiiijiiifi(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiijjji(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10){var sp=stackSave();try{dynCall_viiijjji(index,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiji(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiiiji(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiji(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viiiji(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_viiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiijii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iiijii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiijiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_iiijiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiij(index,a1,a2,a3,a4){var sp=stackSave();try{return dynCall_iiij(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viji(index,a1,a2,a3,a4){var sp=stackSave();try{dynCall_viji(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijii(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiji(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iiiji(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iijiii(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijjiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return dynCall_iijjiii(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vjjiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_vjjiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ijjiii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_ijjiii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iijj(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiij(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiiiij(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijif(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijif(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiji(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{dynCall_viiiiiji(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iijji(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iijji(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{dynCall_vijj(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viijj(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{dynCall_viijj(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ji(index,a1){var sp=stackSave();try{return dynCall_ji(index,a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiij(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return dynCall_iiiiij(index,a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiij(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return dynCall_iiiij(index,a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_j(index){var sp=stackSave();try{return dynCall_j(index)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiij(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return dynCall_iiiiiiij(index,a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiijii(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{dynCall_viiijii(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jii(index,a1,a2){var sp=stackSave();try{return dynCall_jii(index,a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiij(index,a1,a2,a3,a4,a5,a6,a7){var sp=stackSave();try{return dynCall_iiiiiij(index,a1,a2,a3,a4,a5,a6,a7)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_jiji(index,a1,a2,a3,a4){var sp=stackSave();try{return dynCall_jiji(index,a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["UTF8ToString"]=UTF8ToString;var missingLibrarySymbols=["writeI53ToI64","writeI53ToI64Clamped","writeI53ToI64Signaling","writeI53ToU64Clamped","writeI53ToU64Signaling","readI53FromI64","readI53FromU64","convertI32PairToI53","convertU32PairToI53","getTempRet0","exitJS","inetPton4","inetNtop4","inetPton6","inetNtop6","readSockaddr","writeSockaddr","emscriptenLog","readEmAsmArgs","jstoi_q","listenOnce","autoResumeAudioContext","dynCallLegacy","getDynCaller","dynCall","handleException","runtimeKeepalivePush","runtimeKeepalivePop","callUserCallback","maybeExit","asmjsMangle","HandleAllocator","getNativeTypeSize","STACK_SIZE","STACK_ALIGN","POINTER_SIZE","ASSERTIONS","uleb128Encode","sigToWasmTypes","generateFuncType","convertJsFunctionToWasm","getEmptyTableSlot","updateTableMap","getFunctionAddress","addFunction","removeFunction","reallyNegative","unSign","strLen","reSign","formatString","intArrayToString","AsciiToString","UTF16ToString","stringToUTF16","lengthBytesUTF16","UTF32ToString","stringToUTF32","lengthBytesUTF32","stringToNewUTF8","registerKeyEventCallback","maybeCStringToJsString","findEventTarget","getBoundingClientRect","fillMouseEventData","registerMouseEventCallback","registerWheelEventCallback","registerUiEventCallback","registerFocusEventCallback","fillDeviceOrientationEventData","registerDeviceOrientationEventCallback","fillDeviceMotionEventData","registerDeviceMotionEventCallback","screenOrientation","fillOrientationChangeEventData","registerOrientationChangeEventCallback","fillFullscreenChangeEventData","registerFullscreenChangeEventCallback","JSEvents_requestFullscreen","JSEvents_resizeCanvasForFullscreen","registerRestoreOldStyle","hideEverythingExceptGivenElement","restoreHiddenElements","setLetterbox","softFullscreenResizeWebGLRenderTarget","doRequestFullscreen","fillPointerlockChangeEventData","registerPointerlockChangeEventCallback","registerPointerlockErrorEventCallback","requestPointerLock","fillVisibilityChangeEventData","registerVisibilityChangeEventCallback","registerTouchEventCallback","fillGamepadEventData","registerGamepadEventCallback","registerBeforeUnloadEventCallback","fillBatteryEventData","battery","registerBatteryEventCallback","setCanvasElementSize","getCanvasElementSize","jsStackTrace","getCallstack","convertPCtoSourceLocation","checkWasiClock","wasiRightsToMuslOFlags","wasiOFlagsToMuslOFlags","createDyncallWrapper","safeSetTimeout","setImmediateWrapped","clearImmediateWrapped","polyfillSetImmediate","registerPostMainLoop","registerPreMainLoop","getPromise","makePromise","idsToPromises","makePromiseCallback","Browser_asyncPrepareDataCounter","safeRequestAnimationFrame","isLeapYear","ydayFromDate","arraySum","addDays","getSocketFromFD","getSocketAddress","FS_unlink","FS_mkdirTree","_setNetworkCallback","heapObjectForWebGLType","toTypedArrayIndex","webgl_enable_ANGLE_instanced_arrays","webgl_enable_OES_vertex_array_object","webgl_enable_WEBGL_draw_buffers","webgl_enable_WEBGL_multi_draw","webgl_enable_EXT_polygon_offset_clamp","webgl_enable_EXT_clip_control","webgl_enable_WEBGL_polygon_mode","emscriptenWebGLGet","computeUnpackAlignedImageSize","colorChannelsInGlTextureFormat","emscriptenWebGLGetTexPixelData","emscriptenWebGLGetUniform","webglGetUniformLocation","webglPrepareUniformLocationsBeforeFirstUse","webglGetLeftBracePos","emscriptenWebGLGetVertexAttrib","__glGetActiveAttribOrUniform","writeGLArray","registerWebGlEventCallback","runAndAbortIfError","ALLOC_NORMAL","ALLOC_STACK","allocate","writeStringToMemory","writeAsciiToMemory","setErrNo","demangle","stackTrace"];missingLibrarySymbols.forEach(missingLibrarySymbol);var unexportedSymbols=["run","addOnPreRun","addOnInit","addOnPreMain","addOnExit","addOnPostRun","addRunDependency","removeRunDependency","out","err","callMain","abort","wasmMemory","wasmExports","writeStackCookie","checkStackCookie","convertI32PairToI53Checked","stackSave","stackRestore","stackAlloc","setTempRet0","ptrToString","zeroMemory","getHeapMax","growMemory","ENV","setStackLimits","ERRNO_CODES","strError","DNS","Protocols","Sockets","initRandomFill","randomFill","timers","warnOnce","readEmAsmArgsArray","jstoi_s","getExecutableName","keepRuntimeAlive","asyncLoad","alignMemory","mmapAlloc","wasmTable","noExitRuntime","getCFunc","freeTableIndexes","functionsInTableMap","setValue","getValue","PATH","PATH_FS","UTF8Decoder","UTF8ArrayToString","stringToUTF8Array","stringToUTF8","lengthBytesUTF8","intArrayFromString","stringToAscii","UTF16Decoder","stringToUTF8OnStack","writeArrayToMemory","JSEvents","specialHTMLTargets","findCanvasEventTarget","currentFullscreenStrategy","restoreOldWindowedStyle","UNWIND_CACHE","ExitStatus","getEnvStrings","doReadv","doWritev","promiseMap","uncaughtExceptionCount","exceptionLast","exceptionCaught","ExceptionInfo","findMatchingCatch","Browser","getPreloadedImageData__data","wget","MONTH_DAYS_REGULAR","MONTH_DAYS_LEAP","MONTH_DAYS_REGULAR_CUMULATIVE","MONTH_DAYS_LEAP_CUMULATIVE","SYSCALLS","preloadPlugins","FS_createPreloadedFile","FS_modeStringToFlags","FS_getMode","FS_stdin_getChar_buffer","FS_stdin_getChar","FS_createPath","FS_createDevice","FS_readFile","FS","FS_createDataFile","FS_createLazyFile","MEMFS","TTY","PIPEFS","SOCKFS","tempFixedLengthArray","miniTempWebGLFloatBuffers","miniTempWebGLIntBuffers","GL","AL","GLUT","EGL","GLEW","IDBStore","SDL","SDL_gfx","allocateUTF8","allocateUTF8OnStack","print","printErr"];unexportedSymbols.forEach(unexportedRuntimeSymbol);var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function stackCheckInit(){_emscripten_stack_init();writeStackCookie()}function run(){if(runDependencies>0){return}stackCheckInit();if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();assert(!Module["_main"],'compiled without a main, but one is present. if you added it from JS, use Module["onRuntimeInitialized"]');postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}checkStackCookie()}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise;for(const prop of Object.keys(Module)){if(!(prop in moduleArg)){Object.defineProperty(moduleArg,prop,{configurable:true,get(){abort(`Access to module property ('${prop}') is no longer possible via the module constructor argument; Instead, use the result of the module constructor.`)}})}} return moduleRtn; diff --git a/web/app/src/vendor/blender/web_engine.wasm b/web/app/src/vendor/blender/web_engine.wasm old mode 100755 new mode 100644 index 8ec8efc4..4568056d Binary files a/web/app/src/vendor/blender/web_engine.wasm and b/web/app/src/vendor/blender/web_engine.wasm differ diff --git a/web/app/src/volume/nanovdb-float32.ts b/web/app/src/volume/nanovdb-float32.ts index 35529d3a..fee41b6c 100644 --- a/web/app/src/volume/nanovdb-float32.ts +++ b/web/app/src/volume/nanovdb-float32.ts @@ -2,6 +2,10 @@ import type { NanoVDBFloat32TreeLayoutIR, NanoVDBGridIR } from "../../../protoco export interface NanoVDBSampleIR { value: number; active: boolean } +type NanoVDBNearestLocation = + | { sample: NanoVDBSampleIR } + | { leaf: number; voxel: number }; + export class NanoVDBFloat32Sampler { private readonly view: DataView; private readonly layout: NanoVDBFloat32TreeLayoutIR; @@ -24,6 +28,20 @@ export class NanoVDBFloat32Sampler { } nearest(coord: readonly [number, number, number]): NanoVDBSampleIR { + const location = this.nearestLocation(coord); + if ("sample" in location) return location.sample; + return { + value: this.f32(location.leaf + this.layout.leafValuesOffset + location.voxel * 4), + active: this.mask(location.leaf + this.layout.leafValueMaskOffset, location.voxel), + }; + } + + leafByteOffset(coord: readonly [number, number, number]): number | null { + const location = this.nearestLocation(coord); + return "leaf" in location ? location.leaf : null; + } + + private nearestLocation(coord: readonly [number, number, number]): NanoVDBNearestLocation { if (coord.some((value) => !Number.isSafeInteger(value) || value < -0x8000_0000 || value > 0x7fff_ffff)) throw new Error("NANOVDB_MANIFEST_INVALID: sample coordinate is outside int32"); const tableSize = this.u32(this.root + this.layout.rootTableSizeOffset); const key = this.rootKey(coord); @@ -39,20 +57,20 @@ export class NanoVDBFloat32Sampler { if (candidate > key) low = middle + 1; else high = middle - 1; } - if (tile < 0) return { value: this.view.getFloat32(this.root + 28, true), active: false }; + if (tile < 0) return { sample: { value: this.view.getFloat32(this.root + 28, true), active: false } }; const child = this.i64(tile + this.layout.rootTileChildOffset); - if (child === 0n) return { value: this.f32(tile + this.layout.rootTileValueOffset), active: this.u32(tile + this.layout.rootTileStateOffset) !== 0 }; + if (child === 0n) return { sample: { value: this.f32(tile + this.layout.rootTileValueOffset), active: this.u32(tile + this.layout.rootTileStateOffset) !== 0 } }; const upper = this.child(this.root, child, this.layout.upperNodeBytes); const upperOffset = (((coord[0] >>> 0 & 4095) >>> 7) << 10) | (((coord[1] >>> 0 & 4095) >>> 7) << 5) | ((coord[2] >>> 0 & 4095) >>> 7); const upperSample = this.internal(upper, upperOffset, this.layout.upperValueMaskOffset, this.layout.upperChildMaskOffset, this.layout.upperTableOffset, this.layout.lowerNodeBytes); - if ("sample" in upperSample) return upperSample.sample; + if ("sample" in upperSample) return upperSample; const lower = upperSample.child; const lowerOffset = (((coord[0] >>> 0 & 127) >>> 3) << 8) | (((coord[1] >>> 0 & 127) >>> 3) << 4) | ((coord[2] >>> 0 & 127) >>> 3); const lowerSample = this.internal(lower, lowerOffset, this.layout.lowerValueMaskOffset, this.layout.lowerChildMaskOffset, this.layout.lowerTableOffset, this.layout.leafNodeBytes); - if ("sample" in lowerSample) return lowerSample.sample; + if ("sample" in lowerSample) return lowerSample; const leaf = lowerSample.child; const voxel = ((coord[0] >>> 0 & 7) << 6) | ((coord[1] >>> 0 & 7) << 3) | (coord[2] >>> 0 & 7); - return { value: this.f32(leaf + this.layout.leafValuesOffset + voxel * 4), active: this.mask(leaf + this.layout.leafValueMaskOffset, voxel) }; + return { leaf, voxel }; } linear(coord: readonly [number, number, number]): NanoVDBSampleIR { diff --git a/web/app/src/volume/nanovdb-viewport.ts b/web/app/src/volume/nanovdb-viewport.ts index bec4abf8..0101dd10 100644 --- a/web/app/src/volume/nanovdb-viewport.ts +++ b/web/app/src/volume/nanovdb-viewport.ts @@ -7,6 +7,16 @@ import { type NanoVDBRangeIR, } from "../../../protocol/volume-vdb"; import { + dispatchNanoVDBPageFeedbackBatch, + type NanoVDBPageFeedbackBatch, + type NanoVDBPageFeedbackDispatchResult, +} from "../../../protocol/nanovdb-page-feedback"; +import { + planNanoVDBDeviceLossReplay, + type NanoVDBDeviceLossReplayPlanIR, +} from "../../../protocol/nanovdb-device-recovery"; +import { + createNanoVDBFloat32GridPaged, NanoVDBWebGPUDeviceSession, probeNanoVDBWebGPU, renderNanoVDBFloat32WebGPU, @@ -267,6 +277,177 @@ export async function loadNanoVDBGridPage( return output.buffer; } +export interface NanoVDBFeedbackPageIR { + pageId: number; + renderRevision: number; + data: ArrayBuffer; +} + +export interface NanoVDBDeviceLossGridReplayIR { + grid: NanoVDBWebGPUGrid; + plan: NanoVDBDeviceLossReplayPlanIR; + residentBeforeReplay: readonly number[]; +} + +export async function rebuildNanoVDBGridAfterDeviceLoss( + device: GPUDevice, + manifestValue: NanoVDBBundleManifestIR, + gridName: string, + source: NanoVDBRangeSource, + visiblePageIds: readonly number[], + signal: AbortSignal, +): Promise { + const manifest = validateNanoVDBBundleManifest(manifestValue); + const gridDefinition = manifest.grids.find((candidate) => candidate.name === gridName); + if (!gridDefinition) throw new Error(`NANOVDB_MANIFEST_INVALID: grid ${gridName} is missing`); + const rebuilt = createNanoVDBFloat32GridPaged( + device, + gridDefinition.byteLength, + manifest.gpu.pageByteLength, + manifest.gpu.maxResidentBytes, + ); + const residentBeforeReplay = [...rebuilt.residentVirtualPages]; + const plan = planNanoVDBDeviceLossReplay(visiblePageIds, rebuilt.pageCount, rebuilt.residentPageCapacity); + try { + for (const pageId of plan.replayedPageIds) { + if (signal.aborted) throw new DOMException("NanoVDB device-loss replay cancelled", "AbortError"); + const data = await loadNanoVDBGridPage(manifest, gridName, pageId, source, signal); + rebuilt.uploadPage(pageId, data); + } + return { grid: rebuilt, plan, residentBeforeReplay }; + } + catch (error) { + rebuilt.dispose(); + throw error; + } +} + +interface NanoVDBPageRequestSubscriber { + signal: AbortSignal; + onAbort: () => void; + resolve: (data: ArrayBuffer) => void; + reject: (error: unknown) => void; +} + +interface NanoVDBPendingPageRequest { + controller: AbortController; + subscribers: Map; +} + +export interface NanoVDBPageRequestCoordinatorStatsIR { + pendingPages: number; + subscribers: number; + pageIds: number[]; +} + +function pageRequestCancelled(): DOMException { + return new DOMException("NanoVDB page request cancelled", "AbortError"); +} + +export class NanoVDBGridPageRequestCoordinator { + private readonly manifest: NanoVDBBundleManifestIR; + private readonly pending = new Map(); + private disposed = false; + + constructor( + manifestValue: NanoVDBBundleManifestIR, + private readonly gridName: string, + private readonly source: NanoVDBRangeSource, + ) { + this.manifest = validateNanoVDBBundleManifest(manifestValue); + if (!this.manifest.grids.some((grid) => grid.name === gridName)) { + throw new Error(`NANOVDB_MANIFEST_INVALID: grid ${gridName} is missing`); + } + } + + request(pageId: number, signal: AbortSignal): Promise { + if (this.disposed || signal.aborted) return Promise.reject(pageRequestCancelled()); + let request = this.pending.get(pageId); + if (!request) { + request = { controller: new AbortController(), subscribers: new Map() }; + this.pending.set(pageId, request); + const current = request; + void loadNanoVDBGridPage(this.manifest, this.gridName, pageId, this.source, current.controller.signal).then( + (data) => this.resolve(pageId, current, data), + (error) => this.reject(pageId, current, error), + ); + } + const current = request; + return new Promise((resolve, reject) => { + const token = Symbol("NanoVDB page subscriber"); + const onAbort = (): void => { + const subscriber = current.subscribers.get(token); + if (!subscriber) return; + current.subscribers.delete(token); + signal.removeEventListener("abort", onAbort); + reject(pageRequestCancelled()); + if (current.subscribers.size === 0) { + if (this.pending.get(pageId) === current) this.pending.delete(pageId); + current.controller.abort(); + } + }; + current.subscribers.set(token, { signal, onAbort, resolve, reject }); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); + } + + stats(): NanoVDBPageRequestCoordinatorStatsIR { + return { + pendingPages: this.pending.size, + subscribers: [...this.pending.values()].reduce((total, request) => total + request.subscribers.size, 0), + pageIds: [...this.pending.keys()].sort((left, right) => left - right), + }; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const [pageId, request] of this.pending) { + this.pending.delete(pageId); + request.controller.abort(); + for (const subscriber of request.subscribers.values()) { + subscriber.signal.removeEventListener("abort", subscriber.onAbort); + subscriber.reject(pageRequestCancelled()); + } + request.subscribers.clear(); + } + } + + private resolve(pageId: number, request: NanoVDBPendingPageRequest, data: ArrayBuffer): void { + if (this.pending.get(pageId) === request) this.pending.delete(pageId); + for (const subscriber of request.subscribers.values()) { + subscriber.signal.removeEventListener("abort", subscriber.onAbort); + subscriber.resolve(data.slice(0)); + } + request.subscribers.clear(); + } + + private reject(pageId: number, request: NanoVDBPendingPageRequest, error: unknown): void { + if (this.pending.get(pageId) === request) this.pending.delete(pageId); + for (const subscriber of request.subscribers.values()) { + subscriber.signal.removeEventListener("abort", subscriber.onAbort); + subscriber.reject(error); + } + request.subscribers.clear(); + } +} + +export async function loadNanoVDBFeedbackPages( + batch: NanoVDBPageFeedbackBatch, + currentRenderRevision: number, + requests: NanoVDBGridPageRequestCoordinator, + signal: AbortSignal, + consume: (page: NanoVDBFeedbackPageIR) => Promise | void, +): Promise { + return dispatchNanoVDBPageFeedbackBatch(batch, currentRenderRevision, async (pageId, renderRevision) => { + if (signal.aborted) throw new DOMException("NanoVDB feedback page load cancelled", "AbortError"); + const data = await requests.request(pageId, signal); + if (signal.aborted) throw new DOMException("NanoVDB feedback page load cancelled", "AbortError"); + await consume({ pageId, renderRevision, data }); + }); +} + export function validateNanoVDBViewportAsset(value: NanoVDBViewportAssetIR): NanoVDBViewportAssetIR { if (!value.dataId || value.dataId.length > 256) throw new Error("NANOVDB_MANIFEST_INVALID: viewport dataId"); const manifest = validateNanoVDBBundleManifest(value.manifest); diff --git a/web/app/src/workers/curve-topology-contract-test.worker.ts b/web/app/src/workers/curve-topology-contract-test.worker.ts new file mode 100644 index 00000000..27a4f202 --- /dev/null +++ b/web/app/src/workers/curve-topology-contract-test.worker.ts @@ -0,0 +1,43 @@ +import { + CURVE_TOPOLOGY_EDITOR_BUDGET, + createCurveTopologyEditorManifest, + parseCurveTopologyOperationClaim, +} from "../../../protocol/curve-topology-editor"; + +const scope = self as unknown as { onmessage: (() => void) | null; postMessage(value: unknown): void }; + +scope.onmessage = (): void => { + const manifest = createCurveTopologyEditorManifest(); + const accepted = parseCurveTopologyOperationClaim({ + schemaVersion: 1, + operator: "ADD_SPLINE", + dataId: "curve:BrowserCurve", + baseRevision: 9, + inputSplineCount: 1, + inputPointCount: 2, + selectedSplineIndices: [], + selectedPointIndices: [], + addedSplineCount: 1, + addedPointCount: 2, + outputSplineCount: 2, + outputPointCount: 4, + payloadBytes: 256, + }, 9); + const failure = (value: unknown, revision = 9): string => { + try { parseCurveTopologyOperationClaim(value, revision); } + catch (error) { return (error as { code?: string }).code ?? ""; } + return ""; + }; + scope.postMessage({ + operatorCount: manifest.operators.length, + blockedCount: manifest.operators.filter((operator) => operator.gate.status === "BLOCKED").length, + readyOperators: manifest.operators.filter((operator) => operator.gate.status === "READY").map((operator) => operator.id), + sourceAuthority: manifest.sourceAuthority, + atomicMainTransaction: manifest.atomicMainTransaction, + accepted, + unknownCode: failure({ ...accepted, operator: "SPIN" }), + staleCode: failure(accepted, 10), + budgetCode: failure({ ...accepted, payloadBytes: CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes + 1 }), + stage: "ONE_VERIFIED_OPERATOR", + }); +}; diff --git a/web/app/src/workers/external-vfont-test.worker.ts b/web/app/src/workers/external-vfont-test.worker.ts new file mode 100644 index 00000000..c8d8419d --- /dev/null +++ b/web/app/src/workers/external-vfont-test.worker.ts @@ -0,0 +1,45 @@ +import { validateExternalVFontImport } from "../../../protocol/external-vfont"; + +interface Request { + data: ArrayBuffer; + sha256: string; +} + +const scope = self as unknown as { onmessage: ((event: MessageEvent) => void) | null; postMessage: (value: unknown) => void }; + +scope.onmessage = (event): void => { + void (async () => { + const validated = await validateExternalVFontImport({ + sourcePath: "//fonts/browser-bfont.pfb", + mimeType: "application/x-font-type1", + byteLength: event.data.data.byteLength, + sha256: event.data.sha256, + data: event.data.data, + }); + let spoofCode = ""; + try { + await validateExternalVFontImport({ + sourcePath: "//fonts/browser-bfont.otf", + mimeType: "font/otf", + byteLength: validated.data.byteLength, + sha256: event.data.sha256, + data: validated.data, + }); + } + catch (error) { spoofCode = (error as { code?: string }).code ?? ""; } + scope.postMessage({ + metadata: { + schemaVersion: validated.schemaVersion, + sourcePath: validated.sourcePath, + fileName: validated.fileName, + format: validated.format, + mimeType: validated.mimeType, + byteLength: validated.byteLength, + sha256: validated.sha256, + }, + copied: validated.data !== event.data.data && new Uint8Array(validated.data).every((value, index) => value === new Uint8Array(event.data.data)[index]), + spoofCode, + stage: "VALIDATED_BEFORE_STORAGE_OR_MAIN", + }); + })().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); +}; diff --git a/web/app/src/workers/grease-pencil-schema-test.worker.ts b/web/app/src/workers/grease-pencil-schema-test.worker.ts index ee98d1ef..c005d2d9 100644 --- a/web/app/src/workers/grease-pencil-schema-test.worker.ts +++ b/web/app/src/workers/grease-pencil-schema-test.worker.ts @@ -1,17 +1,18 @@ import { GREASE_PENCIL_BUDGET, parseGreasePencilData } from "../../../protocol/grease-pencil"; -const point = (x: number) => ({ position: [x, 0, 0], radius: 0.05, opacity: 1 }); +const point = (x: number, index: number) => ({ id: `grease-pencil-point:SchemaFixture:0:0:${index}`, position: [x, 0, 0], radius: 0.05, opacity: 1 }); const valid = { - id: "gp:SchemaFixture", + id: "grease-pencil:SchemaFixture", name: "SchemaFixture", geometryStatus: "available", layerCount: 1, frameCount: 1, strokeCount: 1, pointCount: 2, + activeLayerId: "grease-pencil-layer:SchemaFixture:Lines", layers: [{ - id: "layer:Lines", + id: "grease-pencil-layer:SchemaFixture:Lines", name: "Lines", visible: true, locked: false, @@ -19,10 +20,10 @@ const valid = { frames: [{ frame: 1, drawing: { - id: "drawing:1", + id: "grease-pencil-drawing:SchemaFixture:0", strokeCount: 1, pointCount: 2, - strokes: [{ id: "stroke:1", cyclic: false, pointCount: 2, points: [point(0), point(1)] }], + strokes: [{ id: "grease-pencil-stroke:SchemaFixture:0:0", cyclic: false, pointCount: 2, points: [point(0, 0), point(1, 1)] }], }, }], }], @@ -49,13 +50,8 @@ function result(): Record { ...valid, geometryStatus: "blocked", pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1, - layers: [{ - ...valid.layers[0], - frames: [{ - ...valid.layers[0].frames[0], - drawing: { ...valid.layers[0].frames[0].drawing, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1, strokes: [{ cyclic: false, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1 }] }, - }], - }], + activeLayerId: undefined, + layers: [], strokeCount: 1, frameCount: 1, errorCode: "GREASE_PENCIL_BUDGET_EXCEEDED", diff --git a/web/app/src/workers/grease-pencil-viewport-test.worker.ts b/web/app/src/workers/grease-pencil-viewport-test.worker.ts index 257cf4e9..ba0977c7 100644 --- a/web/app/src/workers/grease-pencil-viewport-test.worker.ts +++ b/web/app/src/workers/grease-pencil-viewport-test.worker.ts @@ -23,10 +23,10 @@ self.onmessage = () => { id: "grease-pencil-drawing:Viewport", strokeCount: 1, pointCount: 3, - strokes: [{ id: "grease-pencil-stroke:Viewport", cyclic: false, pointCount: 3, points: [ - { position: [-1, 0, 0], radius: 0.1, opacity: 1 }, - { position: [0, 0, 0], radius: 0.1, opacity: 1 }, - { position: [1, 0, 0], radius: 0.1, opacity: 1 }, + strokes: [{ id: "grease-pencil-stroke:Viewport:0:0", cyclic: false, pointCount: 3, points: [ + { id: "grease-pencil-point:Viewport:0:0:0", position: [-1, 0, 0], radius: 0.1, opacity: 1 }, + { id: "grease-pencil-point:Viewport:0:0:1", position: [0, 0, 0], radius: 0.1, opacity: 1 }, + { id: "grease-pencil-point:Viewport:0:0:2", position: [1, 0, 0], radius: 0.1, opacity: 1 }, ] }], } }] }], }; diff --git a/web/app/src/workers/m10-domain-gate-test.worker.ts b/web/app/src/workers/m10-domain-gate-test.worker.ts new file mode 100644 index 00000000..6631fd45 --- /dev/null +++ b/web/app/src/workers/m10-domain-gate-test.worker.ts @@ -0,0 +1,285 @@ +import { + GEOMETRY_NODE_GRAPH_BUDGET, + gateGeometryNodeGraph, + parseGeometryNodeGraph, + type GeometryNodeGraphIR, + validateGeometryNodeGraph, +} from "../../../protocol/geometry-nodes"; +import { NLA_STACK_BUDGET, gateNlaTracks } from "../../../protocol/nla"; +import { + SHADER_COMPILE_BUDGET, + compileMaterialGraph, +} from "../../../protocol/shader-compiler"; +import { + SIMULATION_CACHE_BUDGET, + computeSimulationCacheRevisionHash, + parseSimulationCacheManifest, + verifySimulationCache, + verifySimulationCacheFrame, +} from "../../../protocol/simulation-cache"; +import type { MaterialIR } from "../../../protocol/scene-ir"; + +type Domain = "GN" | "SHADER" | "NLA" | "SIMULATION"; +type GateResult = { + domain: Domain; + performanceMs: number; + workUnits: number; + oomCode: string; + maliciousCode: string; + recovered: boolean; + performanceStatus: string; + oomPreventedBeforeAllocation: boolean; +}; + +function geometryGraph(nodeCount: number): GeometryNodeGraphIR { + return { + schemaVersion: 1, + id: "node-group:M10Gate", + name: "M10 Gate", + interfaceInputs: [], + interfaceOutputs: [], + nodes: Array.from({ length: nodeCount }, (_value, index) => ({ + id: `node:${index}`, + type: "ShaderNodeValue", + name: `Value ${index}`, + sockets: [], + })), + links: [], + groupReferences: [], + }; +} + +function runGeometryNodes(): GateResult { + const graph = geometryGraph(512); + const workUnits = 10; + const started = performance.now(); + for (let index = 0; index < workUnits; index += 1) { + const parsed = parseGeometryNodeGraph(graph); + if (validateGeometryNodeGraph(parsed).status !== "SUPPORTED") throw new Error("GN performance fixture was blocked"); + } + const oversized = geometryGraph(GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph + 1); + const oom = gateGeometryNodeGraph(oversized); + const malicious = geometryGraph(2); + malicious.nodes[0].sockets = [ + { id: "in", name: "In", direction: "INPUT", dataType: "FLOAT" }, + { id: "out", name: "Out", direction: "OUTPUT", dataType: "FLOAT" }, + ]; + malicious.nodes[1].sockets = structuredClone(malicious.nodes[0].sockets); + malicious.links = [ + { fromNodeId: "node:0", fromSocketId: "out", toNodeId: "node:1", toSocketId: "in" }, + { fromNodeId: "node:1", fromSocketId: "out", toNodeId: "node:0", toSocketId: "in" }, + ]; + const maliciousGate = gateGeometryNodeGraph(malicious); + return { + domain: "GN", + performanceMs: Math.round(performance.now() - started), + workUnits, + oomCode: oom.issues[0]?.code ?? "", + maliciousCode: maliciousGate.issues.find((issue) => issue.code === "GN_DEPENDENCY_CYCLE")?.code ?? "", + recovered: gateGeometryNodeGraph(geometryGraph(1)).status === "READY", + performanceStatus: "SUPPORTED", + oomPreventedBeforeAllocation: oom.status === "BLOCKED", + }; +} + +function shaderMaterial(): MaterialIR { + return { + id: "material:M10Gate", + name: "M10 Gate", + baseColor: [0.2, 0.3, 0.4, 1], + roughness: 0.5, + metallic: 0.1, + emissionColor: [0, 0, 0, 1], + alpha: 1, + ior: 1.45, + shaderGraphHash: "a".repeat(64), + nodes: [ + { id: "value-a", type: "VALUE", name: "A", defaultValue: [0.2] }, + { id: "value-b", type: "VALUE", name: "B", defaultValue: [0.3] }, + { id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } }, + { id: "principled", type: "PRINCIPLED", name: "Principled" }, + { id: "output", type: "OUTPUT", name: "Output" }, + ], + links: [ + { fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" }, + { fromNodeId: "value-b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" }, + { fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" }, + { fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }, + ], + }; +} + +function runShader(): GateResult { + const material = shaderMaterial(); + const workUnits = 100; + const started = performance.now(); + for (let index = 0; index < workUnits; index += 1) { + if (compileMaterialGraph(material).status !== "COMPILED") throw new Error("Shader performance fixture was blocked"); + } + const oversized = shaderMaterial(); + oversized.nodes = Array.from({ length: SHADER_COMPILE_BUDGET.maxNodes + 1 }, (_value, index) => ({ + id: `value:${index}`, type: "VALUE", name: `Value ${index}`, defaultValue: [0], + })); + oversized.links = []; + const oom = compileMaterialGraph(oversized); + const cyclic = shaderMaterial(); + cyclic.links = [ + { fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" }, + { fromNodeId: "math", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" }, + { fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" }, + { fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }, + ]; + const malicious = compileMaterialGraph(cyclic); + return { + domain: "SHADER", + performanceMs: Math.round(performance.now() - started), + workUnits, + oomCode: oom.issues[0]?.code ?? "", + maliciousCode: malicious.issues.find((issue) => issue.code === "SHADER_GRAPH_CYCLE")?.code ?? "", + recovered: compileMaterialGraph(shaderMaterial()).status === "COMPILED", + performanceStatus: "COMPILED", + oomPreventedBeforeAllocation: oom.status === "BLOCKED", + }; +} + +function nlaTracks(trackCount: number, stripsPerTrack: number) { + return Array.from({ length: trackCount }, (_trackValue, trackIndex) => ({ + schemaVersion: 1, + id: `track:${trackIndex}`, + ownerId: "object:M10Gate", + name: `Track ${trackIndex}`, + muted: false, + solo: false, + selected: false, + strips: Array.from({ length: stripsPerTrack }, (_stripValue, stripIndex) => ({ + id: `strip:${trackIndex}:${stripIndex}`, + actionId: "action:M10Gate", + frameStart: stripIndex * 12, + frameEnd: stripIndex * 12 + 10, + actionFrameStart: 0, + actionFrameEnd: 10, + scale: 1, + repeat: 1, + blendIn: 0, + blendOut: 0, + influence: 1, + blendMode: "REPLACE", + extrapolation: "NOTHING", + muted: false, + selected: false, + stripType: "CLIP", + })), + })); +} + +function runNla(): GateResult { + const context = { actionIds: new Set(["action:M10Gate"]), ownerId: "object:M10Gate" }; + const tracks = nlaTracks(64, 4); + const workUnits = 20; + const started = performance.now(); + for (let index = 0; index < workUnits; index += 1) { + if (gateNlaTracks(tracks, context).status !== "READY") throw new Error("NLA performance fixture was blocked"); + } + const oversized = new Array(NLA_STACK_BUDGET.maxTracks + 1).fill(tracks[0]); + const oom = gateNlaTracks(oversized, context); + const malicious = nlaTracks(1, 1) as Array & { strips: Array> }>; + malicious[0].strips[0].proxySuccess = true; + const maliciousGate = gateNlaTracks(malicious, context); + return { + domain: "NLA", + performanceMs: Math.round(performance.now() - started), + workUnits, + oomCode: oom.issues[0]?.code ?? "", + maliciousCode: maliciousGate.issues[0]?.code ?? "", + recovered: gateNlaTracks(nlaTracks(1, 1), context).status === "READY", + performanceStatus: "SUPPORTED", + oomPreventedBeforeAllocation: oom.status === "BLOCKED", + }; +} + +async function digest(value: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest("SHA-256", value); + return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function simulationFixture(frameCount: number, frameBytes: number) { + const payload = new Uint8Array(frameCount * frameBytes); + for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251; + const binding = { + graphId: "node-group:M10Gate", + graphHash: await digest(Uint8Array.from([1]).buffer), + sourceBlendSha256: await digest(Uint8Array.from([2]).buffer), + sourceRevision: 15, + inputHash: await digest(Uint8Array.from([3]).buffer), + blenderVersion: "5.2.0", + frameStart: 1, + frameEnd: frameCount, + }; + const frames = []; + for (let frame = 1; frame <= frameCount; frame += 1) { + const byteOffset = (frame - 1) * frameBytes; + frames.push({ frame, byteOffset, byteLength: frameBytes, sha256: await digest(payload.buffer.slice(byteOffset, byteOffset + frameBytes)) }); + } + return { + payload: payload.buffer, + manifest: { + schemaVersion: 2, + ...binding, + revisionHash: await computeSimulationCacheRevisionHash(binding), + cacheSha256: await digest(payload.buffer), + byteLength: payload.byteLength, + frames, + }, + }; +} + +async function simulationError(operation: () => unknown | Promise): Promise { + try { await operation(); return ""; } + catch (error) { return String((error as Error & { code?: string }).code ?? ""); } +} + +async function runSimulation(): Promise { + const fixture = await simulationFixture(64, 16); + const workUnits = 64; + const started = performance.now(); + await verifySimulationCache(fixture.manifest, fixture.payload); + const performanceMs = Math.round(performance.now() - started); + const oomCode = await simulationError(() => parseSimulationCacheManifest({ + ...fixture.manifest, + byteLength: SIMULATION_CACHE_BUDGET.maxCacheBytes + 1, + })); + const maliciousCode = await simulationError(() => parseSimulationCacheManifest({ + ...fixture.manifest, + proxySuccess: true, + })); + const recoveredFrame = await verifySimulationCacheFrame( + fixture.manifest, + 1, + fixture.payload.slice(0, 16), + ); + return { + domain: "SIMULATION", + performanceMs, + workUnits, + oomCode, + maliciousCode, + recovered: recoveredFrame.frame === 1, + performanceStatus: "VERIFIED", + oomPreventedBeforeAllocation: oomCode === "SIMULATION_CACHE_BUDGET_EXCEEDED", + }; +} + +self.onmessage = async (event: MessageEvent<{ domain?: Domain }>) => { + try { + const result = event.data.domain === "GN" ? runGeometryNodes() + : event.data.domain === "SHADER" ? runShader() + : event.data.domain === "NLA" ? runNla() + : event.data.domain === "SIMULATION" ? await runSimulation() + : undefined; + if (!result) throw new Error("Unknown M10 gate domain"); + self.postMessage({ ok: true, result }); + } + catch (error) { + self.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) }); + } +}; diff --git a/web/app/src/workers/nanovdb-offscreen-page-feedback-test.worker.ts b/web/app/src/workers/nanovdb-offscreen-page-feedback-test.worker.ts new file mode 100644 index 00000000..dab7ddd7 --- /dev/null +++ b/web/app/src/workers/nanovdb-offscreen-page-feedback-test.worker.ts @@ -0,0 +1,83 @@ +import { validateNanoVDBBundleManifest } from "../../../protocol/volume-vdb"; +import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32"; +import { + createNanoVDBFloat32GridPaged, + createNanoVDBPageFeedbackGPUBuffer, + NanoVDBProgressivePageUploader, + NanoVDBProgressiveRedrawScheduler, + NanoVDBWebGPUDeviceSession, + readNanoVDBPageFeedbackGPUBuffer, + renderNanoVDBFloat32WebGPU, + sampleNanoVDBFloat32WebGPU, +} from "../render/nanovdb-volume-renderer"; + +const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; + +async function digest(pixels: Uint8Array): Promise { + const bytes = new Uint8Array(pixels.byteLength); + bytes.set(pixels); + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.buffer))).map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +scope.onmessage = (): void => { + void (async () => { + const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json()); + const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid); + if (!density || !manifest.gpu.float32TreeLayout) throw new Error("NANOVDB_MANIFEST_INVALID: offscreen fixture is incomplete"); + const response = await fetch("/__vdb_fixture__/bundle", { + cache: "no-store", + headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` }, + }); + if (response.status !== 206) throw new Error("NANOVDB_STREAM_INCOMPLETE: offscreen fixture range was not served"); + const payload = await response.arrayBuffer(); + const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout); + const coordinate = [0, 0, 0] as const; + const leafByteOffset = sampler.leafByteOffset(coordinate); + if (leafByteOffset === null) throw new Error("NANOVDB_GRID_UNSUPPORTED: offscreen fixture has no leaf"); + const pageByteLength = 256 * 1024; + const pageCount = Math.ceil(payload.byteLength / pageByteLength); + const leafPageId = Math.floor(leafByteOffset / pageByteLength); + if (leafPageId <= 0 || leafPageId >= pageCount) throw new Error("NANOVDB_GRID_UNSUPPORTED: offscreen leaf page is not pageable"); + const session = new NanoVDBWebGPUDeviceSession(); + const device = await session.open(pageCount * pageByteLength, 6); + const grid = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, pageCount * pageByteLength); + for (let pageId = 0; pageId < pageCount; pageId++) { + if (pageId === leafPageId) continue; + grid.uploadPage(pageId, payload.slice(pageId * pageByteLength, Math.min(payload.byteLength, (pageId + 1) * pageByteLength))); + } + const feedback = createNanoVDBPageFeedbackGPUBuffer(device, 4); + const beforeLoad = await sampleNanoVDBFloat32WebGPU(device, grid, [coordinate], feedback); + const missing = await readNanoVDBPageFeedbackGPUBuffer(device, feedback, 4, pageCount, 17); + const start = density.byteOffset + missing.pageIds[0] * pageByteLength; + const end = Math.min(density.byteOffset + density.byteLength, start + pageByteLength) - 1; + const pageResponse = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${start}-${end}` } }); + if (pageResponse.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: offscreen page range returned ${pageResponse.status}`); + const frames: Array<() => void> = []; + let redraws = 0; + const scheduler = new NanoVDBProgressiveRedrawScheduler((callback) => frames.push(callback), () => { redraws++; }); + const uploader = new NanoVDBProgressivePageUploader(grid, scheduler); + const upload = uploader.upload(missing.pageIds[0], await pageResponse.arrayBuffer()); + const queuedBeforeRedraw = frames.length; + frames.shift()?.(); + const afterLoad = await sampleNanoVDBFloat32WebGPU(device, grid, [coordinate]); + const pixelsA = await renderNanoVDBFloat32WebGPU(device, grid, density, { ...manifest.material, interpolation: "LINEAR" }, 64, 64); + const pixelsB = await renderNanoVDBFloat32WebGPU(device, grid, density, { ...manifest.material, interpolation: "LINEAR" }, 64, 64); + const result = { + pageCount, + leafPageId, + beforeLoad, + missing, + upload, + queuedBeforeRedraw, + redraws, + afterLoad, + pixels: { bytes: pixelsA.byteLength, sha256A: await digest(pixelsA), sha256B: await digest(pixelsB) }, + scheduler: scheduler.stats(), + }; + scheduler.dispose(); + grid.dispose(); + feedback.destroy(); + session.dispose(); + scope.postMessage(result); + })().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); +}; diff --git a/web/app/src/workers/nanovdb-opfs-restart-test.worker.ts b/web/app/src/workers/nanovdb-opfs-restart-test.worker.ts new file mode 100644 index 00000000..0195feab --- /dev/null +++ b/web/app/src/workers/nanovdb-opfs-restart-test.worker.ts @@ -0,0 +1,126 @@ +import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; +import { createNanoVDBFloat32GridPaged, NanoVDBWebGPUDeviceSession } from "../render/nanovdb-volume-renderer"; +import { commitNanoVDBToOPFS, openNanoVDBFromOPFS, pruneNanoVDBOPFS } from "../volume/nanovdb-opfs"; +import { loadNanoVDBGridPage } from "../volume/nanovdb-viewport"; + +interface RestartState { + projectId: string; + bundleSha256: string; + claimedResidentPages: number[]; +} + +const scope = self as unknown as { onmessage: ((event: MessageEvent<{ action: "prepare" | "reopen"; state?: RestartState }>) => void) | null; postMessage: (value: unknown) => void }; +const projectId = "m8-14-worker-restart"; + +function buffer(seed: number): ArrayBuffer { + const bytes = Uint8Array.from({ length: 128 * 1024 }, (_value, index) => (index * 29 + seed) & 0xff); + return bytes.buffer; +} + +async function digest(value: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest("SHA-256", value.slice(0)); + return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function manifestFor(data: ArrayBuffer): Promise { + const bytes = new Uint8Array(data); + const first = bytes.slice(0, 64 * 1024).buffer; + const second = bytes.slice(64 * 1024).buffer; + return validateNanoVDBBundleManifest({ + schemaVersion: 1, + projectId, + sourcePath: "//volumes/m8-14.vdb", + sourceSha256: "a".repeat(64), + conversionRequestSha256: "b".repeat(64), + bundlePath: "//volumes/m8-14.nvdb", + bundleByteLength: data.byteLength, + bundleSha256: await digest(data), + converter: { target: "SERVER", blenderVersion: "5.2.0", openVDBVersion: "13.0.0", nanoVDBVersion: "32.9.0", executableSha256: "c".repeat(64) }, + grids: [{ + name: "density", valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 0, + segmentByteOffset: 0, segmentByteLength: data.byteLength, byteOffset: 0, byteLength: data.byteLength, + indexBounds: { min: [0, 0, 0], max: [1, 1, 1] }, worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, voxelSize: [1, 1, 1], + indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1], + }], + chunks: [ + { index: 0, byteOffset: 0, byteLength: first.byteLength, sha256: await digest(first) }, + { index: 1, byteOffset: first.byteLength, byteLength: second.byteLength, sha256: await digest(second) }, + ], + material: { densityGrid: "density", densityScale: 1, emissionScale: 0, temperatureScale: 1, anisotropy: 0, interpolation: "LINEAR" }, + gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: 64 * 1024, maxResidentBytes: 128 * 1024, shaderSemanticVersion: "volume-wgsl-v1" }, + }); +} + +async function prepare(): Promise { + await pruneNanoVDBOPFS(projectId, 0); + const data = buffer(19); + const manifest = await manifestFor(data); + await commitNanoVDBToOPFS(manifest, async (range) => data.slice(range.start, range.endExclusive), new AbortController().signal); + const session = new NanoVDBWebGPUDeviceSession(); + const device = await session.open(manifest.gpu.maxResidentBytes, 5); + const grid = createNanoVDBFloat32GridPaged(device, manifest.grids[0].byteLength, manifest.gpu.pageByteLength, manifest.gpu.maxResidentBytes); + grid.uploadPage(0, data.slice(0, 64 * 1024)); + const residentBeforeRestart = [...grid.residentVirtualPages]; + grid.dispose(); + session.dispose(); + return { projectId, bundleSha256: manifest.bundleSha256, residentBeforeRestart, claimedResidentPages: residentBeforeRestart }; +} + +async function overwriteFirstChunk(state: RestartState, data: ArrayBuffer): Promise { + let directory = await navigator.storage.getDirectory(); + for (const name of ["projects", state.projectId, "cache", "vdb", state.bundleSha256]) { + directory = await directory.getDirectoryHandle(name); + } + const writer = await (await directory.getFileHandle("00000.chunk")).createWritable(); + await writer.write(data); + await writer.close(); +} + +async function reopen(state: RestartState): Promise { + const opened = await openNanoVDBFromOPFS(state.projectId, state.bundleSha256); + const session = new NanoVDBWebGPUDeviceSession(); + const device = await session.open(opened.manifest.gpu.maxResidentBytes, 5); + const grid = createNanoVDBFloat32GridPaged(device, opened.manifest.grids[0].byteLength, opened.manifest.gpu.pageByteLength, opened.manifest.gpu.maxResidentBytes); + const residentBeforeRestore = [...grid.residentVirtualPages]; + const verificationGrid = createNanoVDBFloat32GridPaged(device, opened.manifest.grids[0].byteLength, opened.manifest.gpu.pageByteLength, opened.manifest.gpu.maxResidentBytes); + try { + const page = await loadNanoVDBGridPage(opened.manifest, "density", 0, opened.source, new AbortController().signal); + grid.uploadPage(0, page); + const residentAfterRestore = [...grid.residentVirtualPages]; + + const originalChunk = page.slice(0); + const tamperedChunk = new Uint8Array(originalChunk.slice(0)); + tamperedChunk[0] ^= 0xff; + await overwriteFirstChunk(state, tamperedChunk.buffer); + let tamperedError = ""; + try { + const unverified = await loadNanoVDBGridPage(opened.manifest, "density", 0, opened.source, new AbortController().signal); + verificationGrid.uploadPage(0, unverified); + } + catch (error) { + tamperedError = error instanceof Error ? error.message : String(error); + } + await overwriteFirstChunk(state, originalChunk); + return { + manifestSha256: opened.manifest.bundleSha256, + claimedResidentPages: state.claimedResidentPages, + residentBeforeRestore, + residentAfterRestore, + pageBytes: page.byteLength, + tamperedError, + residentAfterTamperedRestore: [...verificationGrid.residentVirtualPages], + }; + } + finally { + grid.dispose(); + verificationGrid.dispose(); + session.dispose(); + await pruneNanoVDBOPFS(state.projectId, 0); + } +} + +scope.onmessage = (event): void => { + void (event.data.action === "prepare" ? prepare() : reopen(event.data.state!)) + .then((result) => scope.postMessage(result)) + .catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); +}; diff --git a/web/app/src/workers/nanovdb-page-feedback-test.worker.ts b/web/app/src/workers/nanovdb-page-feedback-test.worker.ts new file mode 100644 index 00000000..0f85a85f --- /dev/null +++ b/web/app/src/workers/nanovdb-page-feedback-test.worker.ts @@ -0,0 +1,450 @@ +import { + createNanoVDBPageFeedbackBuffer, + dispatchNanoVDBPageFeedbackBatch, +} from "../../../protocol/nanovdb-page-feedback"; +import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; +import { + createNanoVDBFloat32GridPaged, + NanoVDBProgressivePageUploader, + NanoVDBProgressiveRedrawScheduler, + NanoVDBWebGPUDeviceSession, + readNanoVDBPageFeedbackGPUBuffer, + readNanoVDBWordsWebGPU, + sampleNanoVDBFloat32WebGPU, +} from "../render/nanovdb-volume-renderer"; +import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32"; +import { createResumableHttpNanoVDBRangeSource, type NanoVDBRangeSource } from "../volume/nanovdb-stream"; +import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport"; + +const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; +const FEEDBACK_STRESS_PAGE_BYTES = 256 * 1024; +const GUARD_WORDS = [0x13579bdf, 0x2468ace0] as const; + +function createGuardedFeedback(device: GPUDevice, capacity: number): { buffer: GPUBuffer; logicalBytes: number; totalBytes: number } { + const initial = new Uint32Array(createNanoVDBPageFeedbackBuffer(capacity)); + const guarded = new Uint32Array(initial.length + GUARD_WORDS.length); + guarded.set(initial); + guarded.set(GUARD_WORDS, initial.length); + const buffer = device.createBuffer({ + label: `NanoVDB guarded page feedback ${capacity}`, + size: guarded.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + device.queue.writeBuffer(buffer, 0, guarded); + return { buffer, logicalBytes: initial.byteLength, totalBytes: guarded.byteLength }; +} + +async function readWords(device: GPUDevice, buffer: GPUBuffer, byteLength: number): Promise { + const readback = device.createBuffer({ size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const encoder = device.createCommandEncoder(); + encoder.copyBufferToBuffer(buffer, 0, readback, 0, byteLength); + device.queue.submit([encoder.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const words = new Uint32Array(readback.getMappedRange().slice(0)); + readback.unmap(); + readback.destroy(); + return words; +} + +async function waitFor(condition: () => boolean, label: string): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error(`NanoVDB page request test timed out waiting for ${label}`); +} + +function outcome(promise: Promise): Promise<{ status: "RESOLVED"; value: T } | { status: "REJECTED"; name: string }> { + return promise.then( + (value) => ({ status: "RESOLVED" as const, value }), + (error: unknown) => ({ status: "REJECTED" as const, name: error instanceof DOMException ? error.name : "Error" }), + ); +} + +scope.onmessage = (): void => { + void (async () => { + const manifest = validateNanoVDBBundleManifest( + await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR, + ); + const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid); + if (!density || !manifest.gpu.float32TreeLayout) throw new Error("NanoVDB page feedback fixture is incomplete"); + const response = await fetch("/__vdb_fixture__/bundle", { + cache: "no-store", + headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` }, + }); + if (response.status !== 206) throw new Error("NanoVDB page feedback fixture range was not served"); + const payload = await response.arrayBuffer(); + const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout); + const activeCoordinate = [0, 0, 0] as const; + const leafByteOffset = sampler.leafByteOffset(activeCoordinate); + if (leafByteOffset === null) throw new Error("NanoVDB page feedback fixture does not reach a leaf"); + const manifestPageBytes = manifest.gpu.pageByteLength; + const manifestPageCount = Math.ceil(payload.byteLength / manifestPageBytes); + const stressPageCount = Math.ceil(payload.byteLength / FEEDBACK_STRESS_PAGE_BYTES); + const leafPageId = Math.floor(leafByteOffset / FEEDBACK_STRESS_PAGE_BYTES); + if (stressPageCount < 4 || leafPageId < 1 || leafPageId >= stressPageCount || manifestPageCount < 1) throw new Error("NanoVDB page feedback fixture page layout is insufficient"); + + const session = new NanoVDBWebGPUDeviceSession(); + const device = await session.open(Math.max(manifestPageBytes, payload.byteLength + FEEDBACK_STRESS_PAGE_BYTES), 6); + + const leafGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, FEEDBACK_STRESS_PAGE_BYTES, (stressPageCount - 1) * FEEDBACK_STRESS_PAGE_BYTES); + for (let pageId = 0; pageId < stressPageCount; pageId++) { + if (pageId === leafPageId) continue; + leafGrid.uploadPage(pageId, payload.slice(pageId * FEEDBACK_STRESS_PAGE_BYTES, Math.min(payload.byteLength, (pageId + 1) * FEEDBACK_STRESS_PAGE_BYTES))); + } + const leafFeedback = createGuardedFeedback(device, 4); + const leafSamples = await sampleNanoVDBFloat32WebGPU( + device, + leafGrid, + Array.from({ length: 256 }, () => activeCoordinate), + leafFeedback.buffer, + ); + const leafResult = await readNanoVDBPageFeedbackGPUBuffer(device, leafFeedback.buffer, 4, stressPageCount, 17); + const leafWords = await readWords(device, leafFeedback.buffer, leafFeedback.totalBytes); + const leafGuard = [...leafWords.slice(leafFeedback.logicalBytes / 4)]; + const pageIoRequests: Array<{ pageId: number; renderRevision: number }> = []; + const staleDispatch = await dispatchNanoVDBPageFeedbackBatch(leafResult, 18, async (pageId, renderRevision) => { + pageIoRequests.push({ pageId, renderRevision }); + }); + const stalePageIoCount = pageIoRequests.length; + const currentDispatch = await dispatchNanoVDBPageFeedbackBatch(leafResult, 17, async (pageId, renderRevision) => { + pageIoRequests.push({ pageId, renderRevision }); + }); + + const manifestGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, manifestPageBytes, manifestPageBytes); + const manifestFeedback = createGuardedFeedback(device, 4); + const manifestWords = await readNanoVDBWordsWebGPU(device, manifestGrid, [leafByteOffset], manifestFeedback.buffer); + const manifestFeedbackResult = await readNanoVDBPageFeedbackGPUBuffer(device, manifestFeedback.buffer, 4, manifestPageCount, 19); + const manifestRanges: Parameters[0][] = []; + const httpPageSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { + retries: 0, + requireStableEtag: true, + }); + const pageSource: NanoVDBRangeSource = async (range, signal) => { + manifestRanges.push({ ...range }); + return httpPageSource(range, signal); + }; + const pageRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, pageSource); + const manifestPageIoRequests: Array<{ pageId: number; renderRevision: number }> = []; + const manifestStaleDispatch = await loadNanoVDBFeedbackPages( + manifestFeedbackResult, + 20, + pageRequests, + new AbortController().signal, + async ({ pageId, renderRevision }) => { manifestPageIoRequests.push({ pageId, renderRevision }); }, + ); + const staleManifestRangeCount = manifestRanges.length; + let manifestPageMatchesPayload = false; + const manifestDispatch = await loadNanoVDBFeedbackPages( + manifestFeedbackResult, + 19, + pageRequests, + new AbortController().signal, + async ({ pageId, renderRevision, data }) => { + manifestPageIoRequests.push({ pageId, renderRevision }); + const expected = new Uint8Array(payload, pageId * manifestPageBytes, data.byteLength); + const loaded = new Uint8Array(data); + manifestPageMatchesPayload = loaded.length === expected.length && loaded.every((byte, index) => byte === expected[index]); + }, + ); + pageRequests.dispose(); + const declaredManifestRanges = manifestRanges.map((range) => { + const chunk = manifest.chunks[range.chunkIndex]; + return { + chunkIndex: chunk.index, + start: chunk.byteOffset, + endExclusive: chunk.byteOffset + chunk.byteLength, + sha256: chunk.sha256, + }; + }); + + let coalescedRangeCalls = 0; + let coalescedUnderlyingAborts = 0; + let releaseCoalescedRange: (() => void) | undefined; + const coalescedSource: NanoVDBRangeSource = async (range, signal) => { + coalescedRangeCalls++; + await new Promise((resolve, reject) => { + const onAbort = (): void => { + coalescedUnderlyingAborts++; + reject(new DOMException("coalesced range cancelled", "AbortError")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + releaseCoalescedRange = () => { + signal.removeEventListener("abort", onAbort); + resolve(); + }; + }); + return httpPageSource(range, signal); + }; + const coalescedRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, coalescedSource); + const firstSubscriber = new AbortController(); + const secondSubscriber = new AbortController(); + const coalescedConsumers: string[] = []; + const firstCoalesced = outcome(loadNanoVDBFeedbackPages( + manifestFeedbackResult, + 19, + coalescedRequests, + firstSubscriber.signal, + async ({ pageId }) => { coalescedConsumers.push(`first:${pageId}`); }, + )); + const secondCoalesced = outcome(loadNanoVDBFeedbackPages( + manifestFeedbackResult, + 19, + coalescedRequests, + secondSubscriber.signal, + async ({ pageId }) => { coalescedConsumers.push(`second:${pageId}`); }, + )); + await waitFor(() => coalescedRangeCalls === 1 && coalescedRequests.stats().subscribers === 2, "two coalesced subscribers"); + const coalescedBeforeCancel = coalescedRequests.stats(); + firstSubscriber.abort(); + const coalescedAfterFirstCancel = coalescedRequests.stats(); + releaseCoalescedRange?.(); + const coalescedOutcomes = await Promise.all([firstCoalesced, secondCoalesced]); + const coalescedAfterResolve = coalescedRequests.stats(); + coalescedRequests.dispose(); + + let lastCancelRangeCalls = 0; + let lastCancelUnderlyingAborts = 0; + const lastCancelSource: NanoVDBRangeSource = async (_range, signal) => { + lastCancelRangeCalls++; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + lastCancelUnderlyingAborts++; + reject(new DOMException("last subscriber cancelled range", "AbortError")); + }, { once: true }); + }); + }; + const lastCancelRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, lastCancelSource); + const thirdSubscriber = new AbortController(); + const fourthSubscriber = new AbortController(); + const thirdCancelled = outcome(lastCancelRequests.request(0, thirdSubscriber.signal)); + const fourthCancelled = outcome(lastCancelRequests.request(0, fourthSubscriber.signal)); + await waitFor(() => lastCancelRangeCalls === 1 && lastCancelRequests.stats().subscribers === 2, "last-cancel subscribers"); + thirdSubscriber.abort(); + const lastCancelAfterFirst = { + stats: lastCancelRequests.stats(), + underlyingAborts: lastCancelUnderlyingAborts, + }; + fourthSubscriber.abort(); + const lastCancelOutcomes = await Promise.all([thirdCancelled, fourthCancelled]); + await waitFor(() => lastCancelUnderlyingAborts === 1, "underlying range abort"); + const lastCancelAfterLast = lastCancelRequests.stats(); + lastCancelRequests.dispose(); + + let tamperedRangeCalls = 0; + let tamperedResidentWrites = 0; + const tamperedSource: NanoVDBRangeSource = async (range, signal) => { + tamperedRangeCalls++; + const data = new Uint8Array(await httpPageSource(range, signal)); + data[0] ^= 0xff; + return data.buffer; + }; + const tamperedRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, tamperedSource); + const tamperedResidentGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, manifestPageBytes, manifestPageBytes); + let tamperedErrorCode = ""; + let tamperedErrorMessage = ""; + try { + await loadNanoVDBFeedbackPages( + manifestFeedbackResult, + 19, + tamperedRequests, + new AbortController().signal, + async ({ pageId, data }) => { + tamperedResidentWrites++; + tamperedResidentGrid.uploadPage(pageId, data); + }, + ); + } + catch (error) { + tamperedErrorCode = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + tamperedErrorMessage = error instanceof Error ? error.message : String(error); + } + const tamperedCache = { + residentPageCount: tamperedResidentGrid.residentPageCount, + residentBytes: tamperedResidentGrid.residentBytes, + residentVirtualPages: [...tamperedResidentGrid.residentVirtualPages], + coordinator: tamperedRequests.stats(), + }; + tamperedResidentGrid.dispose(); + tamperedRequests.dispose(); + + const pinPageBytes = 64 * 1024; + const pinGrid = createNanoVDBFloat32GridPaged(device, 4 * pinPageBytes, pinPageBytes, 2 * pinPageBytes); + const pinPage = new ArrayBuffer(pinPageBytes); + pinGrid.uploadPage(0, pinPage.slice(0)); + pinGrid.uploadPage(1, pinPage.slice(0)); + pinGrid.beginFrame(); + const pinnedPage0 = pinGrid.pinPage(0); + const pinnedMissingPage = pinGrid.pinPage(3); + pinGrid.uploadPage(2, pinPage.slice(0)); + const afterPinnedEviction = { + residentVirtualPages: [...pinGrid.residentVirtualPages], + evictionCount: pinGrid.evictionCount, + page0: pinGrid.hasResidentPage(0), + page1: pinGrid.hasResidentPage(1), + page2: pinGrid.hasResidentPage(2), + }; + pinGrid.beginFrame(); + pinGrid.pinPage(0); + pinGrid.pinPage(2); + let allPinnedError = ""; + try { pinGrid.uploadPage(3, pinPage.slice(0)); } + catch (error) { allPinnedError = error instanceof Error ? error.message : String(error); } + const afterAllPinned = { + residentVirtualPages: [...pinGrid.residentVirtualPages], + evictionCount: pinGrid.evictionCount, + page3: pinGrid.hasResidentPage(3), + }; + pinGrid.endFrame(); + pinGrid.beginFrame(); + pinGrid.pinPage(2); + pinGrid.uploadPage(3, pinPage.slice(0)); + pinGrid.endFrame(); + const afterNextFrame = { + residentVirtualPages: [...pinGrid.residentVirtualPages], + evictionCount: pinGrid.evictionCount, + page0: pinGrid.hasResidentPage(0), + page2: pinGrid.hasResidentPage(2), + page3: pinGrid.hasResidentPage(3), + }; + const pinPageTable = [...await readWords(device, pinGrid.pageTable, 4 * Uint32Array.BYTES_PER_ELEMENT)]; + pinGrid.dispose(); + + const redrawGrid = createNanoVDBFloat32GridPaged(device, 3 * pinPageBytes, pinPageBytes, 3 * pinPageBytes); + const redrawFrames: Array<() => void> = []; + let redrawCallbacks = 0; + const redrawScheduler = new NanoVDBProgressiveRedrawScheduler( + (callback) => { redrawFrames.push(callback); }, + () => { redrawCallbacks++; }, + ); + const redrawUploader = new NanoVDBProgressivePageUploader(redrawGrid, redrawScheduler); + const firstRedrawUpload = redrawUploader.upload(0, pinPage.slice(0)); + const secondRedrawUpload = redrawUploader.upload(1, pinPage.slice(0)); + let failedRedrawUpload = ""; + try { redrawUploader.upload(3, pinPage.slice(0)); } + catch (error) { failedRedrawUpload = error instanceof Error ? error.message : String(error); } + const beforeFirstRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() }; + redrawFrames.shift()?.(); + const afterFirstRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() }; + const thirdRedrawUpload = redrawUploader.upload(2, pinPage.slice(0)); + const beforeSecondRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() }; + redrawFrames.shift()?.(); + const afterSecondRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() }; + redrawScheduler.dispose(); + redrawGrid.dispose(); + + // A faulty producer may keep reporting an uploadable page forever. The render epoch + // must stop scheduling after its bounded redraw budget instead of spinning indefinitely. + const cappedGrid = createNanoVDBFloat32GridPaged(device, 3 * pinPageBytes, pinPageBytes, 3 * pinPageBytes); + const cappedFrames: Array<() => void> = []; + let cappedCallbacks = 0; + const cappedScheduler = new NanoVDBProgressiveRedrawScheduler( + (callback) => { cappedFrames.push(callback); }, + () => { cappedCallbacks++; }, + { maxRedraws: 2 }, + ); + const cappedUploader = new NanoVDBProgressivePageUploader(cappedGrid, cappedScheduler); + const cappedUploads: Array<{ pageId: number; redrawScheduled: boolean }> = []; + for (let attempt = 0; attempt < 4; attempt++) { + cappedUploads.push(cappedUploader.upload(0, pinPage.slice(0))); + cappedFrames.shift()?.(); + } + const cappedRedraw = { uploads: cappedUploads, callbacks: cappedCallbacks, queuedFrames: cappedFrames.length, stats: cappedScheduler.stats() }; + cappedScheduler.beginRender(); + const afterCappedReset = cappedScheduler.stats(); + cappedScheduler.dispose(); + cappedGrid.dispose(); + + const missingGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, FEEDBACK_STRESS_PAGE_BYTES, FEEDBACK_STRESS_PAGE_BYTES); + const missingPageIds = [1, 2, 3]; + const overflowFeedback = createGuardedFeedback(device, 2); + const missingOffsets = Array.from({ length: 384 }, (_value, index) => missingPageIds[index % missingPageIds.length] * FEEDBACK_STRESS_PAGE_BYTES); + const missingWords = await readNanoVDBWordsWebGPU(device, missingGrid, missingOffsets, overflowFeedback.buffer); + const overflowResult = await readNanoVDBPageFeedbackGPUBuffer(device, overflowFeedback.buffer, 2, stressPageCount, 18); + const overflowWords = await readWords(device, overflowFeedback.buffer, overflowFeedback.totalBytes); + const overflowGuard = [...overflowWords.slice(overflowFeedback.logicalBytes / 4)]; + + leafGrid.dispose(); + manifestGrid.dispose(); + missingGrid.dispose(); + leafFeedback.buffer.destroy(); + manifestFeedback.buffer.destroy(); + overflowFeedback.buffer.destroy(); + session.dispose(); + + return { + pageCount: stressPageCount, + manifestPageCount, + manifestPageBytes, + leafByteOffset, + leafPageId, + leafResult, + leafGuard, + staleDispatch, + stalePageIoCount, + currentDispatch, + pageIoRequests, + manifestFeedbackResult, + manifestWordsZero: manifestWords.every((word) => word === 0), + manifestStaleDispatch, + staleManifestRangeCount, + manifestDispatch, + manifestPageIoRequests, + manifestRanges, + declaredManifestRanges, + manifestPageMatchesPayload, + coalesced: { + rangeCalls: coalescedRangeCalls, + underlyingAborts: coalescedUnderlyingAborts, + beforeCancel: coalescedBeforeCancel, + afterFirstCancel: coalescedAfterFirstCancel, + afterResolve: coalescedAfterResolve, + outcomes: coalescedOutcomes, + consumers: coalescedConsumers, + }, + lastCancel: { + rangeCalls: lastCancelRangeCalls, + underlyingAborts: lastCancelUnderlyingAborts, + afterFirst: lastCancelAfterFirst, + afterLast: lastCancelAfterLast, + outcomes: lastCancelOutcomes, + }, + tamperedPage: { + rangeCalls: tamperedRangeCalls, + residentWrites: tamperedResidentWrites, + errorCode: tamperedErrorCode, + errorMessage: tamperedErrorMessage, + cache: tamperedCache, + }, + framePin: { + pinnedPage0, + pinnedMissingPage, + afterPinnedEviction, + allPinnedError, + afterAllPinned, + afterNextFrame, + pageTable: pinPageTable, + }, + progressiveRedraw: { + firstUpload: firstRedrawUpload, + secondUpload: secondRedrawUpload, + failedUpload: failedRedrawUpload, + beforeFirst: beforeFirstRedraw, + afterFirst: afterFirstRedraw, + thirdUpload: thirdRedrawUpload, + beforeSecond: beforeSecondRedraw, + afterSecond: afterSecondRedraw, + }, + progressiveRedrawCap: { cappedRedraw, afterCappedReset }, + leafSamplesFallback: leafSamples.every((sample) => sample.valid && !sample.active && sample.value === 0), + leafSampleCount: leafSamples.length, + overflowResult, + overflowGuard, + missingPageIds, + missingWordsZero: missingWords.every((word) => word === 0), + guardWords: [...GUARD_WORDS], + }; + })().then((result) => scope.postMessage(result)).catch((error) => { + scope.postMessage({ error: error instanceof Error ? error.message : String(error) }); + }); +}; diff --git a/web/app/src/workers/nanovdb-page-resume-test.worker.ts b/web/app/src/workers/nanovdb-page-resume-test.worker.ts new file mode 100644 index 00000000..df20e8cf --- /dev/null +++ b/web/app/src/workers/nanovdb-page-resume-test.worker.ts @@ -0,0 +1,79 @@ +import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; +import type { NanoVDBPageFeedbackBatch } from "../../../protocol/nanovdb-page-feedback"; +import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport"; +import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream"; + +const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; + +async function sha256(bytes: ArrayBuffer): Promise { + const copy = bytes.slice(0); + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", copy))).map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +scope.onmessage = (): void => { + void (async () => { + const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR); + const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid); + if (!density) throw new Error("NANOVDB_MANIFEST_INVALID: resume fixture has no density grid"); + const pageByteLength = 256 * 1024; + const pagingManifest = validateNanoVDBBundleManifest({ ...manifest, gpu: { ...manifest.gpu, pageByteLength } }); + const pageId = 0; + const pageStart = density.byteOffset + pageId * pageByteLength; + const pageEnd = Math.min(density.byteOffset + density.byteLength, pageStart + pageByteLength); + let interruptBody = true; + const ranges: string[] = []; + const ifRanges: string[] = []; + const interruptedFetcher: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + ranges.push(headers.get("Range") ?? ""); + ifRanges.push(headers.get("If-Range") ?? ""); + const response = await fetch(input, init); + if (!interruptBody) return response; + interruptBody = false; + const bytes = new Uint8Array(await response.arrayBuffer()); + const partial = bytes.slice(0, 4096); + let emitted = false; + const body = new ReadableStream({ + pull(controller) { + if (!emitted) { emitted = true; controller.enqueue(partial); return; } + controller.error(new TypeError("injected page response-body interruption")); + }, + }); + return new Response(body, { status: response.status, headers: response.headers }); + }; + const source = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { + fetcher: interruptedFetcher, + retries: 2, + retryDelayMs: 0, + requireStableEtag: true, + }); + const coordinator = new NanoVDBGridPageRequestCoordinator(pagingManifest, density.name, source); + const batch: NanoVDBPageFeedbackBatch = { + schemaVersion: 1, + renderRevision: 17, + attemptedCount: 1, + gpuStoredCount: 1, + uniqueCount: 1, + pageIds: [pageId], + status: "READY", + errorCode: null, + }; + let consumed: ArrayBuffer | undefined; + const dispatch = await loadNanoVDBFeedbackPages(batch, 17, coordinator, new AbortController().signal, ({ data }) => { consumed = data; }); + const direct = await (await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${pageStart}-${pageEnd - 1}` } })).arrayBuffer(); + const result = { + dispatch, + ranges, + ifRanges, + expectedRange: `bytes=${pageStart}-${pageEnd - 1}`, + resumedRangeStart: Number(ranges[1]?.match(/^bytes=(\d+)-/)?.[1] ?? -1), + consumedBytes: consumed?.byteLength ?? 0, + expectedBytes: direct.byteLength, + consumedSha256: consumed ? await sha256(consumed) : "", + expectedSha256: await sha256(direct), + coordinator: coordinator.stats(), + }; + coordinator.dispose(); + scope.postMessage(result); + })().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); +}; diff --git a/web/app/src/workers/nanovdb-render-golden-test.worker.ts b/web/app/src/workers/nanovdb-render-golden-test.worker.ts new file mode 100644 index 00000000..2a4a54b1 --- /dev/null +++ b/web/app/src/workers/nanovdb-render-golden-test.worker.ts @@ -0,0 +1,55 @@ +import { + NanoVDBWebGPUDeviceSession, + renderNanoVDBFloat32WebGPU, + uploadNanoVDBFloat32Grid, + type NanoVDBViewAxis, +} from "../render/nanovdb-volume-renderer"; +import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; + +const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown, transfer?: Transferable[]) => void }; + +async function sha256(value: Uint8Array): Promise { + const owned = new ArrayBuffer(value.byteLength); + new Uint8Array(owned).set(value); + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", owned))).map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +scope.onmessage = (): void => { + void (async () => { + const asset = await loadNanoVDBViewportAsset( + "m8-19-offscreen", + "/__vdb_fixture__/manifest", + "/__vdb_fixture__/bundle", + new AbortController().signal, + ); + const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid); + const payload = asset.grids.find((grid) => grid.name === density?.name)?.data; + if (!density || !payload) throw new Error("NANOVDB_STREAM_INCOMPLETE: M8-19 density payload is missing"); + const session = new NanoVDBWebGPUDeviceSession(); + const device = await session.open(payload.byteLength, 4); + const uploaded = uploadNanoVDBFloat32Grid(device, payload); + const material = { + ...asset.manifest.material, + temperatureGrid: undefined, + colorGrid: undefined, + emissionGrid: undefined, + interpolation: "LINEAR" as const, + }; + const images: Record = { X: new ArrayBuffer(0), Y: new ArrayBuffer(0), Z: new ArrayBuffer(0) }; + const hashes: Record = { X: "", Y: "", Z: "" }; + for (const axis of ["X", "Y", "Z"] as const) { + const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, material, 64, 64, {}, axis); + hashes[axis] = await sha256(pixels); + images[axis] = new ArrayBuffer(pixels.byteLength); + new Uint8Array(images[axis]).set(pixels); + } + uploaded.dispose(); + session.dispose(); + scope.postMessage({ + sourceSha256: asset.manifest.sourceSha256, + shaderSemanticVersion: asset.manifest.gpu.shaderSemanticVersion, + hashes, + images, + }, Object.values(images)); + })().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); +}; diff --git a/web/app/src/workers/nanovdb-sparse-performance-test.worker.ts b/web/app/src/workers/nanovdb-sparse-performance-test.worker.ts new file mode 100644 index 00000000..d6ab23b5 --- /dev/null +++ b/web/app/src/workers/nanovdb-sparse-performance-test.worker.ts @@ -0,0 +1,170 @@ +import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; +import type { NanoVDBPageFeedbackBatch } from "../../../protocol/nanovdb-page-feedback"; +import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport"; +import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream"; + +const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; + +interface RangeStats { + rangeCalls: number; + transferredBytes: number; + peakRangeBytes: number; + activeRangeBytes: number; + abortedRequests: number; + requestStarted: Promise; + resolveRequestStarted: () => void; +} + +function createRangeStats(): RangeStats { + let resolveRequestStarted = (): void => undefined; + const requestStarted = new Promise((resolve) => { resolveRequestStarted = resolve; }); + return { rangeCalls: 0, transferredBytes: 0, peakRangeBytes: 0, activeRangeBytes: 0, abortedRequests: 0, requestStarted, resolveRequestStarted }; +} + +function trackingFetcher(stats: RangeStats): typeof fetch { + return async (input, init) => { + stats.rangeCalls += 1; + stats.resolveRequestStarted(); + const response = await fetch(input, init); + const expectedBytes = Number(response.headers.get("Content-Length") ?? 0); + stats.activeRangeBytes += expectedBytes; + stats.peakRangeBytes = Math.max(stats.peakRangeBytes, stats.activeRangeBytes); + let released = false; + const release = (): void => { + if (released) return; + released = true; + stats.activeRangeBytes = Math.max(0, stats.activeRangeBytes - expectedBytes); + }; + const signal = init?.signal; + const onAbort = (): void => { stats.abortedRequests += 1; }; + signal?.addEventListener("abort", onAbort, { once: true }); + if (!response.body) { + release(); + signal?.removeEventListener("abort", onAbort); + return response; + } + const reader = response.body.getReader(); + const body = new ReadableStream({ + async pull(controller) { + try { + const next = await reader.read(); + if (next.done) { + release(); + signal?.removeEventListener("abort", onAbort); + controller.close(); + return; + } + stats.transferredBytes += next.value.byteLength; + controller.enqueue(next.value); + } + catch (error) { + release(); + signal?.removeEventListener("abort", onAbort); + controller.error(error); + } + }, + async cancel(reason) { + release(); + signal?.removeEventListener("abort", onAbort); + await reader.cancel(reason); + }, + }); + return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers }); + }; +} + +function feedbackBatch(pageId: number): NanoVDBPageFeedbackBatch { + return { + schemaVersion: 1, + renderRevision: 17, + attemptedCount: 1, + gpuStoredCount: 1, + uniqueCount: 1, + pageIds: [pageId], + status: "READY", + errorCode: null, + }; +} + +scope.onmessage = (): void => { + void (async () => { + const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_sparse_64m__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR); + const gridName = manifest.material.densityGrid; + const successStats = createRangeStats(); + const successSource = createResumableHttpNanoVDBRangeSource("/__vdb_sparse_64m__/bundle", manifest.bundleByteLength, { + fetcher: trackingFetcher(successStats), + retries: 0, + requireStableEtag: true, + }); + const successCoordinator = new NanoVDBGridPageRequestCoordinator(manifest, gridName, successSource); + const requestedPageIds = [0, 16, 128, 192]; + const firstPageStarted = performance.now(); + let firstPageMs = 0; + let loadedPageCount = 0; + let pageBytes = 0; + for (const [index, pageId] of requestedPageIds.entries()) { + await loadNanoVDBFeedbackPages(feedbackBatch(pageId), 17, successCoordinator, new AbortController().signal, ({ data }) => { + if (data.byteLength === 0) throw new Error("NANOVDB_STREAM_INCOMPLETE: sparse page is empty"); + pageBytes = data.byteLength; + loadedPageCount += 1; + }); + if (index === 0) firstPageMs = Math.round(performance.now() - firstPageStarted); + } + const successElapsedMs = Math.round(performance.now() - firstPageStarted); + const successCoordinatorStats = successCoordinator.stats(); + successCoordinator.dispose(); + + const cancelStats = createRangeStats(); + const cancelSource = createResumableHttpNanoVDBRangeSource("/__vdb_sparse_64m__/bundle?delayMs=2", manifest.bundleByteLength, { + fetcher: trackingFetcher(cancelStats), + retries: 0, + requireStableEtag: true, + }); + const cancelCoordinator = new NanoVDBGridPageRequestCoordinator(manifest, gridName, cancelSource); + const cancelController = new AbortController(); + let consumerCalls = 0; + let cancelStatus = "COMPLETED"; + let cancelErrorName = ""; + const cancelStartedAt = performance.now(); + const cancelPromise = loadNanoVDBFeedbackPages(feedbackBatch(64), 17, cancelCoordinator, cancelController.signal, () => { consumerCalls += 1; }); + await cancelStats.requestStarted; + const abortAt = performance.now(); + setTimeout(() => cancelController.abort(), 12); + try { + await cancelPromise; + } + catch (error) { + cancelStatus = "CANCELLED"; + cancelErrorName = error instanceof DOMException ? error.name : error instanceof Error ? error.name : String(error); + } + const cancelLatencyMs = Math.round(performance.now() - abortAt); + const cancelElapsedMs = Math.round(performance.now() - cancelStartedAt); + const cancelCoordinatorStats = cancelCoordinator.stats(); + cancelCoordinator.dispose(); + scope.postMessage({ + bundleBytes: manifest.bundleByteLength, + chunkBytes: manifest.chunks[0].byteLength, + pageBytes, + requestedPageIds, + loadedPageCount, + transferredBytes: successStats.transferredBytes, + rangeCalls: successStats.rangeCalls, + peakRangeBytes: successStats.peakRangeBytes, + firstPageMs, + successElapsedMs, + successCoordinatorStats, + cancel: { + status: cancelStatus, + errorName: cancelErrorName, + consumerCalls, + transferredBytes: cancelStats.transferredBytes, + rangeCalls: cancelStats.rangeCalls, + abortedRequests: cancelStats.abortedRequests, + peakRangeBytes: cancelStats.peakRangeBytes, + cancelLatencyMs, + cancelElapsedMs, + coordinatorStats: cancelCoordinatorStats, + }, + }); + })().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); +}; diff --git a/web/app/src/workers/physics-cache-family-test.worker.ts b/web/app/src/workers/physics-cache-family-test.worker.ts new file mode 100644 index 00000000..5e5b98d0 --- /dev/null +++ b/web/app/src/workers/physics-cache-family-test.worker.ts @@ -0,0 +1,87 @@ +import { + PHYSICS_FAMILIES, + PHYSICS_SIMULATION_BUDGET, + parsePhysicsSimulationManifest, + verifyPhysicsCachePayload, + type PhysicsFamily, +} from "../../../protocol/physics-simulation"; + +const digest = async (value: ArrayBuffer): Promise => Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", value)), + (byte) => byte.toString(16).padStart(2, "0"), +).join(""); + +async function cachedSystem(family: PhysicsFamily, index: number) { + const source = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index]).buffer; + const payload = Uint8Array.from([index + 1, 2, index + 3, 4]).buffer; + const settingsHash = await digest(Uint8Array.from([index + 11]).buffer); + const cache = { + schemaVersion: 1, + cacheKey: `physics-${family.toLowerCase()}-1-2`, + family, + source: index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE", + blenderVersion: "5.2.0", + sourceBlendSha256: await digest(source), + settingsHash, + inputHash: await digest(Uint8Array.from([index + 21]).buffer), + cacheSha256: await digest(payload), + frameStart: 1, + frameEnd: 2, + byteLength: payload.byteLength, + frames: [ + { frame: 1, byteOffset: 0, byteLength: 2, sha256: await digest(payload.slice(0, 2)) }, + { frame: 2, byteOffset: 2, byteLength: 2, sha256: await digest(payload.slice(2, 4)) }, + ], + status: "COMPLETE", + }; + return { + source, + payload, + manifest: { + schemaVersion: 1, + systems: [{ + id: `physics:${family.toLowerCase()}`, + family, + ownerObjectId: `object:${family}`, + settingsHash, + settings: { enabled: true }, + dependencyIds: [], + cache, + }], + }, + }; +} + +async function errorCode(operation: () => unknown | Promise): Promise { + try { await operation(); return ""; } + catch (error) { return String((error as Error & { code?: string }).code ?? ""); } +} + +self.onmessage = async () => { + const verified: Array<{ family: PhysicsFamily; source: string; bytes: number; frames: number }> = []; + for (const [index, family] of PHYSICS_FAMILIES.entries()) { + const value = await cachedSystem(family, index); + const parsed = parsePhysicsSimulationManifest(value.manifest); + const cache = await verifyPhysicsCachePayload(parsed.systems[0], value.source, value.payload); + verified.push({ family, source: cache.source, bytes: cache.byteLength, frames: cache.frames.length }); + } + + const value = await cachedSystem("FLUID", 3); + const parsed = parsePhysicsSimulationManifest(value.manifest); + const sourceMismatch = await errorCode(() => verifyPhysicsCachePayload( + parsed.systems[0], Uint8Array.from([1]).buffer, value.payload, + )); + const payloadMismatch = await errorCode(() => verifyPhysicsCachePayload( + parsed.systems[0], value.source, Uint8Array.from([9, 9, 9, 9]).buffer, + )); + const cache = value.manifest.systems[0].cache; + const versionMismatch = await errorCode(() => parsePhysicsSimulationManifest({ + ...value.manifest, + systems: [{ ...value.manifest.systems[0], cache: { ...cache, blenderVersion: "5.3.0" } }], + })); + const budgetExceeded = await errorCode(() => parsePhysicsSimulationManifest({ + ...value.manifest, + systems: [{ ...value.manifest.systems[0], cache: { ...cache, byteLength: PHYSICS_SIMULATION_BUDGET.maxCacheBytes + 1 } }], + })); + self.postMessage({ verified, sourceMismatch, payloadMismatch, versionMismatch, budgetExceeded }); +}; diff --git a/web/app/src/workers/physics-simulation-test.worker.ts b/web/app/src/workers/physics-simulation-test.worker.ts index 6b10fdfb..8cdec2a4 100644 --- a/web/app/src/workers/physics-simulation-test.worker.ts +++ b/web/app/src/workers/physics-simulation-test.worker.ts @@ -18,15 +18,22 @@ const base = { settings: { quality: 5, usePressure: false }, dependencyIds: ["object:Collision"], cache: { + schemaVersion: 1, cacheKey: "cloth-cache-1-2", + family: "CLOTH", source: "BLENDER_DESKTOP_BAKE", + blenderVersion: "5.2.0", sourceBlendSha256: hash, settingsHash: hash, inputHash: hash, cacheSha256: hash, frameStart: 1, frameEnd: 2, - cachedFrames: [1, 2], + byteLength: 2, + frames: [ + { frame: 1, byteOffset: 0, byteLength: 1, sha256: hash }, + { frame: 2, byteOffset: 1, byteLength: 1, sha256: hash }, + ], status: "COMPLETE", }, }; diff --git a/web/app/src/workers/physics-solver-probe-test.worker.ts b/web/app/src/workers/physics-solver-probe-test.worker.ts new file mode 100644 index 00000000..9605c310 --- /dev/null +++ b/web/app/src/workers/physics-solver-probe-test.worker.ts @@ -0,0 +1,48 @@ +import { + PHYSICS_FAMILIES, + gatePhysicsExecution, + probePhysicsSolverCapabilities, + selectPhysicsExecutionRoute, + type PhysicsFamily, + type PhysicsSolverInitializationIR, +} from "../../../protocol/physics-simulation"; + +const mib = 1024 * 1024; +const initialization: Partial> = { + RIGID_BODY: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib }, + SOFT_BODY: { initialized: false, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib }, + CLOTH: { initialized: true, requiredThreadMode: "PTHREAD", requiredMemoryBytes: 128 * mib }, + FLUID: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 512 * mib }, + DYNAMIC_PAINT: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: Number.NaN }, +}; + +self.onmessage = async () => { + const defaultCapabilities = await probePhysicsSolverCapabilities(undefined, { + threadMode: "PTHREAD", + memoryLimitBytes: 2_048 * mib, + }); + const capabilities = await probePhysicsSolverCapabilities({ + hasFamilyExport: (family) => family !== "HAIR", + initializeFamily: async (family) => { + if (family === "PARTICLE") throw new Error("synthetic initialization failure"); + return initialization[family] as PhysicsSolverInitializationIR; + }, + }, { + threadMode: "SINGLE", + memoryLimitBytes: 256 * mib, + }); + const localGate = gatePhysicsExecution("RIGID_BODY", "LOCAL_SOLVER", capabilities); + const fallbackGate = gatePhysicsExecution("CLOTH", "LOCAL_SOLVER", capabilities); + self.postMessage({ + familyOrder: [...PHYSICS_FAMILIES], + defaultProbe: defaultCapabilities.map((entry) => entry.solverProbe), + probes: Object.fromEntries(capabilities.map((entry) => [entry.family, entry.solverProbe])), + routes: Object.fromEntries(capabilities.map((entry) => [entry.family, selectPhysicsExecutionRoute(entry.family, capabilities).mode])), + localGate: { status: localGate.status, issues: localGate.issues.length }, + fallbackGate: { + status: fallbackGate.status, + code: fallbackGate.issues[0]?.code, + message: fallbackGate.issues[0]?.message, + }, + }); +}; diff --git a/web/app/src/workers/sequencer-test.worker.ts b/web/app/src/workers/sequencer-test.worker.ts index 9bf7f7b4..fe3fcc15 100644 --- a/web/app/src/workers/sequencer-test.worker.ts +++ b/web/app/src/workers/sequencer-test.worker.ts @@ -42,7 +42,20 @@ self.onmessage = () => { parseSequencerTimeline({ ...base, strips: new Array(SEQUENCER_BUDGET.maxStrips + 1).fill(movie) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); } - result.codec = gateSequencerCodec("video/mp4", new Set()).issues[0]?.code; + const codecRequest = { + schemaVersion: 1 as const, + stripType: "MOVIE" as const, + mimeType: "video/mp4", + byteLength: 1, + sourceSha256: "0".repeat(64), + }; + result.codec = gateSequencerCodec(codecRequest, { + ...codecRequest, + status: "BLOCKED", + backend: null, + reason: "RUNTIME_UNAVAILABLE", + decoded: null, + }).issues[0]?.code; const transitionTimeline = { ...base, strips: [ { id: "strip:From", name: "From", type: "SCENE", channel: 1, frameStart: 10, frameEnd: 20, sourceStart: 100, sourceEnd: 110, speed: 1, muted: false, locked: false }, { id: "strip:To", name: "To", type: "SCENE", channel: 2, frameStart: 10, frameEnd: 20, sourceStart: 200, sourceEnd: 210, speed: 1, muted: false, locked: false }, diff --git a/web/app/src/workers/storage.worker.ts b/web/app/src/workers/storage.worker.ts index 513a56b7..d29c0708 100644 --- a/web/app/src/workers/storage.worker.ts +++ b/web/app/src/workers/storage.worker.ts @@ -1,9 +1,22 @@ -import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage"; +import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheQuarantineIssue, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage"; import { normalizeProjectAssetPath } from "../../../protocol/asset-path"; import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod"; -import { parseSimulationCacheManifest, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, verifySimulationCacheFrame, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache"; +import { planSimulationCacheLRU, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCacheCancellable, verifySimulationCacheFrame, verifySimulationCacheRevisionBinding, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache"; import { STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION, STORAGE_STORES, upgradeStorageSchema } from "../storage/migrations"; -import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files"; +import { deleteContentAsset, deleteLodCache, deleteProjectSnapshot, ensureProjectLayout, measureProjectVDBBytes, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, readProjectSnapshot, recoverProjectBlend, removeOrphanContentAssets, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, writeProjectSnapshot, type ProjectSaveFault } from "../storage/opfs-files"; +import { classifyRecentProjectIdentity, createRecentProjectIndex, parseRecentProjectIndex, parseRecentProjectRecord, removeRecentProject, upsertRecentProject, type RecentProjectIdentity, type RecentProjectIndex, type RecentProjectIssue, type RecentProjectRecord } from "../../../protocol/recent-projects"; +import { createStorageBudget } from "../../../protocol/storage-budget"; +import { applyUdimTilePatch } from "../../../protocol/paint"; +import { + texturePaintTileBindingKey, + validateTexturePaintTileBindingRequest, + validateTexturePaintTileCommit, + type TexturePaintTileBindingIR, + type TexturePaintTileBindingRequestIR, + type TexturePaintTileBindingResultIR, + type TexturePaintTileCommitIR, + type TexturePaintTileCommitResultIR, +} from "../../../protocol/texture-paint-asset"; const scope = self as unknown as { onmessage: ((event: MessageEvent) => void) | null; @@ -11,7 +24,33 @@ const scope = self as unknown as { }; const projectTransactions = new Map>(); +const simulationPlaybackReady = new Set(); +const simulationPlaybackActive = new Set(); +const cancelledRequests = new Set(); +let recentProjectsTransaction: Promise = Promise.resolve(); let opfsUsable: boolean | undefined; +const RECENT_PROJECTS_SETTING_ID = "recent-projects:v1"; + +function storageError(code: NonNullable, message: string): Error & { code: NonNullable } { + const error = new Error(`${code}: ${message}`) as Error & { code: NonNullable }; + error.code = code; + return error; +} + +function simulationSessionKey(projectId: string, cacheKey: string): string { + return `${projectId}:${cacheKey}`; +} + +function throwIfCancelled(requestId: string): void { + if (cancelledRequests.has(requestId)) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_CANCELLED", "Simulation cache operation was cancelled"); + } +} + +function cancelRequest(requestId: string): void { + cancelledRequests.add(requestId); + setTimeout(() => cancelledRequests.delete(requestId), 30_000); +} interface WorkerLockManager { request(name: string, callback: () => Promise): Promise; @@ -51,6 +90,15 @@ async function withProjectTransaction(projectId: string, operation: () => Pro } } +async function withRecentProjectsTransaction(operation: () => Promise): Promise { + const result = recentProjectsTransaction.catch(() => undefined).then(() => { + const locks = (self as unknown as { navigator?: { locks?: WorkerLockManager } }).navigator?.locks; + return locks ? locks.request("blender-web-recent-projects", operation) : operation(); + }); + recentProjectsTransaction = result.then(() => undefined, () => undefined); + return result; +} + function openDatabase(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION); @@ -101,6 +149,13 @@ interface SimulationManifestRow { manifest: SimulationCacheManifestIR; path: string; createdAt: string; + lastAccessAt: string; +} + +interface SimulationQuarantineRow extends StorageSimulationCacheQuarantineIssue { + id: string; + projectId: string; + row: SimulationManifestRow; } interface ProjectRow { @@ -114,6 +169,100 @@ interface ProjectRow { buffer?: ArrayBuffer; } +interface SettingRow { + id: string; + value?: unknown; +} + +async function readRecentProjectIndex(): Promise<{ index: RecentProjectIndex; quarantined: number }> { + const db = await openDatabase(); + const transaction = db.transaction("setting", "readonly"); + const row = await new Promise((resolve, reject) => { + const request = transaction.objectStore("setting").get(RECENT_PROJECTS_SETTING_ID); + request.onsuccess = () => resolve(request.result as SettingRow | undefined); + request.onerror = () => reject(request.error ?? new Error("Recent project index lookup failed")); + }); + await transactionComplete(transaction); + db.close(); + const parsed = row ? parseRecentProjectIndex(row.value) : { index: createRecentProjectIndex(), quarantined: 0 }; + if (parsed.quarantined > 0) { + const cleaned = await openDatabase(); + const write = cleaned.transaction("setting", "readwrite"); + write.objectStore("setting").put({ id: RECENT_PROJECTS_SETTING_ID, value: parsed.index }); + await transactionComplete(write); + cleaned.close(); + } + return parsed; +} + +async function writeRecentProjectIndex(index: RecentProjectIndex): Promise { + const db = await openDatabase(); + const transaction = db.transaction("setting", "readwrite"); + transaction.objectStore("setting").put({ id: RECENT_PROJECTS_SETTING_ID, value: index }); + await transactionComplete(transaction); + db.close(); +} + +async function readCommittedProjectIdentity(projectId: string, row: ProjectRow): Promise { + if (row.backend === "indexeddb") { + if (!row.buffer || row.buffer.byteLength !== row.bytes) return undefined; + const sha256 = await sha256Hex(row.buffer); + return { revision: row.revision, bytes: row.bytes, sha256 }; + } + if (!await useOpfsForProject(projectId)) return { revision: row.revision, bytes: row.bytes, sha256: row.sha256 }; + const recovery = await recoverProjectBlend(projectId); + if (!recovery.manifest) return undefined; + return { revision: recovery.manifest.revision, bytes: recovery.manifest.bytes, sha256: recovery.manifest.sha256 }; +} + +async function inspectRecentProject(project: RecentProjectRecord): Promise { + // Records created while storage was unavailable cannot be checked against a committed backend. + if (project.backend === "unknown") return undefined; + try { + const row = await readProjectRow(project.projectId); + if (!row) return { project, code: "MISSING" }; + const rowIdentity = { revision: row.revision, bytes: row.bytes, sha256: row.sha256 }; + const committed = await readCommittedProjectIdentity(project.projectId, row); + const metadataIssue = classifyRecentProjectIdentity(rowIdentity, committed); + if (metadataIssue) return { project, code: metadataIssue }; + const issue = classifyRecentProjectIdentity(project, committed); + return issue ? { project, code: issue } : undefined; + } + catch (error) { + const message = error instanceof Error ? error.message : ""; + return { project, code: /integrity|hash|digest|mismatch/i.test(message) ? "HASH_MISMATCH" : "MISSING" }; + } +} + +async function listRecentProjects(): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> { + const parsed = await readRecentProjectIndex(); + const projects: RecentProjectRecord[] = []; + const issues: RecentProjectIssue[] = []; + for (const project of parsed.index.projects) { + const issue = await inspectRecentProject(project); + if (issue) issues.push(issue); + else projects.push(project); + } + return { projects, quarantined: parsed.quarantined, issues }; +} + +async function touchRecentProject(project: RecentProjectRecord): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> { + const parsed = parseRecentProjectRecord(project); + if (!parsed) throw new Error("RECENT_PROJECT_INVALID"); + const current = await readRecentProjectIndex(); + const next = upsertRecentProject(current.index, parsed); + await writeRecentProjectIndex(next); + return listRecentProjects(); +} + +async function removeRecentProjectEntry(projectId: string): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> { + projectLayout(projectId); + const current = await readRecentProjectIndex(); + const next = removeRecentProject(current.index, projectId); + await writeRecentProjectIndex(next); + return listRecentProjects(); +} + async function readProjectRow(projectId: string): Promise { projectLayout(projectId); const db = await openDatabase(); @@ -162,6 +311,58 @@ async function readSimulationRow(projectId: string, cacheKey: string): Promise; reason: string } { + const code = error && typeof error === "object" && "code" in error ? + (error as { code?: StorageResponse["errorCode"] }).code : undefined; + const simulationCode = code === "SIMULATION_CACHE_INVALID" || code === "SIMULATION_CACHE_MISSING" || + code === "SIMULATION_CACHE_HASH_MISMATCH" ? code : "SIMULATION_CACHE_INVALID"; + return { + code: simulationCode, + reason: error instanceof Error ? error.message : "Simulation cache validation failed", + }; +} + +async function quarantineSimulationCache( + row: SimulationManifestRow, + error: unknown, +): Promise { + const issue = simulationIssue(error); + const quarantinedAt = new Date().toISOString(); + const quarantine: SimulationQuarantineRow = { + id: row.id, + projectId: row.projectId, + cacheKey: row.cacheKey, + code: issue.code, + reason: issue.reason, + quarantinedAt, + row, + }; + const db = await openDatabase(); + const transaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readwrite"); + transaction.objectStore("simulation_manifest").delete(row.id); + transaction.objectStore("simulation_quarantine").put(quarantine); + await transactionComplete(transaction); + db.close(); + const sessionKey = simulationSessionKey(row.projectId, row.cacheKey); + simulationPlaybackReady.delete(sessionKey); + simulationPlaybackActive.delete(sessionKey); + return { cacheKey: row.cacheKey, code: issue.code, reason: issue.reason, quarantinedAt }; +} + +async function listSimulationQuarantine(projectId: string): Promise { + const db = await openDatabase(); + const transaction = db.transaction("simulation_quarantine", "readonly"); + const rows = await new Promise((resolve, reject) => { + const request = transaction.objectStore("simulation_quarantine").getAll(); + request.onsuccess = () => resolve((request.result as SimulationQuarantineRow[]).filter((row) => row.projectId === projectId)); + request.onerror = () => reject(request.error ?? new Error("Simulation quarantine scan failed")); + }); + await transactionComplete(transaction); + db.close(); + return rows.map(({ cacheKey, code, reason, quarantinedAt }) => ({ cacheKey, code, reason, quarantinedAt })) + .sort((left, right) => left.quarantinedAt.localeCompare(right.quarantinedAt) || left.cacheKey.localeCompare(right.cacheKey)); +} + async function smoke() { const db = await openDatabase(); const write = db.transaction("smoke", "readwrite"); @@ -245,7 +446,7 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf if (faultAt && existing && previousBuffer) { const rollback = await writeProjectBlend(projectId, existing.revision, previousBuffer); if (rollback.manifest.sha256 !== existing.sha256) { - throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored"); + throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored", { cause: error }); } } throw error; @@ -414,7 +615,12 @@ async function pruneOperations(projectId: string, throughRevision: number): Prom return { projectId, throughRevision, removed: remove.length }; } -interface SnapshotRow extends StorageSnapshotResult { id: string; buffer: ArrayBuffer } +interface SnapshotRow extends StorageSnapshotResult { + id: string; + backend?: "indexeddb" | "opfs"; + path?: string; + buffer?: ArrayBuffer; +} async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBuffer, maxCount = 5, maxBytes = 268_435_456): Promise { projectLayout(projectId); @@ -422,25 +628,59 @@ async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBu throw new Error("Invalid snapshot retention request"); } const sha256 = await sha256Hex(buffer); - const db = await openDatabase(); - const transaction = db.transaction("snapshot", "readwrite"); - const store = transaction.objectStore("snapshot"); + const readDb = await openDatabase(); + const readTransaction = readDb.transaction("snapshot", "readonly"); const rows = await new Promise((resolve, reject) => { - const request = store.getAll(); + const request = readTransaction.objectStore("snapshot").getAll(); request.onsuccess = () => resolve((request.result as SnapshotRow[]).filter((row) => row.projectId === projectId)); request.onerror = () => reject(request.error ?? new Error("Snapshot scan failed")); }); + await transactionComplete(readTransaction); + readDb.close(); + + const existing = rows.find((candidate) => candidate.revision === revision); + if (existing?.bytes === buffer.byteLength && existing.sha256 === sha256) { + const existingBuffer = existing.backend === "opfs" + ? await readProjectSnapshot(projectId, revision) + : existing.buffer; + if (existingBuffer?.byteLength === existing.bytes && await sha256Hex(existingBuffer) === sha256) { + return { projectId, revision, bytes: existing.bytes, sha256, createdAt: existing.createdAt, persisted: true }; + } + } + + const useOpfs = await useOpfsForProject(projectId); + const opfs = useOpfs ? await writeProjectSnapshot(projectId, revision, buffer) : undefined; const createdAt = new Date().toISOString(); - const row: SnapshotRow = { id: `${projectId}:${revision}`, projectId, revision, bytes: buffer.byteLength, sha256, createdAt, persisted: true, buffer: buffer.slice(0) }; - store.put(row); + const row: SnapshotRow = { + id: `${projectId}:${revision}`, + projectId, + revision, + bytes: buffer.byteLength, + sha256, + createdAt, + persisted: true, + backend: useOpfs ? "opfs" : "indexeddb", + path: opfs?.path, + buffer: useOpfs ? undefined : buffer.slice(0), + }; const retained = [...rows.filter((candidate) => candidate.revision !== revision), row].sort((left, right) => right.revision - left.revision); let total = 0; + const removed: SnapshotRow[] = []; + const db = await openDatabase(); + const transaction = db.transaction("snapshot", "readwrite"); + const store = transaction.objectStore("snapshot"); + store.put(row); for (let index = 0; index < retained.length; index++) { total += retained[index].bytes; - if (index >= maxCount || total > maxBytes) store.delete(retained[index].id); + if (index >= maxCount || total > maxBytes) { + store.delete(retained[index].id); + removed.push(retained[index]); + } } await transactionComplete(transaction); db.close(); + await Promise.all(removed.filter((candidate) => candidate.backend === "opfs" && candidate.revision !== revision) + .map((candidate) => deleteProjectSnapshot(projectId, candidate.revision))); return { projectId, revision, bytes: row.bytes, sha256, createdAt, persisted: true }; } @@ -469,8 +709,10 @@ async function readSnapshot(projectId: string, revision: number): Promise { }; } +async function readTexturePaintTileBindingRow(requestValue: TexturePaintTileBindingRequestIR): Promise { + const request = validateTexturePaintTileBindingRequest(requestValue); + const db = await openDatabase(); + const transaction = db.transaction("setting", "readonly"); + const row = await new Promise((resolve, reject) => { + const lookup = transaction.objectStore("setting").get(texturePaintTileBindingKey(request)); + lookup.onsuccess = () => resolve(lookup.result as SettingRow | undefined); + lookup.onerror = () => reject(lookup.error ?? new Error("Texture paint tile binding lookup failed")); + }); + await transactionComplete(transaction); + db.close(); + return row?.value as TexturePaintTileBindingIR | undefined; +} + +async function readTexturePaintTileBinding(requestValue: TexturePaintTileBindingRequestIR): Promise { + const request = validateTexturePaintTileBindingRequest(requestValue); + return { projectId: request.projectId, binding: await readTexturePaintTileBindingRow(request) }; +} + +async function decodePackedPng(data: ArrayBuffer, width: number, height: number): Promise { + const bitmap = await createImageBitmap(new Blob([data], { type: "image/png" })); + try { + if (bitmap.width !== width || bitmap.height !== height) throw new Error("PAINT_SCHEMA_INVALID: Packed texture dimensions do not match the dirty tile target"); + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("PAINT_SCHEMA_INVALID: Texture paint decode canvas is unavailable"); + context.clearRect(0, 0, width, height); + context.drawImage(bitmap, 0, 0); + return new Uint8Array(context.getImageData(0, 0, width, height).data); + } + finally { + bitmap.close(); + } +} + +async function encodePackedPng(pixels: Uint8Array, width: number, height: number): Promise { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) throw new Error("PAINT_SCHEMA_INVALID: Texture paint encode canvas is unavailable"); + context.putImageData(new ImageData(new Uint8ClampedArray(pixels), width, height), 0, 0); + return (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer(); +} + +async function commitTexturePaintTile(commitValue: TexturePaintTileCommitIR): Promise { + const commit = validateTexturePaintTileCommit(commitValue); + const target = commit.target; + const bindingRequest = { + schemaVersion: 1 as const, + projectId: target.projectId, + textureAssetId: target.textureAssetId, + tile: target.tile, + }; + const previous = await readTexturePaintTileBindingRow(bindingRequest); + if (previous && (previous.imageId !== target.imageId || previous.kind !== target.kind || previous.width !== target.width || + previous.height !== target.height || previous.mimeType !== target.mimeType || previous.colorSpace !== target.colorSpace)) { + throw new Error("PAINT_SCHEMA_INVALID: Texture paint tile target does not match its published binding"); + } + const expectedBase = previous?.assetSha256 ?? target.baseAssetSha256; + if (target.baseAssetSha256 !== expectedBase) throw new Error("PAINT_TILE_HASH_MISMATCH: Texture paint base asset binding is stale"); + const base = await readAsset(target.projectId, expectedBase); + if (base.asset.mimeType !== "image/png") throw new Error("PAINT_SCHEMA_INVALID: Texture paint base asset is not a packed PNG"); + const basePixels = await decodePackedPng(base.data, target.width, target.height); + const nextPixels = await applyUdimTilePatch(basePixels, commit.patch, target.revision); + const nextData = await encodePackedPng(nextPixels, target.width, target.height); + if (commit.faultAt === "before-asset-write") throw new Error("STORAGE_TRANSACTION: injected before texture tile asset write"); + const stored = await putAsset(target.projectId, nextData, target.mimeType, target.sourcePath); + if (commit.faultAt === "after-asset-write" || commit.faultAt === "before-binding-commit") { + throw new Error("STORAGE_TRANSACTION: injected before texture tile binding commit"); + } + const binding: TexturePaintTileBindingIR = { + schemaVersion: 1, + projectId: target.projectId, + imageId: target.imageId, + textureAssetId: target.textureAssetId, + kind: target.kind, + tile: target.tile, + revision: target.revision, + generation: (previous?.generation ?? 0) + 1, + width: target.width, + height: target.height, + mimeType: target.mimeType, + colorSpace: target.colorSpace, + sourcePath: target.sourcePath, + assetId: stored.assetId, + assetSha256: stored.sha256, + pixelSha256: commit.patch.resultSha256, + bytes: stored.bytes, + path: stored.path, + updatedAt: new Date().toISOString(), + }; + const db = await openDatabase(); + const transaction = db.transaction("setting", "readwrite"); + transaction.objectStore("setting").put({ id: texturePaintTileBindingKey(bindingRequest), value: binding }); + await transactionComplete(transaction); + db.close(); + const published = await readTexturePaintTileBindingRow(bindingRequest); + if (!published || published.assetSha256 !== stored.sha256 || published.pixelSha256 !== commit.patch.resultSha256) { + throw new Error("STORAGE_TRANSACTION: Texture paint tile binding verification failed"); + } + await readAsset(target.projectId, published.assetSha256); + return { + projectId: target.projectId, + persisted: true, + binding: published, + previousAssetSha256: expectedBase, + orphanedAssetPossible: false, + }; +} + async function saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise { const layout = await writeLodCache(projectId, cacheKey, data); const existing = await readLODRow(projectId, cacheKey); @@ -721,82 +1081,213 @@ async function putSimulationCache( projectId: string, manifestValue: SimulationCacheManifestIR, data: ArrayBuffer, + requestId: string, ): Promise { projectLayout(projectId); - const manifest = await verifySimulationCache(manifestValue, data); + throwIfCancelled(requestId); + const manifest = await verifySimulationCacheRevisionBinding(manifestValue); + throwIfCancelled(requestId); const project = await readProjectRow(projectId); if (!project) { throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend"); } + if (project.revision !== manifest.sourceRevision) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache source revision does not match the committed project"); + } if (project.sha256 !== manifest.sourceBlendSha256) { throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project"); } + await verifySimulationCacheCancellable(manifest, data, () => throwIfCancelled(requestId)); const cacheKey = simulationCacheKey(manifest); const existing = await readSimulationRow(projectId, cacheKey); if (existing && existing.manifest.cacheSha256 !== manifest.cacheSha256) { throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache key already references a different payload"); } - const asset = await putAsset( - projectId, - data, - "application/x-blender-simulation-cache", - `simulation/${cacheKey}.bin`, - ); - if (asset.sha256 !== manifest.cacheSha256) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest"); + let asset: StorageAssetPutResult | undefined; + try { + asset = await putAsset( + projectId, + data, + "application/x-blender-simulation-cache", + `simulation/${cacheKey}.bin`, + ); + if (asset.sha256 !== manifest.cacheSha256) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest"); + } + throwIfCancelled(requestId); + const now = new Date().toISOString(); + const row: SimulationManifestRow = { + id: `${projectId}:${cacheKey}`, + projectId, + cacheKey, + manifest, + path: asset.path, + createdAt: existing?.createdAt ?? now, + lastAccessAt: now, + }; + const db = await openDatabase(); + const transaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readwrite"); + transaction.objectStore("simulation_manifest").put(row); + transaction.objectStore("simulation_quarantine").delete(row.id); + await transactionComplete(transaction); + db.close(); + simulationPlaybackReady.add(simulationSessionKey(projectId, cacheKey)); + return { projectId, cacheKey, persisted: true, manifest, path: row.path }; } - const row: SimulationManifestRow = { - id: `${projectId}:${cacheKey}`, - projectId, - cacheKey, - manifest, - path: asset.path, - createdAt: existing?.createdAt ?? new Date().toISOString(), - }; + catch (error) { + if (!existing && asset && error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") { + await deleteUnreferencedSimulationAssets(projectId, new Set([asset.sha256])); + } + throw error; + } +} + +async function requireSimulationCacheProjectIdentity( + projectId: string, + manifest: SimulationCacheManifestIR, +): Promise { + const project = await readProjectRow(projectId); + if (!project) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend"); + } + if (project.revision !== manifest.sourceRevision) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache is stale for the committed project revision"); + } + if (project.sha256 !== manifest.sourceBlendSha256) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project"); + } + return project; +} + +async function requireSimulationCacheRow( + projectId: string, + cacheKey: string, +): Promise<{ row: SimulationManifestRow; manifest: SimulationCacheManifestIR }> { + const row = await readSimulationRow(projectId, cacheKey); + if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing"); + let manifest: SimulationCacheManifestIR; + try { + manifest = await verifySimulationCacheRevisionBinding(row.manifest); + if (simulationCacheKey(manifest) !== cacheKey) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent"); + } + } + catch (error) { + await quarantineSimulationCache(row, error); + throw error; + } + await requireSimulationCacheProjectIdentity(projectId, manifest); + return { row, manifest }; +} + +function simulationPayloadError(error: unknown): SimulationCacheValidationError { + if (error instanceof SimulationCacheValidationError) return error; + const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined; + if (code === "NON_MESH_RESOURCE_MISSING") { + return new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing"); + } + return new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload failed integrity verification"); +} + +async function readVerifiedSimulationPayload( + row: SimulationManifestRow, + manifest: SimulationCacheManifestIR, + requestId: string, +): Promise { + try { + throwIfCancelled(requestId); + const asset = await readAsset(row.projectId, manifest.cacheSha256); + await verifySimulationCacheCancellable(manifest, asset.data, () => throwIfCancelled(requestId)); + return asset.data; + } + catch (error) { + if (error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") throw error; + const normalized = simulationPayloadError(error); + await quarantineSimulationCache(row, normalized); + throw normalized; + } +} + +async function touchSimulationCache(row: SimulationManifestRow): Promise { + const updated = { ...row, lastAccessAt: new Date().toISOString() }; const db = await openDatabase(); const transaction = db.transaction("simulation_manifest", "readwrite"); - transaction.objectStore("simulation_manifest").put(row); + transaction.objectStore("simulation_manifest").put(updated); await transactionComplete(transaction); db.close(); - return { projectId, cacheKey, persisted: true, manifest, path: row.path }; + return updated; } -async function readSimulationCache(projectId: string, cacheKey: string): Promise { +async function readSimulationCache(projectId: string, cacheKey: string, requestId: string): Promise { projectLayout(projectId); - const row = await readSimulationRow(projectId, cacheKey); - if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing"); - const manifest = parseSimulationCacheManifest(row.manifest); - if (simulationCacheKey(manifest) !== cacheKey) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent"); - } - const asset = await readAsset(projectId, manifest.cacheSha256); - await verifySimulationCache(manifest, asset.data); - return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data }; + const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey); + const data = await readVerifiedSimulationPayload(row, manifest, requestId); + const touched = await touchSimulationCache(row); + simulationPlaybackReady.add(simulationSessionKey(projectId, cacheKey)); + return { projectId, cacheKey, persisted: true, manifest, path: touched.path, data }; } -async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise { +async function prepareSimulationCachePlayback( + projectId: string, + cacheKey: string, + requestId: string, +): Promise { projectLayout(projectId); - const row = await readSimulationRow(projectId, cacheKey); - if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing"); - const manifest = parseSimulationCacheManifest(row.manifest); - if (simulationCacheKey(manifest) !== cacheKey) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent"); + const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey); + await readVerifiedSimulationPayload(row, manifest, requestId); + throwIfCancelled(requestId); + const touched = await touchSimulationCache(row); + const sessionKey = simulationSessionKey(projectId, cacheKey); + simulationPlaybackReady.add(sessionKey); + simulationPlaybackActive.add(sessionKey); + return { projectId, cacheKey, persisted: true, manifest, path: touched.path, verifiedAt: touched.lastAccessAt }; +} + +async function releaseSimulationCachePlayback( + projectId: string, + cacheKey: string, +): Promise { + projectLayout(projectId); + const sessionKey = simulationSessionKey(projectId, cacheKey); + simulationPlaybackReady.delete(sessionKey); + simulationPlaybackActive.delete(sessionKey); + return { projectId, cacheKey, released: true }; +} + +async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, requestId: string): Promise { + projectLayout(projectId); + throwIfCancelled(requestId); + if (!simulationPlaybackReady.has(simulationSessionKey(projectId, cacheKey))) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_NOT_READY", "Simulation cache playback requires full verification in the current Worker"); } + const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey); const selected = selectSimulationCacheFrame(manifest, frame); - const asset = await readAssetRow(projectId, manifest.cacheSha256); - if (!asset || asset.bytes !== manifest.byteLength) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated"); + let data: ArrayBuffer; + try { + const asset = await readAssetRow(projectId, manifest.cacheSha256); + if (!asset || asset.bytes !== manifest.byteLength) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated"); + } + throwIfCancelled(requestId); + data = asset.buffer ? + asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) : + await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength); + throwIfCancelled(requestId); + await verifySimulationCacheFrame(manifest, frame, data); } - const data = asset.buffer ? - asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) : - await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength); - await verifySimulationCacheFrame(manifest, frame, data); + catch (error) { + if (error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") throw error; + const normalized = simulationPayloadError(error); + await quarantineSimulationCache(row, normalized); + throw normalized; + } + const touched = await touchSimulationCache(row); return { projectId, cacheKey, persisted: true, manifest, - path: row.path, + path: touched.path, frame, byteOffset: selected.byteOffset, byteLength: selected.byteLength, @@ -806,6 +1297,10 @@ async function readSimulationCacheFrame(projectId: string, cacheKey: string, fra async function listSimulationCaches(projectId: string): Promise { projectLayout(projectId); + const project = await readProjectRow(projectId); + if (!project) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache listing requires a committed source blend"); + } const db = await openDatabase(); const transaction = db.transaction("simulation_manifest", "readonly"); const rows = await new Promise((resolve, reject) => { @@ -815,22 +1310,184 @@ async function listSimulationCaches(projectId: string): Promise { - const manifest = parseSimulationCacheManifest(row.manifest); - if (simulationCacheKey(manifest) !== row.cacheKey) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent"); + const caches: StorageSimulationCacheListResult["caches"] = []; + for (const row of rows) { + try { + const manifest = await verifySimulationCacheRevisionBinding(row.manifest); + if (simulationCacheKey(manifest) !== row.cacheKey) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent"); + } + if (manifest.sourceRevision === project.revision && manifest.sourceBlendSha256 === project.sha256) { + caches.push({ + cacheKey: row.cacheKey, + manifest, + path: row.path, + createdAt: row.createdAt, + lastAccessAt: row.lastAccessAt ?? row.createdAt, + }); + } } - return { cacheKey: row.cacheKey, manifest, path: row.path, createdAt: row.createdAt }; - }).sort((left, right) => left.cacheKey.localeCompare(right.cacheKey)); - return { projectId, caches }; + catch (error) { + await quarantineSimulationCache(row, error); + } + } + caches.sort((left, right) => left.cacheKey.localeCompare(right.cacheKey)); + const issues = await listSimulationQuarantine(projectId); + return { projectId, caches, quarantined: issues.length, issues }; } -scope.onmessage = async (event) => { +async function deleteUnreferencedSimulationAssets(projectId: string, sha256Values: ReadonlySet): Promise { + if (sha256Values.size === 0) return; + const db = await openDatabase(); + const readTransaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readonly"); + const rowsPromise = new Promise((resolve, reject) => { + const request = readTransaction.objectStore("simulation_manifest").getAll(); + request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId)); + request.onerror = () => reject(request.error ?? new Error("Simulation manifest reference scan failed")); + }); + const quarantinePromise = new Promise((resolve, reject) => { + const request = readTransaction.objectStore("simulation_quarantine").getAll(); + request.onsuccess = () => resolve((request.result as SimulationQuarantineRow[]).filter((row) => row.projectId === projectId)); + request.onerror = () => reject(request.error ?? new Error("Simulation quarantine reference scan failed")); + }); + const [rows, quarantine] = await Promise.all([rowsPromise, quarantinePromise]); + await transactionComplete(readTransaction); + const referenced = new Set([ + ...rows.map((row) => row.manifest.cacheSha256), + ...quarantine.map((row) => row.row.manifest.cacheSha256), + ]); + const removable: string[] = []; + for (const sha256 of sha256Values) { + if (referenced.has(sha256)) continue; + const asset = await readAssetRow(projectId, sha256); + if (asset?.mimeType === "application/x-blender-simulation-cache") removable.push(sha256); + } + if (removable.length > 0) { + const transaction = db.transaction("asset", "readwrite"); + const store = transaction.objectStore("asset"); + removable.forEach((sha256) => store.delete(`${projectId}:${sha256}`)); + await transactionComplete(transaction); + } + db.close(); + await Promise.all(removable.map((sha256) => deleteContentAsset(projectId, sha256))); +} + +async function pruneSimulationCaches( + projectId: string, + maxBytes: number, + protectedCacheKeys: readonly string[] = [], +): Promise { + projectLayout(projectId); + const db = await openDatabase(); + const transaction = db.transaction("simulation_manifest", "readonly"); + const rows = await new Promise((resolve, reject) => { + const request = transaction.objectStore("simulation_manifest").getAll(); + request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId)); + request.onerror = () => reject(request.error ?? new Error("Simulation manifest LRU scan failed")); + }); + await transactionComplete(transaction); + db.close(); + const validRows: SimulationManifestRow[] = []; + for (const row of rows) { + try { + const manifest = await verifySimulationCacheRevisionBinding(row.manifest); + if (simulationCacheKey(manifest) !== row.cacheKey) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent"); + validRows.push({ ...row, manifest, lastAccessAt: row.lastAccessAt ?? row.createdAt }); + } + catch (error) { + await quarantineSimulationCache(row, error); + } + } + const activeKeys = [...simulationPlaybackActive].filter((key) => key.startsWith(`${projectId}:`)).map((key) => key.slice(projectId.length + 1)); + const plan = planSimulationCacheLRU( + validRows.map((row) => ({ cacheKey: row.cacheKey, byteLength: row.manifest.byteLength, createdAt: row.createdAt, lastAccessAt: row.lastAccessAt })), + maxBytes, + [...protectedCacheKeys, ...activeKeys], + ); + const removedRows = validRows.filter((row) => plan.cacheKeys.includes(row.cacheKey)); + if (removedRows.length > 0) { + const writeDb = await openDatabase(); + const write = writeDb.transaction("simulation_manifest", "readwrite"); + const store = write.objectStore("simulation_manifest"); + removedRows.forEach((row) => store.delete(row.id)); + await transactionComplete(write); + writeDb.close(); + for (const row of removedRows) { + const sessionKey = simulationSessionKey(projectId, row.cacheKey); + simulationPlaybackReady.delete(sessionKey); + simulationPlaybackActive.delete(sessionKey); + } + await deleteUnreferencedSimulationAssets(projectId, new Set(removedRows.map((row) => row.manifest.cacheSha256))); + } + return { + projectId, + maxBytes: plan.maxBytes, + beforeBytes: plan.beforeBytes, + remainingBytes: plan.remainingBytes, + removedBytes: plan.removedBytes, + removed: plan.cacheKeys.length, + cacheKeys: plan.cacheKeys, + protectedCacheKeys: plan.protectedCacheKeys, + budgetSatisfied: plan.budgetSatisfied, + }; +} + +async function getStorageBudget(projectId: string): Promise { + projectLayout(projectId); + const db = await openDatabase(); + const transaction = db.transaction(["project", "snapshot", "lod_manifest", "asset"], "readonly"); + const getAll = (storeName: "snapshot" | "lod_manifest" | "asset"): Promise => new Promise((resolve, reject) => { + const request = transaction.objectStore(storeName).getAll(); + request.onsuccess = () => resolve(request.result as T[]); + request.onerror = () => reject(request.error ?? new Error(`Storage budget ${storeName} scan failed`)); + }); + const projectRequest = transaction.objectStore("project").get(projectId); + const projectPromise = new Promise((resolve, reject) => { + projectRequest.onsuccess = () => resolve(projectRequest.result as ProjectRow | undefined); + projectRequest.onerror = () => reject(projectRequest.error ?? new Error("Storage budget project lookup failed")); + }); + const [project, snapshots, lodRows, assets] = await Promise.all([ + projectPromise, + getAll("snapshot"), + getAll("lod_manifest"), + getAll("asset"), + ]); + await transactionComplete(transaction); + db.close(); + const projectSnapshots = snapshots.filter((row) => row.projectId === projectId); + const projectLod = lodRows.filter((row) => row.projectId === projectId); + const projectAssets = assets.filter((row) => row.projectId === projectId); + const simulationAssets = projectAssets.filter((row) => row.mimeType === "application/x-blender-simulation-cache"); + const sum = (values: readonly number[]): number => values.reduce((total, value) => Number.isSafeInteger(value) && value >= 0 ? total + value : total, 0); + return createStorageBudget(projectId, { + projectBytes: project?.bytes ?? 0, + snapshotBytes: sum(projectSnapshots.map((row) => row.bytes)), + lodBytes: sum(projectLod.map((row) => row.bytes ?? 0)), + mediaBytes: sum(projectAssets.filter((row) => row.mimeType !== "application/x-blender-simulation-cache").map((row) => row.bytes)), + vdbBytes: sum(simulationAssets.map((row) => row.bytes)) + await measureProjectVDBBytes(projectId), + }); +} + +async function cleanupProject(projectId: string): Promise { + projectLayout(projectId); + const listed = await listAssets(projectId); + const referenced = new Set(listed.assets.map((asset) => asset.sha256)); + const cleanup = await removeOrphanContentAssets(projectId, referenced); + return { projectId, ...cleanup }; +} + +async function handleRequest(event: MessageEvent): Promise { try { let result; const command = event.data.command; - if (command.type === "smoke") result = await smoke(); + if (command.type === "crashForTest") throw new Error("Worker crash injection escaped the synchronous boundary"); + else if (command.type === "smoke") result = await smoke(); else if (command.type === "info") result = await info(); + else if (command.type === "getBudget") result = await withProjectTransaction(command.projectId, () => getStorageBudget(command.projectId)); + else if (command.type === "cleanupProject") result = await withProjectTransaction(command.projectId, () => cleanupProject(command.projectId)); + else if (command.type === "listRecentProjects") result = await withRecentProjectsTransaction(listRecentProjects); + else if (command.type === "touchRecentProject") result = await withRecentProjectsTransaction(() => touchRecentProject(command.project)); + else if (command.type === "removeRecentProject") result = await withRecentProjectsTransaction(() => removeRecentProjectEntry(command.projectId)); else if (command.type === "ensureProject") result = await withProjectTransaction(command.projectId, () => ensureProject(command.projectId)); else if (command.type === "saveProject") result = await withProjectTransaction(command.projectId, () => saveProject(command.projectId, command.revision, command.buffer, command.faultAt)); else if (command.type === "recoverProject") result = await withProjectTransaction(command.projectId, () => recoverProject(command.projectId)); @@ -844,6 +1501,8 @@ scope.onmessage = async (event) => { else if (command.type === "putAsset") result = await putAsset(command.projectId, command.data, command.mimeType, command.sourcePath); else if (command.type === "readAsset") result = await readAsset(command.projectId, command.sha256); else if (command.type === "listAssets") result = await listAssets(command.projectId); + else if (command.type === "commitTexturePaintTile") result = await withProjectTransaction(command.commit.target.projectId, () => commitTexturePaintTile(command.commit)); + else if (command.type === "readTexturePaintTileBinding") result = await withProjectTransaction(command.request.projectId, () => readTexturePaintTileBinding(command.request)); else if (command.type === "saveLOD") result = await saveLOD(command.projectId, command.cacheKey, command.data); else if (command.type === "putLODManifest") result = await putLODManifest(command.projectId, command.manifest); else if (command.type === "getLODManifest") result = await getLODManifest(command.projectId, command.cacheKey); @@ -851,10 +1510,14 @@ scope.onmessage = async (event) => { else if (command.type === "readLOD") result = await readLOD(command.projectId, command.cacheKey); else if (command.type === "deleteLOD") result = await deleteLOD(command.projectId, command.cacheKey); else if (command.type === "pruneLOD") result = await pruneLOD(command.projectId, command.maxBytes); - else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data)); - else if (command.type === "readSimulationCache") result = await readSimulationCache(command.projectId, command.cacheKey); - else if (command.type === "readSimulationCacheFrame") result = await readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame); - else if (command.type === "listSimulationCaches") result = await listSimulationCaches(command.projectId); + else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data, event.data.requestId)); + else if (command.type === "prepareSimulationCachePlayback") result = await withProjectTransaction(command.projectId, () => prepareSimulationCachePlayback(command.projectId, command.cacheKey, event.data.requestId)); + else if (command.type === "releaseSimulationCachePlayback") result = await withProjectTransaction(command.projectId, () => releaseSimulationCachePlayback(command.projectId, command.cacheKey)); + else if (command.type === "readSimulationCache") result = await withProjectTransaction(command.projectId, () => readSimulationCache(command.projectId, command.cacheKey, event.data.requestId)); + else if (command.type === "readSimulationCacheFrame") result = await withProjectTransaction(command.projectId, () => readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame, event.data.requestId)); + else if (command.type === "listSimulationCaches") result = await withProjectTransaction(command.projectId, () => listSimulationCaches(command.projectId)); + else if (command.type === "pruneSimulationCaches") result = await withProjectTransaction(command.projectId, () => pruneSimulationCaches(command.projectId, command.maxBytes, command.protectedCacheKeys)); + else if (command.type === "cancelRequest") return; else throw new Error("Unknown storage command"); if (result && "data" in result && result.data instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.data]); else if (result && "buffer" in result && result.buffer instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.buffer]); @@ -867,4 +1530,16 @@ scope.onmessage = async (event) => { errorCode: error && typeof error === "object" && "code" in error ? (error as { code: StorageResponse["errorCode"] }).code : undefined, }); } + finally { + cancelledRequests.delete(event.data.requestId); + } +} + +scope.onmessage = (event) => { + if (event.data.command.type === "crashForTest") throw new Error("WORKER_CRASH_INJECTED"); + if (event.data.command.type === "cancelRequest") { + cancelRequest(event.data.command.targetRequestId); + return; + } + void handleRequest(event); }; diff --git a/web/app/src/workers/vdb-fault-test.worker.ts b/web/app/src/workers/vdb-fault-test.worker.ts index ec0b37d9..66373b9a 100644 --- a/web/app/src/workers/vdb-fault-test.worker.ts +++ b/web/app/src/workers/vdb-fault-test.worker.ts @@ -8,10 +8,22 @@ import { uploadNanoVDBFloat32GridPaged, } from "../render/nanovdb-volume-renderer"; import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream"; -import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency } from "../volume/nanovdb-viewport"; +import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency, rebuildNanoVDBGridAfterDeviceLoss } from "../volume/nanovdb-viewport"; const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; +async function readWords(device: GPUDevice, buffer: GPUBuffer, byteLength: number): Promise { + const readback = device.createBuffer({ size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const encoder = device.createCommandEncoder(); + encoder.copyBufferToBuffer(buffer, 0, readback, 0, byteLength); + device.queue.submit([encoder.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const words = new Uint32Array(readback.getMappedRange().slice(0)); + readback.unmap(); + readback.destroy(); + return words; +} + scope.onmessage = (): void => { void (async () => { const manifest = await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR; @@ -199,10 +211,19 @@ scope.onmessage = (): void => { device.destroy(); const loss = await session.waitForLoss(); const recoveredDevice = await session.recover(payload.byteLength + 256 * 1024); - const recoveredDemand = createNanoVDBFloat32GridPaged(recoveredDevice, payload.byteLength, pageByteLength, 2 * pageByteLength); - recoveredDemand.uploadPage(0, await loadNanoVDBGridPage(pagingManifest, density.name, 0, pageSource, new AbortController().signal)); - recoveredDemand.uploadPage(lastPageIndex, await loadNanoVDBGridPage(pagingManifest, density.name, lastPageIndex, pageSource, new AbortController().signal)); - const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, wordOffsets.slice(0, 2)); + const visiblePageIds = [2, 1, 0, 2]; + const replay = await rebuildNanoVDBGridAfterDeviceLoss( + recoveredDevice, + pagingManifest, + density.name, + pageSource, + visiblePageIds, + new AbortController().signal, + ); + const recoveredDemand = replay.grid; + const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, [0, pageByteLength]); + const recoveredPageTable = [...await readWords(recoveredDevice, recoveredDemand.pageTable, recoveredDemand.pageCount * 4)]; + const recoveredResidentPages = [...recoveredDemand.residentVirtualPages]; recoveredDemand.dispose(); const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget); const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord)); @@ -227,7 +248,20 @@ scope.onmessage = (): void => { demandPaging, globalResidency, paging, - deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration, recoveredDemandWords }, + deviceLoss: { + reason: loss.reason, + firstGeneration, + recoveredGeneration, + residentBeforeReplay: replay.residentBeforeReplay, + visiblePageIds: replay.plan.visiblePageIds, + replayedPageIds: replay.plan.replayedPageIds, + skippedPageIds: replay.plan.skippedPageIds, + recoveredResidentPages, + recoveredPageTable, + oldNonVisiblePage: lastPageIndex, + recoveredDemandWords, + expectedDemandWords: [new Uint32Array(page0)[0], new Uint32Array(page1)[0]], + }, samplesStable: JSON.stringify(before) === JSON.stringify(after), }; })().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); diff --git a/web/app/src/workers/viewport-render.worker.ts b/web/app/src/workers/viewport-render.worker.ts index f3ea9320..0380aa95 100644 --- a/web/app/src/workers/viewport-render.worker.ts +++ b/web/app/src/workers/viewport-render.worker.ts @@ -34,21 +34,29 @@ import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNano import { createNanoVDBViewportObject } from "../three-adapter/volume"; import { configurePBRLight, + configurePBRCamera, configurePBRRenderer, createPBRLight, createPBRMaterial, + PBR_SHADOW_MAP_DIMENSION, setPBRMaterialSelected, } from "../three-adapter/pbr"; import { GPUTextureStore } from "../three-adapter/texture-assets"; +import { planPBRLightingBudget } from "../../../protocol/render-budget"; import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh"; import { applyGreasePencilPointSelection, applyGreasePencilPointPreview, applyGreasePencilTransform, createGreasePencilObject, + greasePencilMarqueeCandidates, greasePencilPointRef, type GreasePencilPointRef, } from "../three-adapter/grease-pencil"; +import { selectGreasePencilMarquee, type GreasePencilDrawingScopeIR, type GreasePencilMarqueeBoxIR } from "../../../protocol/grease-pencil-marquee"; +import { applyOrbitDelta, cameraState, VIEWPORT_DEFAULT_ORBIT, orbitPosition, type ViewportOrbitState } from "../../../protocol/viewport-camera"; +import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR } from "../../../protocol/paint-depth-visibility"; +import { samplePaintDepthVisibilityGPU } from "../three-adapter/paint-depth-visibility"; const workerScope = self as unknown as { onmessage: ((event: MessageEvent) => void) | null; @@ -62,13 +70,11 @@ let importedLights: Group | null = null; let contextLost = false; let width = 1; let height = 1; -let yaw = -Math.PI / 4; -let pitch = 0.55; -let distance = 7; +let orbit: ViewportOrbitState = { ...VIEWPORT_DEFAULT_ORBIT, target: [...VIEWPORT_DEFAULT_ORBIT.target] as [number, number, number] }; let editMode = false; let selectionMode: MeshElementMode = "FACE"; let currentSnapshot: SceneSnapshotIR | null = null; -const textureStore = new GPUTextureStore(); +const textureStore = new GPUTextureStore("THREE_WEBGL2"); const raycaster = new Raycaster(); raycaster.params.Points.threshold = 0.14; const objectById = new Map(); @@ -88,8 +94,8 @@ function post(message: OffscreenViewportResponse): void { function render(): void { if (contextLost || !renderer || !scene || !camera) return; - camera.position.set(distance * Math.cos(pitch) * Math.cos(yaw), distance * Math.cos(pitch) * Math.sin(yaw), distance * Math.sin(pitch)); - camera.lookAt(0, 0, 0); + camera.position.set(...orbitPosition(orbit)); + camera.lookAt(...orbit.target); renderer.render(scene, camera); publishCurveGizmoFrame(); const gl = renderer.getContext(); @@ -109,7 +115,7 @@ function render(): void { for (let index = 0; index < pixels.length; index += 4) { if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) visiblePixels += 1; } - post({ type: "frame", visiblePixels }); + post({ type: "frame", visiblePixels, camera: cameraState(orbit) }); } function publishCurveGizmoFrame(): void { @@ -155,6 +161,15 @@ function matrixFor(node: SceneSnapshotIR["nodes"][number]): Matrix4 { ); } +function applyCamera(snapshot: SceneSnapshotIR): void { + if (!camera) return; + const cameraObjectId = snapshot.scenes[0]?.cameraObjectId; + const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA"); + const definition = snapshot.cameras.find((candidate) => candidate.id === cameraNode?.dataId); + if (!cameraNode || !definition) return; + configurePBRCamera(camera, definition); +} + function geometryFrom(payload: MeshGeometryBuffer, materialCount: number): BufferGeometry { const sourcePositions = new Float32Array(payload.positions); const sourceIndices = new Uint32Array(payload.indices); @@ -283,7 +298,7 @@ function applyTextureAssets(assets: readonly GPUTextureAsset[]): void { if (currentSnapshot && root) textureStore.applySnapshotMaterials(root, currentSnapshot); const world = currentSnapshot?.worlds.find((candidate) => candidate.id === currentSnapshot?.scenes[0]?.worldId) ?? currentSnapshot?.worlds[0]; if (scene && renderer) void textureStore.applyEnvironment(scene, renderer, world, true); - post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes }); + post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes, budget: status.budget }); render(); }).catch((error) => post({ type: "error", message: error instanceof Error ? error.message : "Texture upload failed" })); } @@ -393,19 +408,26 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n greasePencilOnionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0); greasePencilCount++; } - post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount }); + const lightingBudget = planPBRLightingBudget(snapshot, "THREE_WEBGL2"); + post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount, renderBudget: lightingBudget }); const world = snapshot.worlds.find((candidate) => candidate.id === snapshot.scenes[0]?.worldId) ?? snapshot.worlds[0]; const sceneDefinition = snapshot.scenes.find((candidate) => candidate.id === snapshot.sceneId) ?? snapshot.scenes[0]; scene.background = world ? new Color().setRGB(...world.color) : new Color(0x25272b); if (renderer) configurePBRRenderer(renderer, sceneDefinition?.colorManagement?.exposure ?? world?.exposure ?? 0); + applyCamera(snapshot); + const rendered = new Set(lightingBudget.renderedLightNodeIds); + const shadowed = new Set(lightingBudget.shadowLightNodeIds); if (importedLights) { const lights = new Map(snapshot.lights.map((definition) => [definition.id, definition])); for (const node of snapshot.nodes) { - if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue; + if (node.type !== "LIGHT" || !node.visible || !node.dataId || !rendered.has(node.id)) continue; const definition = lights.get(node.dataId); if (!definition) continue; const light = createPBRLight(definition); - configurePBRLight(light, node, importedLights); + configurePBRLight(light, node, importedLights, { + shadowEnabled: shadowed.has(node.id), + shadowMapDimension: lightingBudget.budget.shadowMapDimension, + }); light.name = node.name; importedLights.add(light); objectById.set(node.id, light); @@ -415,7 +437,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n render(); } -function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[]): void { +function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[], greasePencilSelectionRevision: number): void { const selected = new Set(ids); const visited = new Set(); for (const [id, object] of objectById) { @@ -444,9 +466,10 @@ function setSelection(ids: string[], elements: Array<{ dataId: string; kind: Non if (root) applyNonMeshElementSelection(root, selection); if (root) applyGreasePencilPointSelection(root, greasePencilPoints); render(); + post({ type: "greasePencilSelectionStatus", selectionRevision: greasePencilSelectionRevision, pointIds: greasePencilPoints.map((point) => point.pointId) }); } -function pick(x: number, y: number, additive: boolean): void { +function pick(x: number, y: number, additive: boolean, baseSelectionRevision: number): void { if (!root || !camera) return; raycaster.setFromCamera(new Vector2(x, y), camera); const hits = raycaster.intersectObjects(root.children, true); @@ -455,7 +478,7 @@ function pick(x: number, y: number, additive: boolean): void { : undefined; if (greasePencilHit?.index !== undefined) { const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index); - if (point) post({ type: "greasePencilPointSelected", point, additive }); + if (point) post({ type: "greasePencilPointSelected", point, additive, baseSelectionRevision }); return; } const preferredNonMeshHit = editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined; @@ -511,6 +534,41 @@ function pick(x: number, y: number, additive: boolean): void { else if (typeof objectId === "string") post({ type: "selected", objectId, additive }); } +function greasePencilMarquee( + drawing: GreasePencilDrawingScopeIR, + box: GreasePencilMarqueeBoxIR, + baseRevision: number, + baseSelectionRevision: number, + additive: boolean, +): void { + if (!root || !camera || !currentSnapshot) return; + const result = selectGreasePencilMarquee({ + schemaVersion: 1, + baseRevision, + baseSelectionRevision, + drawing, + box, + candidates: greasePencilMarqueeCandidates(root, drawing, camera), + }, currentSnapshot.revision); + post({ type: "greasePencilMarqueeSelected", result, additive }); +} + +function paintDepthVisibility(requestId: string, requestValue: PaintDepthVisibilityRequestIR): void { + try { + if (!renderer || !scene || !camera || !currentSnapshot) throw new Error("PAINT_DEPTH_UNAVAILABLE: Offscreen viewport is not ready"); + const request = validatePaintDepthVisibilityRequest(requestValue, currentSnapshot.revision); + const node = currentSnapshot.nodes.find((candidate) => candidate.id === request.objectId && candidate.dataId === request.meshId && candidate.visible); + const object = node ? objectById.get(node.id) : undefined; + if (!object) throw new Error("PAINT_DEPTH_UNAVAILABLE: Paint object is not available in the current viewport"); + const result = samplePaintDepthVisibilityGPU({ renderer, scene, camera, object, request, backend: "OFFSCREEN_WEBGL2" }); + post({ type: "paintDepthVisibilityResult", requestId, result }); + render(); + } + catch (error) { + post({ type: "paintDepthVisibilityError", requestId, message: error instanceof Error ? error.message : "PAINT_DEPTH_UNAVAILABLE: GPU depth readback failed" }); + } +} + workerScope.onmessage = (event): void => { try { const message = event.data; @@ -537,7 +595,7 @@ workerScope.onmessage = (event): void => { const light = new DirectionalLight(0xffffff, 2.5); light.position.set(4, -5, 8); light.castShadow = true; - light.shadow.mapSize.set(1024, 1024); + light.shadow.mapSize.set(PBR_SHADOW_MAP_DIMENSION, PBR_SHADOW_MAP_DIMENSION); light.shadow.bias = -0.0005; light.shadow.normalBias = 0.03; scene.add(light, light.target, new GridHelper(20, 20, 0x60656e, 0x383b42), root, importedLights); @@ -552,7 +610,7 @@ workerScope.onmessage = (event): void => { void refreshVolumes(); } else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio); - else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints); + else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints, message.greasePencilSelectionRevision); else if (message.type === "interaction") { editMode = message.editMode; selectionMode = message.selectionMode; @@ -574,12 +632,14 @@ workerScope.onmessage = (event): void => { render(); } else if (message.type === "orbit") { - yaw -= message.deltaX * 0.008; - pitch = Math.max(-1.45, Math.min(1.45, pitch + message.deltaY * 0.008)); - distance = Math.max(0.2, Math.min(500, distance * Math.exp(message.zoom * 0.001))); + orbit = applyOrbitDelta(orbit, message.deltaX, message.deltaY, message.zoom); render(); } - else if (message.type === "pick") pick(message.x, message.y, message.additive); + else if (message.type === "pick") pick(message.x, message.y, message.additive, message.baseSelectionRevision); + else if (message.type === "greasePencilMarquee") { + greasePencilMarquee(message.drawing, message.box, message.baseRevision, message.baseSelectionRevision, message.additive); + } + else if (message.type === "paintDepthVisibility") paintDepthVisibility(message.requestId, message.request); else if (message.type === "dispose") { clearRoot(); volumeRenderGeneration++; diff --git a/web/app/src/workers/web-engine.worker.ts b/web/app/src/workers/web-engine.worker.ts index b2434751..f28d7d5b 100644 --- a/web/app/src/workers/web-engine.worker.ts +++ b/web/app/src/workers/web-engine.worker.ts @@ -1,6 +1,6 @@ import type { ErrorReport } from "../../../protocol/error"; import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta"; -import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine"; +import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine"; import { parseSceneSnapshotIR } from "../../../protocol/scene-ir"; import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify"; import type { LODGenerationRequest } from "../../../protocol/lod"; @@ -12,11 +12,23 @@ import { cloneMeshGeometryBuffers, diffMeshGeometryBuffers } from "../../../prot import { gateSculptCapability, parseSculptMeshAttributes, parseSculptStroke } from "../../../protocol/sculpt"; import { gateGeometryNodeGraph } from "../../../protocol/geometry-nodes"; import { gateShaderGraph } from "../../../protocol/shader-graph"; -import { gateNlaTracks } from "../../../protocol/nla"; +import { compileShaderGraph, type ShaderCompileReport } from "../../../protocol/shader-compiler"; +import { gateNlaTracks, moveNlaStrip } from "../../../protocol/nla"; import { blockedGate, capabilityIssue, type CapabilityGateResult } from "../../../protocol/capability-gates"; import { gateRenderCapability } from "../../../protocol/render-capabilities"; import { gateNonMeshData } from "../../../protocol/non-mesh"; import { chunkNonMeshGeometry, nonMeshChunkTransferables, type NonMeshGeometryChunk, type NonMeshAttributeArray } from "../../../protocol/nonmesh-binary"; +import { + EXTERNAL_VFONT_MAX_BYTES, + validateExternalVFontImport, + validateExternalVFontMainImportProof, +} from "../../../protocol/external-vfont"; +import { validateGreasePencilReorderCommand } from "../../../protocol/grease-pencil-reorder"; +import { PaintStrokeSessionError, PaintStrokeSessionStore } from "../../../protocol/paint-stroke-session"; +import { + gatePaintPBVHCapability, + PAINT_PBVH_WASM_ENTRYPOINT, +} from "../../../protocol/paint-pbvh-capability"; type WasmModule = { _malloc: (size: number) => number; @@ -97,6 +109,7 @@ let sourceBlendBuffer: ArrayBuffer | null = null; let sourceBlendRevision = -1; const cancelledOpenRequests = new Set(); const activeOpenResources = new Map(); +const paintStrokeSessions = new PaintStrokeSessionStore(); function openResourceStatus(): WebEngineOpenResourceStatus { return [...activeOpenResources.values()].reduce((status, item) => ({ @@ -140,11 +153,78 @@ function reportProtocolError(error: unknown, fallbackCode: ErrorReport["code"]): throw report(fallbackCode, error instanceof Error ? error.message : "Capability payload is invalid"); } +async function assertExternalVFontImport(payload: Extract): Promise { + try { + validateExternalVFontMainImportProof(payload); + } + catch (error) { + reportProtocolError(error, "NON_MESH_BINARY_INVALID"); + } + if (typeof payload.base64 !== "string" || payload.base64.length === 0 || + payload.base64.length > Math.ceil(EXTERNAL_VFONT_MAX_BYTES / 3) * 4 + 4) { + throw report("NON_MESH_DATA_BUDGET_EXCEEDED", "External VFont command exceeds the 32 MiB payload budget"); + } + let data: ArrayBuffer; + try { + const binary = atob(payload.base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); + data = bytes.buffer; + } + catch { + throw report("NON_MESH_BINARY_INVALID", "External VFont command base64 is invalid"); + } + try { + await validateExternalVFontImport({ + sourcePath: payload.sourcePath, + mimeType: payload.mimeType, + byteLength: payload.byteLength, + sha256: payload.sha256, + data, + }); + } + catch (error) { + reportProtocolError(error, "NON_MESH_BINARY_INVALID"); + } +} + function rejectCapabilityGate(gate: CapabilityGateResult): never { const issue = gate.issues[0]; throw report(issue?.code ?? "CAPABILITY_MISSING", `N-${gate.taskId.slice(2)} ${gate.capability} blocked${issue ? `: ${issue.message}` : ""}`); } +function prepareNlaMoveCommand( + payload: Extract, +): Extract { + const snapshot = currentSnapshot; + if (!snapshot?.nodes.some((node) => node.id === payload.objectId)) { + throw report("NLA_PATH_INCOMPATIBLE", `NLA owner does not exist: ${payload.objectId}`); + } + if (payload.baseRevision !== snapshot.revision) { + throw report("REVISION_CONFLICT", "NLA operator base revision does not match the current SceneIR"); + } + const actionIds = new Set(snapshot.animations.map((animation) => animation.id)); + const actionChannelPaths = new Map(snapshot.animations.map((animation) => [ + animation.id, + new Set(animation.channels.map((channel) => channel.path)), + ])); + try { + return { + type: "setNLAStack", + objectId: payload.objectId, + baseRevision: payload.baseRevision, + tracks: moveNlaStrip( + (snapshot.nlaTracks ?? []).filter((track) => track.ownerId === payload.objectId), + payload, + { actionIds, actionChannelPaths, ownerId: payload.objectId }, + ), + }; + } + catch (error) { + reportProtocolError(error, "NLA_INVALID_STACK"); + } +} + function sculptMeshGate(meshId: string): CapabilityGateResult { const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === meshId); const objectCount = currentSnapshot?.nodes.filter((node) => node.type === "MESH" && node.dataId === meshId).length ?? 0; @@ -309,6 +389,16 @@ function assertFutureCapability(payload: Extract= payload.textBoxes.length) throw report("NON_MESH_PROPERTY_INVALID", "Font active text box is outside the text box array"); return; } + case "importVFont": { + try { + validateExternalVFontMainImportProof(payload); + return; + } + catch (error) { + reportProtocolError(error, "NON_MESH_BINARY_INVALID"); + } + return; + } case "setFontLinks": { const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId); if (!data || data.type !== "FONT") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 font data block is unavailable: ${payload.dataId}`); @@ -345,7 +435,21 @@ function assertFutureCapability(payload: Extract candidate.id === payload.objectId && candidate.type === "MESH"); const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === object?.dataId); if (!object || !mesh || typeof payload.vertexGroup !== "string" || payload.vertexGroup.length === 0 || new TextEncoder().encode(payload.vertexGroup).byteLength > 63 || !Array.isArray(payload.indices) || !Array.isArray(payload.values) || payload.indices.length === 0 || payload.indices.length !== payload.values.length || payload.indices.length > 1_000_000) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch is invalid"); - if (payload.indices.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= mesh.vertexCount) || payload.values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) || (payload.normalize !== undefined && typeof payload.normalize !== "boolean") || (payload.mirror !== undefined && typeof payload.mirror !== "boolean")) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch values are outside the bounded domain"); - if (payload.mirror) throw report("CAPABILITY_MISSING", "N-017 topology mirror requires a verified mesh symmetry map"); + const uniqueIndices = new Set(); + if (payload.indices.some((index) => { + if (!Number.isSafeInteger(index) || index < 0 || index >= mesh.vertexCount || uniqueIndices.has(index)) return true; + uniqueIndices.add(index); + return false; + }) || payload.values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) || (payload.normalize !== undefined && typeof payload.normalize !== "boolean") || (payload.limit !== undefined && (!Number.isSafeInteger(payload.limit) || payload.limit < 1 || payload.limit > 32)) || (payload.mirror !== undefined && typeof payload.mirror !== "boolean") || (payload.mirrorAxis !== undefined && (!Number.isSafeInteger(payload.mirrorAxis) || payload.mirrorAxis < 0 || payload.mirrorAxis > 2)) || (payload.mirrorTolerance !== undefined && (typeof payload.mirrorTolerance !== "number" || !Number.isFinite(payload.mirrorTolerance) || payload.mirrorTolerance <= 0 || payload.mirrorTolerance > 1))) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch values are outside the bounded domain"); + if (!payload.mirror && (payload.mirrorAxis !== undefined || payload.mirrorTolerance !== undefined)) throw report("PAINT_SCHEMA_INVALID", "Vertex weight mirror axis/tolerance require mirror=true"); return; } case "setLightProperties": { @@ -949,11 +1058,17 @@ function progress(requestId: string, phase: "started" | "progress" | "completed" scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message, stage, cancellable } }); } -scope.onmessage = async (event) => { +function openCancellationWindow(): Promise { + return new Promise((resolve) => setTimeout(resolve, 16)); +} + +async function handleRequest(event: MessageEvent): Promise { const request = event.data; try { let result: WebEngineResult; switch (request.command.type) { + case "crashForTest": + throw report("WORKER_TERMINATED", "Worker crash injection escaped the synchronous boundary"); case "init": result = baseResult(); await initialize(); @@ -966,6 +1081,7 @@ scope.onmessage = async (event) => { let candidateHandle = 0; try { progress(request.requestId, "started", 0, "Preparing isolated Main", "NATIVE_INITIALIZE", true); + await openCancellationWindow(); await initialize(); throwIfOpenCancelled(request.requestId); if (!wasmFactory || !wasmBinary) throw report("WASM_INIT_FAILED", "WebEngine isolated open module is unavailable"); @@ -987,7 +1103,7 @@ scope.onmessage = async (event) => { resource.liveInputBytes = 0; } progress(request.requestId, "progress", 0.7, "Isolated Main opened", "NATIVE_OPENED", true); - await new Promise((resolve) => setTimeout(resolve, 0)); + await openCancellationWindow(); throwIfOpenCancelled(request.requestId); const previousModule = module; @@ -1000,6 +1116,7 @@ scope.onmessage = async (event) => { throwIfOpenCancelled(request.requestId); const sourceCopy = request.command.buffer.slice(0); currentSnapshot = scene.snapshot; + paintStrokeSessions.clear(); sourceBlendBuffer = sourceCopy; sourceBlendRevision = scene.snapshot.revision; result = { status: status(), ...publishFullScene(scene) }; @@ -1040,11 +1157,87 @@ scope.onmessage = async (event) => { await initialize(); result = { status: status(), delta: readDelta() }; break; + case "beginPaintStroke": + if (!currentSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene"); + result = { status: status(), paintStrokeSession: paintStrokeSessions.begin(request.command.session, currentSnapshot.revision) }; + break; + case "appendPaintStrokeChunk": + if (!currentSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene"); + result = { status: status(), paintStrokeSession: paintStrokeSessions.append(request.command.chunk, currentSnapshot.revision) }; + break; + case "cancelPaintStroke": + result = { status: status(), paintStrokeSession: paintStrokeSessions.cancel(request.command.session) }; + break; + case "queryPaintPBVHCapability": { + await initialize(); + const activeObject = currentSnapshot?.nodes.find((node) => node.id === currentSnapshot?.activeObjectId && node.type === "MESH"); + const nativeEntrypointPresent = typeof (module as unknown as Record | null)?.[PAINT_PBVH_WASM_ENTRYPOINT] === "function"; + try { + const gate = gatePaintPBVHCapability(request.command.request, { + nativeEntrypointPresent, + sessionContextReady: false, + verifiedBrushes: new Set(), + currentRevision: currentSnapshot?.revision, + currentObjectId: activeObject?.id, + currentMeshId: activeObject?.dataId ?? undefined, + }); + result = { status: status(), capabilityGate: gate }; + } + catch (error) { + reportProtocolError(error, "PAINT_SCHEMA_INVALID"); + } + break; + } + case "commitPaintStroke": { + await initialize(); + const beforeSnapshot = currentSnapshot; + if (!beforeSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene"); + const staged = paintStrokeSessions.commit(request.command.session, beforeSnapshot.revision); + assertFutureCapability(staged.command); + const input = copyCommand(staged.command); + try { + if (!module || module._web_engine_apply_command(handle, input.pointer, input.length) !== 0) throw nativeError("INVALID_ARGUMENT"); + } + finally { + module?._free(input.pointer); + } + const scene = await readSnapshotResult(); + currentSnapshot = scene.snapshot; + result = { + status: status(), + ...publishIncrementalScene(scene), + delta: readDelta(), + paintStrokeSession: { ...staged.receipt, state: "COMMITTED", committedRevision: scene.snapshot.revision }, + }; + break; + } case "applyCommand": { await initialize(); const beforeSnapshot = currentSnapshot; const payload = request.command.payload; - assertFutureCapability(payload); + const nativePayload = payload.type === "moveNLAStrip" ? prepareNlaMoveCommand(payload) : payload; + if (payload.type === "importVFont") await assertExternalVFontImport(payload); + assertFutureCapability(nativePayload); + let shaderCompile: ShaderCompileReport | undefined; + if (payload.type === "setShaderGraph") { + shaderCompile = compileShaderGraph( + payload.graph, + beforeSnapshot?.materials.find((material) => material.id === payload.materialId), + { + imageIds: new Set(beforeSnapshot?.images.map((image) => image.id) ?? []), + blockedImageIds: new Set(beforeSnapshot?.images + .filter((image) => image.libraryLinked || image.assetStatus === "LINKED_LIBRARY_REQUIRED" || image.assetStatus === "MISSING" || image.assetStatus === "CORRUPT") + .map((image) => image.id) ?? []), + textureIdentities: new Map(beforeSnapshot?.images.map((image) => [image.id, { + assetId: image.assetId, + sha256: image.sha256, + colorSpace: image.colorSpace, + }] as const) ?? []), + }, + ); + const compileIssue = shaderCompile.issues[0]; + if (shaderCompile.status === "BLOCKED" && compileIssue) throw report(compileIssue.code, compileIssue.message, true); + } if (payload.type === "decimateMesh" || payload.type === "previewDecimateMesh") assertModifierStackEvaluated(beforeSnapshot, payload.meshId); if (payload.type === "decimateMesh" || payload.type === "previewDecimateMesh") assertSkinSimplifyEvaluable(beforeSnapshot, payload.meshId, payload.profile); if (payload.type === "previewDecimateMesh") { @@ -1102,7 +1295,7 @@ scope.onmessage = async (event) => { result = { status: status(), ...publishIncrementalScene(scene), delta: readDelta() }; break; } - const input = copyCommand(payload); + const input = copyCommand(nativePayload); try { if (!module || module._web_engine_apply_command(handle, input.pointer, input.length) !== 0) { throw nativeError("INVALID_ARGUMENT"); @@ -1114,7 +1307,13 @@ scope.onmessage = async (event) => { const scene = await readSnapshotResult(); currentSnapshot = scene.snapshot; const simplify = simplifyReport(beforeSnapshot, scene, payload); - result = { status: status(), ...publishIncrementalScene(scene), delta: readDelta(), simplify }; + result = { + status: status(), + ...publishIncrementalScene(scene), + delta: readDelta(), + simplify, + shaderCompile, + }; break; } case "generateLOD": { @@ -1287,6 +1486,7 @@ scope.onmessage = async (event) => { handle = 0; currentSnapshot = null; currentGeometryBuffers = []; + paintStrokeSessions.clear(); result = { status: { ready: false, liveHandles: 0, allocatedBytes: 0 } }; break; } @@ -1314,8 +1514,15 @@ scope.onmessage = async (event) => { scope.postMessage({ kind: "result", requestId: request.requestId, ok: true, result }, transfer); } catch (error) { - const native = typeof error === "object" && error !== null && "code" in error ? error as ErrorReport : null; + const native = error instanceof PaintStrokeSessionError + ? report(error.code, error.message) + : typeof error === "object" && error !== null && "code" in error ? error as ErrorReport : null; const failure = native ?? report("WASM_INIT_FAILED", error instanceof Error ? error.message : "WebEngine request failed"); scope.postMessage({ kind: "result", requestId: request.requestId, ok: false, error: failure }); } +} + +scope.onmessage = (event) => { + if (event.data.command.type === "crashForTest") throw new Error("WORKER_CRASH_INJECTED"); + void handleRequest(event); }; diff --git a/web/app/vite.config.ts b/web/app/vite.config.ts index e1d88209..0634f23c 100644 --- a/web/app/vite.config.ts +++ b/web/app/vite.config.ts @@ -94,12 +94,140 @@ function localVDBFixture(): Plugin { }; } +// Materialize requested bytes over HTTP without checking a 64 MiB performance-only fixture into the repository. +const sparseBundleBytes = 64 * 1024 * 1024; +const sparseChunkBytes = 4 * 1024 * 1024; +const sparseStreamBytes = 64 * 1024; +const sparseChunkSha256 = "bb9f8df61474d25e71fa00722318cd387396ca1736605e1248821cc0de3d3af8"; +const sparseBundleSha256 = "3b6a07d0d404fab4e23b6d34bc6696a6a312dd92821332385e5af7c01c421351"; +const sparseConverterSha256 = "0000000000000000000000000000000000000000000000000000000000000000"; + +function sparseBundleManifest(): Record { + return { + schemaVersion: 1, + projectId: "vdb-sparse-64m", + sourcePath: "//volumes/generated-synthetic-sparse.vdb", + sourceSha256: sparseBundleSha256, + conversionRequestSha256: sparseConverterSha256, + bundlePath: "//volumes/generated-sparse-64m.nvdb", + bundleByteLength: sparseBundleBytes, + bundleSha256: sparseBundleSha256, + converter: { + target: "DESKTOP", + blenderVersion: "5.2.0", + openVDBVersion: "13.0.0", + nanoVDBVersion: "32.9.0", + executableSha256: sparseConverterSha256, + }, + grids: [{ + name: "density", + valueType: "FLOAT16", + gridClass: "FOG_VOLUME", + semantic: "DENSITY", + activeVoxelCount: 0, + segmentByteOffset: 0, + segmentByteLength: sparseBundleBytes, + byteOffset: 0, + byteLength: sparseBundleBytes, + indexBounds: { min: [0, 0, 0], max: [0, 0, 0] }, + worldBounds: { min: [0, 0, 0], max: [0, 0, 0] }, + voxelSize: [1, 1, 1], + indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + }], + chunks: Array.from({ length: sparseBundleBytes / sparseChunkBytes }, (_, index) => ({ + index, + byteOffset: index * sparseChunkBytes, + byteLength: sparseChunkBytes, + sha256: sparseChunkSha256, + })), + material: { + densityGrid: "density", + densityScale: 1, + emissionScale: 0, + temperatureScale: 1, + anisotropy: 0, + interpolation: "NEAREST", + }, + gpu: { + representation: "NANOVDB_STORAGE_BUFFER", + byteAlignment: 32, + pageByteLength: 256 * 1024, + maxResidentBytes: 1 * 1024 * 1024, + shaderSemanticVersion: "volume-wgsl-v1", + }, + }; +} + +function serveSparseVDBFixture(): Plugin { + return { + name: "serve-sparse-64m-vdb-fixture", + configureServer(server) { + server.middlewares.use((request, response, next) => { + const nodeRequest = request as unknown as { url?: string; headers: { range?: string } }; + const parsed = new URL(nodeRequest.url ?? "/", "http://vite.local"); + if (parsed.pathname !== "/__vdb_sparse_64m__/manifest" && parsed.pathname !== "/__vdb_sparse_64m__/bundle") { next(); return; } + const output = response as unknown as { + statusCode: number; + setHeader: (name: string, value: string | number) => void; + writeHead: (status: number) => void; + write: (data: Uint8Array) => boolean; + end: (data?: string) => void; + on: (event: string, listener: () => void) => void; + once: (event: string, listener: () => void) => void; + }; + if (parsed.pathname.endsWith("/manifest")) { + const body = JSON.stringify(sparseBundleManifest()); + output.statusCode = 200; + output.setHeader("Content-Type", "application/json"); + output.setHeader("Cache-Control", "no-store"); + output.setHeader("Content-Length", body.length); + output.end(body); + return; + } + const match = nodeRequest.headers.range?.match(/^bytes=(\d+)-(\d+)$/); + const start = match ? Number(match[1]) : 0; + const end = match ? Number(match[2]) : sparseBundleBytes - 1; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= sparseBundleBytes) { + output.statusCode = 416; + output.end(); + return; + } + const length = end - start + 1; + const delayMs = Math.min(25, Math.max(0, Number.parseInt(parsed.searchParams.get("delayMs") ?? "0", 10) || 0)); + output.statusCode = match ? 206 : 200; + output.setHeader("Content-Type", "application/x-nanovdb"); + output.setHeader("Accept-Ranges", "bytes"); + output.setHeader("Cache-Control", "no-store"); + output.setHeader("ETag", '"vdb-sparse-64m-v1"'); + output.setHeader("Content-Length", length); + if (match) output.setHeader("Content-Range", `bytes ${start}-${end}/${sparseBundleBytes}`); + let closed = false; + let written = 0; + const zeroes = new Uint8Array(sparseStreamBytes); + output.on("close", () => { closed = true; }); + const pump = (): void => { + if (closed) return; + if (written >= length) { output.end(); return; } + const nextLength = Math.min(sparseStreamBytes, length - written); + const accepted = output.write(nextLength === zeroes.byteLength ? zeroes : zeroes.slice(0, nextLength)); + written += nextLength; + const continuePump = (): void => { if (!closed) setTimeout(pump, delayMs); }; + if (!accepted) output.once("drain", continuePump); + else continuePump(); + }; + pump(); + }); + }, + }; +} + export default defineConfig({ root: "app", plugins: [ serveEngineVariantModuleImports(), ...(unisolatedSingleThreadTest ? [] : [preserveIsolationHeaders()]), localVDBFixture(), + serveSparseVDBFixture(), react(), ], server: { diff --git a/web/engine/web_engine_native_reader_stub.cpp b/web/engine/web_engine_native_reader_stub.cpp index 36f3701e..2f7f3918 100644 --- a/web/engine/web_engine_native_reader_stub.cpp +++ b/web/engine/web_engine_native_reader_stub.cpp @@ -295,6 +295,12 @@ WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_font_advanced, const std::vector &, const std::vector &, int) +WEB_NATIVE_MAIN_STUB(web_engine_blend_main_import_vfont, + WebBlendMainState *, + const char *, + const char *, + const char *, + std::string &) WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_font_links, WebBlendMainState *, const char *, @@ -310,6 +316,9 @@ WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_metaball_elements, WEB_NATIVE_MAIN_STUB(web_engine_blend_main_grease_pencils_json, WebBlendMainState *, std::string &) +WEB_NATIVE_MAIN_STUB(web_engine_blend_main_geometry_node_graphs_json, + WebBlendMainState *, + std::string &) WEB_NATIVE_MAIN_STUB(web_engine_blend_main_physics_simulation_json, WebBlendMainState *, std::string &) @@ -326,6 +335,13 @@ WEB_NATIVE_MAIN_STUB(web_engine_blend_main_move_grease_pencil_layer, const char *, const char *, const char *) +WEB_NATIVE_MAIN_STUB(web_engine_blend_main_move_grease_pencil_frame, + WebBlendMainState *, + const char *, + const char *, + int, + int, + const char *) WEB_NATIVE_MAIN_STUB(web_engine_blend_main_insert_grease_pencil_frame, WebBlendMainState *, const char *, @@ -357,7 +373,10 @@ WEB_NATIVE_MAIN_STUB(web_engine_blend_main_set_vertex_weights, const std::vector &, const std::vector &, bool, - bool) + uint32_t, + bool, + int, + float) WEB_NATIVE_MAIN_STUB( web_engine_blend_main_set_render_properties, WebBlendMainState *, const char *, const char *) diff --git a/web/package-lock.json b/web/package-lock.json index a8df1ac7..7863d7c2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "react-dom": "19.2.8" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@types/react": "19.2.18", @@ -24,6 +25,18 @@ "vite": "8.2.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", @@ -813,6 +826,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", diff --git a/web/package.json b/web/package.json index 5ebc7dc9..bfa7d8f4 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,10 @@ "test:engine-variant-selection": "node --test tests/unit/engine-variant-selection.test.mjs", "test:engine-variant-fallback": "node --test tests/unit/engine-variant-fallback.test.mjs", "test:engine-upgrade-safety": "node --test --test-name-pattern=M6-08 tests/unit/engine-variant-selection.test.mjs tests/unit/engine-variant-fallback.test.mjs && playwright test --config playwright.config.ts tests/e2e/engine-upgrade-safety.spec.ts", - "test:user-actions": "node --test tests/unit/user-action-state.test.mjs tests/unit/project-action-mutex.test.mjs tests/unit/file-byte-reader.test.mjs tests/unit/save-transaction.test.mjs tests/unit/dirty-state.test.mjs && playwright test --config playwright.config.ts tests/e2e/user-action-state.spec.ts tests/e2e/project-action-mutex.spec.ts tests/e2e/file-import-progress.spec.ts tests/e2e/save-interruption.spec.ts tests/e2e/dirty-state.spec.ts", + "test:user-actions": "node --test tests/unit/user-action-state.test.mjs tests/unit/project-action-mutex.test.mjs tests/unit/file-byte-reader.test.mjs tests/unit/save-transaction.test.mjs tests/unit/dirty-state.test.mjs tests/unit/worker-fault.test.mjs tests/unit/recent-projects.test.mjs tests/unit/storage-budget.test.mjs tests/unit/ui-schema.test.mjs tests/unit/viewport-camera.test.mjs tests/unit/diagnostic-report.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/user-action-state.spec.ts tests/e2e/project-action-mutex.spec.ts tests/e2e/file-import-progress.spec.ts tests/e2e/save-interruption.spec.ts tests/e2e/dirty-state.spec.ts tests/e2e/worker-crash-recovery.spec.ts tests/e2e/recent-projects-recovery.spec.ts tests/e2e/storage-budget.spec.ts tests/e2e/storage-cleanup.spec.ts tests/e2e/ui-context.spec.ts tests/e2e/responsive-layout.spec.ts tests/e2e/viewport-consistency.spec.ts tests/e2e/keyboard-accessibility.spec.ts tests/e2e/diagnostic-report.spec.ts", + "test:editing-soak": "EDITING_SOAK_DURATION_MS=1800000 EDITING_SOAK_REPORT=../release/soak-reports/editing.json playwright test --config playwright.config.ts --workers=1 tests/e2e/editing-soak.spec.ts && node ../tools/web/check-editing-soak-report.mjs", + "test:editing-soak:debug": "EDITING_SOAK_DURATION_MS=30000 EDITING_SOAK_ALLOW_SHORT=1 playwright test --config playwright.config.ts --workers=1 tests/e2e/editing-soak.spec.ts", + "test:editing-soak-report": "node ../tools/web/check-editing-soak-report.mjs", "test:deployment-http": "node ../tools/web/check-deployment-http.mjs", "test:deployment-runbook": "node ../tools/web/check-deployment-runbook.mjs", "test:upgrade-runbook": "node ../tools/web/check-upgrade-runbook.mjs", @@ -32,6 +35,8 @@ "test:e2e": "playwright test --config playwright.config.ts", "test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"", "test:simulation-cache": "playwright test --config playwright.config.ts -g \"Simulation caches\"", + "test:simulation-cache-identity": "node --test tests/unit/simulation-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/simulation-cache-identity.spec.ts", + "test:simulation-cache-lifecycle": "node --test tests/unit/simulation-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/simulation-cache-lifecycle.spec.ts", "test:simulation-cache-performance": "playwright test --config playwright.config.ts tests/e2e/simulation-cache-performance.spec.ts", "test:network-interruption": "playwright test --config playwright.config.ts tests/e2e/network-interruption.spec.ts", "test:device-loss": "playwright test --config playwright.config.ts tests/e2e/device-loss.spec.ts", @@ -40,6 +45,19 @@ "test:texture-4k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-4k-performance.spec.ts", "test:texture-8k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-8k-performance.spec.ts", "test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs", + "test:geometry-node-main-reader": "node --test tests/unit/geometry-nodes.test.mjs && node ../tools/web/check-geometry-node-main-reader.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/geometry-node-main-reader.spec.ts", + "test:geometry-node-allowlist": "node --test tests/unit/geometry-nodes.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/geometry-node-allowlist.spec.ts", + "test:geometry-node-evaluator-golden": "node --test tests/unit/geometry-nodes.test.mjs && node ../tools/web/check-geometry-node-evaluator-golden.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/geometry-node-evaluator-golden.spec.ts", + "test:geometry-node-field-budget": "node --test tests/unit/geometry-nodes.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/geometry-node-field-budget.spec.ts", + "test:shader-compile": "node --test tests/unit/shader-compiler.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/shader-compile.spec.ts", + "test:shader-compile-key": "node --test tests/unit/shader-compiler.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/shader-compile-key.spec.ts", + "test:shader-pipeline": "playwright test --config playwright.config.ts --workers=1 tests/e2e/shader-pipeline.spec.ts", + "test:shader-capability": "playwright test --config playwright.config.ts --workers=1 tests/e2e/shader-capability-block.spec.ts", + "test:nla-evaluation-golden": "node ../tools/web/check-nla-evaluation-golden.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/nla-evaluation-golden.spec.ts", + "test:nla-operator": "node --test tests/unit/nla.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/nla-operator.spec.ts", + "test:physics-solver-probe": "node --test tests/unit/physics-solver-probe.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/physics-solver-probe.spec.ts", + "test:physics-cache-family": "node --test tests/unit/physics-cache-family.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/physics-cache-family.spec.ts", + "test:m10-domain-gates": "node --test tests/unit/geometry-nodes.test.mjs tests/unit/shader-compiler.test.mjs tests/unit/nla.test.mjs tests/unit/simulation-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/m10-domain-browser-gates.spec.ts", "test:browser": "playwright test --config playwright.release.config.ts", "test:cross-browser": "npm run test:browser", "test:golden": "node ../tools/web/run-blender-golden.mjs", @@ -62,13 +80,47 @@ "test:vdb-webgpu": "playwright test --config playwright.config.ts -g \"real NanoVDB Float32 tree with WebGPU\"", "test:vdb-viewport": "playwright test --config playwright.config.ts -g \"both production viewport backends\"", "test:vdb-faults": "playwright test --config playwright.config.ts -g \"NanoVDB paging from network, Worker and WebGPU device faults\"", + "test:nanovdb-page-feedback": "node --test tests/unit/nanovdb-page-feedback.test.mjs", + "test:nanovdb-page-feedback-webgpu": "playwright test --config playwright.config.ts tests/e2e/nanovdb-page-feedback.spec.ts", + "test:nanovdb-main-thread-webgpu": "playwright test --config playwright.config.ts tests/e2e/nanovdb-main-thread.spec.ts", + "test:nanovdb-offscreen-page-feedback-webgpu": "playwright test --config playwright.config.ts tests/e2e/nanovdb-offscreen-page-feedback.spec.ts", + "test:nanovdb-page-resume": "playwright test --config playwright.config.ts tests/e2e/nanovdb-page-resume.spec.ts", + "test:nanovdb-opfs-restart": "playwright test --config playwright.config.ts tests/e2e/nanovdb-opfs-restart.spec.ts", + "test:nanovdb-sparse-performance": "playwright test --config playwright.config.ts tests/e2e/nanovdb-sparse-performance.spec.ts", + "test:nanovdb-volume-roundtrip": "playwright test --config playwright.config.ts tests/e2e/nanovdb-volume-roundtrip.spec.ts", + "test:nanovdb-render-golden": "playwright test --config playwright.config.ts tests/e2e/nanovdb-render-golden.spec.ts", + "test:external-vfont": "playwright test --config playwright.config.ts tests/e2e/external-vfont.spec.ts", + "test:curve-topology-contract": "playwright test --config playwright.config.ts tests/e2e/curve-topology-contract.spec.ts", + "test:curve-topology-operator": "playwright test --config playwright.config.ts tests/e2e/curve-topology-contract.spec.ts tests/e2e/curve-topology-operator.spec.ts", + "test:grease-pencil-marquee": "node --test tests/unit/grease-pencil-marquee.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/grease-pencil-marquee.spec.ts", + "test:grease-pencil-selection": "node --test tests/unit/grease-pencil-selection.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/grease-pencil-selection.spec.ts", + "test:grease-pencil-reorder": "node --test tests/unit/grease-pencil-reorder.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/grease-pencil-reorder.spec.ts", + "test:paint-depth-visibility": "node --test tests/unit/paint-depth-visibility.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/paint-depth-visibility.spec.ts", + "test:paint-stroke-session": "node --test tests/unit/paint-stroke-session.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/paint-stroke-session.spec.ts", + "test:texture-paint-asset": "node --test tests/unit/texture-paint-asset.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/texture-paint-asset.spec.ts", + "test:paint-pbvh-capability": "node --test tests/unit/paint-pbvh-capability.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/paint-pbvh-capability.spec.ts", + "test:editing-domain-recovery": "node --test tests/unit/editing-domain-recovery.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/editing-domain-recovery.spec.ts", "test:grease-pencil": "node ../tools/web/check-grease-pencil-roundtrip.mjs", "test:grease-pencil-editor": "playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"", "test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs", + "test:weight-paint-golden": "node ../tools/web/check-weight-paint-golden.mjs", "test:lighting-roundtrip": "node ../tools/web/check-lighting-roundtrip.mjs", + "test:lighting-field-parity": "node ../tools/web/check-lighting-field-parity.mjs && npm run test:lighting-roundtrip", + "test:lighting-field-roundtrip": "npm run test:lighting-roundtrip && playwright test --config playwright.config.ts --workers=1 tests/e2e/lighting-field-roundtrip.spec.ts", + "test:render-resource-budget": "node --test tests/unit/render-budget.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/render-resource-budget.spec.ts", + "test:render-reference": "node --test tests/unit/render-image-comparison.test.mjs && node ../tools/web/check-render-reference.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/render-reference.spec.ts", + "test:render-routing": "node --test tests/unit/render-routing.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/render-routing.spec.ts", + "test:server-render-job": "node --test tests/unit/server-render-job.test.mjs && node ../tools/web/check-server-render-job.mjs", + "test:compositor-node-golden": "node --test tests/unit/compositor-webgpu.test.mjs && node ../tools/web/check-compositor-node-golden.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/compositor-node-golden.spec.ts", + "test:compositor-unsupported-gate": "node --test tests/unit/compositor-unsupported-gate.test.mjs && node ../tools/web/check-compositor-main-reader.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/compositor-unsupported-gate.spec.ts", "test:compositor-main-reader": "node ../tools/web/check-compositor-main-reader.mjs", "test:sequencer": "playwright test --config playwright.config.ts -g \"N-021 sequencer\"", "test:sequencer-main-reader": "node ../tools/web/check-sequencer-main-reader.mjs", + "test:sequencer-codec-probe": "node --test tests/unit/sequencer-codec-probe.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-codec-probe.spec.ts", + "test:sequencer-media-cache": "node --test tests/unit/sequencer-media-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-media-cache.spec.ts", + "test:sequencer-media-revision": "node --test tests/unit/sequencer-media-revision.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-media-revision.spec.ts", + "test:sequencer-final-export": "node --test tests/unit/sequencer-final-export.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-final-export.spec.ts", + "test:sequencer-audio-recovery": "node --test tests/unit/sequencer-audio-recovery.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/sequencer-audio-recovery.spec.ts", "test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"", "test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs", "test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"", @@ -128,6 +180,7 @@ "react-dom": "19.2.8" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@types/react": "19.2.18", diff --git a/web/protocol/capability-gates.ts b/web/protocol/capability-gates.ts index 00cbd435..ea6842ad 100644 --- a/web/protocol/capability-gates.ts +++ b/web/protocol/capability-gates.ts @@ -8,7 +8,7 @@ export interface CapabilityIssue { } export interface CapabilityGateResult { - taskId: "M6-02" | "N-011" | "N-012" | "N-013" | "N-014" | "N-015" | "N-018" | "N-020" | "N-021" | "N-022" | "N-023" | "N-024" | "N-025" | "N-026" | "PBR-007" | "PBR-008" | "PBR-009" | "PBR-010" | "PBR-011" | "PBR-012"; + taskId: "M6-02" | "N-011" | "N-012" | "N-013" | "N-014" | "N-015" | "N-017" | "N-018" | "N-020" | "N-021" | "N-022" | "N-023" | "N-024" | "N-025" | "N-026" | "PBR-007" | "PBR-008" | "PBR-009" | "PBR-010" | "PBR-011" | "PBR-012"; capability: string; status: "READY" | "BLOCKED"; issues: CapabilityIssue[]; diff --git a/web/protocol/compositor.ts b/web/protocol/compositor.ts index f67207ad..c31089f4 100644 --- a/web/protocol/compositor.ts +++ b/web/protocol/compositor.ts @@ -31,6 +31,27 @@ export const COMPOSITOR_NODE_TYPES = [ export type CompositorNodeType = typeof COMPOSITOR_NODE_TYPES[number]; +export const COMPOSITOR_WEBGPU_NODE_ALLOWLIST = [ + "CONSTANT_COLOR", + "EXPOSURE", + "INVERT", + "COMPOSITE", +] as const; +export type CompositorWebGPUNodeType = typeof COMPOSITOR_WEBGPU_NODE_ALLOWLIST[number]; + +export type CompositorWebGPUInstructionIR = + | { nodeId: string; type: "CONSTANT_COLOR"; color: readonly [number, number, number, number] } + | { nodeId: string; type: "EXPOSURE"; exposure: number } + | { nodeId: string; type: "INVERT" } + | { nodeId: string; type: "COMPOSITE" }; + +export interface CompositorWebGPUPlanIR { + schemaVersion: typeof COMPOSITOR_SCHEMA; + graphId: string; + outputNodeId: string; + instructions: CompositorWebGPUInstructionIR[]; +} + export interface CompositorResourceIR { id: string; kind: "IMAGE" | "RENDER_LAYER"; @@ -223,6 +244,19 @@ function validateNodeProperties(node: CompositorNodeIR, index: number): void { } } +function unsupportedCompositorNodeNames(graph: CompositorGraphIR): string[] { + return [...new Set(graph.nodes + .filter((node) => !SUPPORTED.has(node.type)) + .map((node) => node.blenderType ?? node.type))].sort(); +} + +function assertCompositorGraphExecutable(graph: CompositorGraphIR): void { + const unsupported = unsupportedCompositorNodeNames(graph); + if (unsupported.length > 0) { + throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `Compositor graph contains unsupported nodes: ${unsupported.join(", ")}`); + } +} + export function parseCompositorGraph(value: unknown): CompositorGraphIR { if (!record(value) || value.schemaVersion !== COMPOSITOR_SCHEMA || !Array.isArray(value.nodes) || !Array.isArray(value.links) || !Array.isArray(value.resources)) { @@ -282,6 +316,51 @@ export function parseCompositorGraph(value: unknown): CompositorGraphIR { return { schemaVersion: COMPOSITOR_SCHEMA, id: text(value.id, "id"), name: text(value.name, "name"), outputNodeId, nodes, links, resources }; } +/** Compiles only the node types with an independent CPU/WebGPU golden. */ +export function compileCompositorWebGPUPlan(value: unknown): CompositorWebGPUPlanIR { + const graph = parseCompositorGraph(value); + if (graph.resources.length !== 0) throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", "WebGPU compositor allowlist does not include resource inputs"); + const unsupported = graph.nodes.filter((node) => !COMPOSITOR_WEBGPU_NODE_ALLOWLIST.includes(node.type as CompositorWebGPUNodeType)); + if (unsupported.length > 0) { + const names = [...new Set(unsupported.map((node) => node.blenderType ?? node.type))].sort(); + throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `WebGPU compositor nodes are not allowlisted: ${names.join(", ")}`); + } + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const incoming = new Map(); + for (const link of graph.links) { + const links = incoming.get(link.toNodeId) ?? []; + links.push(link); + incoming.set(link.toNodeId, links); + } + const visited = new Set(); + const instructions: CompositorWebGPUInstructionIR[] = []; + const visit = (nodeId: string): void => { + if (visited.has(nodeId)) return; + const node = byId.get(nodeId); + if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing WebGPU compositor node ${nodeId}`); + const links = incoming.get(nodeId) ?? []; + if (node.type === "CONSTANT_COLOR") { + if (links.length !== 0) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId} constant input must be disconnected`); + const color = node.properties.color as number[]; + instructions.push({ nodeId, type: "CONSTANT_COLOR", color: [color[0], color[1], color[2], color[3]] }); + } + else { + if (links.length !== 1 || links[0].toSocket !== "Image") throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `${nodeId}.Image requires exactly one input`); + visit(links[0].fromNodeId); + if (node.type === "EXPOSURE") instructions.push({ nodeId, type: "EXPOSURE", exposure: Number(node.properties.exposure ?? 0) }); + else if (node.type === "INVERT") instructions.push({ nodeId, type: "INVERT" }); + else if (node.type === "COMPOSITE") instructions.push({ nodeId, type: "COMPOSITE" }); + else throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `${node.type} has no WebGPU golden`); + } + visited.add(nodeId); + }; + visit(graph.outputNodeId); + if (visited.size !== graph.nodes.length || graph.links.length !== graph.nodes.length - 1 || instructions[0]?.type !== "CONSTANT_COLOR") { + throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "WebGPU compositor schema 1 requires one connected constant-to-composite chain"); + } + return { schemaVersion: COMPOSITOR_SCHEMA, graphId: graph.id, outputNodeId: graph.outputNodeId, instructions }; +} + function validateImage(image: CompositorImageBuffer, name: string): CompositorImageBuffer { const pixels = image.width * image.height; if (!Number.isSafeInteger(image.width) || !Number.isSafeInteger(image.height) || image.width < 1 || image.height < 1 || @@ -307,7 +386,7 @@ function allocate(width: number, height: number): CompositorImageBuffer { export function gateCompositorGraph(value: unknown, availableResourceIds: ReadonlySet): CapabilityGateResult { try { const graph = parseCompositorGraph(value); - const unsupported = graph.nodes.filter((node) => !SUPPORTED.has(node.type)).map((node) => node.blenderType ?? node.type); + const unsupported = unsupportedCompositorNodeNames(graph); if (unsupported.length > 0) return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue("COMPOSITOR_NODE_UNSUPPORTED", `Unsupported compositor nodes: ${unsupported.join(", ")}`)]); const missing = graph.resources.filter((resource) => !availableResourceIds.has(resource.sourceId)); if (missing.length > 0) return blockedGate("N-020", "COMPOSITOR_GRAPH", [capabilityIssue("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resources: ${missing.map((resource) => resource.sourceId).join(", ")}`)]); @@ -325,6 +404,7 @@ export function executeCompositorGraph( options: { width?: number; height?: number; cancelled?: () => boolean } = {}, ): CompositorExecutionResult { const graph = parseCompositorGraph(value); + assertCompositorGraphExecutable(graph); const byId = new Map(graph.nodes.map((node) => [node.id, node])); const incoming = new Map(); graph.links.forEach((link) => incoming.set(`${link.toNodeId}:${link.toSocket}`, link)); @@ -479,6 +559,7 @@ export async function executeCompositorGraphCached( cache: CompositorFrameCache, options: { frame: number; width?: number; height?: number; cancelled?: () => boolean }, ): Promise { + assertCompositorGraphExecutable(parseCompositorGraph(value)); if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled"); const cacheKey = await compositorFrameCacheKey(value, sourceImages, options.frame, options.width, options.height); if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled"); diff --git a/web/protocol/curve-topology-editor.ts b/web/protocol/curve-topology-editor.ts new file mode 100644 index 00000000..3d503bda --- /dev/null +++ b/web/protocol/curve-topology-editor.ts @@ -0,0 +1,315 @@ +export const CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION = 1 as const; + +export const CURVE_TOPOLOGY_EDITOR_BUDGET = { + maxSplines: 65_536, + maxPoints: 1_000_000, + maxSelectedElements: 100_000, + maxAddedSplinesPerOperation: 4_096, + maxAddedPointsPerOperation: 100_000, + maxPayloadBytes: 64 * 1024 * 1024, + maxSubdivideCuts: 64, + maxDataIdBytes: 256, + maxOperationsPerMainTransaction: 1, +} as const; + +export type CurveTopologySelectionDomain = "NONE" | "POINTS" | "SPLINES" | "POINTS_OR_SPLINES"; + +export const CURVE_TOPOLOGY_EDITOR_OPERATORS = [ + { id: "ADD_SPLINE", blenderOperators: ["CURVE_OT_primitive_bezier_curve_add", "CURVE_OT_primitive_bezier_circle_add", "CURVE_OT_primitive_nurbs_curve_add", "CURVE_OT_primitive_nurbs_circle_add", "CURVE_OT_primitive_nurbs_path_add"], selection: "NONE", parameters: ["splineType", "cyclic"] }, + { id: "DECIMATE", blenderOperators: ["CURVE_OT_decimate"], selection: "SPLINES", parameters: ["ratio"] }, + { id: "DELETE", blenderOperators: ["CURVE_OT_delete"], selection: "POINTS_OR_SPLINES", parameters: ["mode"] }, + { id: "DISSOLVE_VERTICES", blenderOperators: ["CURVE_OT_dissolve_verts"], selection: "POINTS", parameters: [] }, + { id: "DUPLICATE", blenderOperators: ["CURVE_OT_duplicate"], selection: "POINTS_OR_SPLINES", parameters: [] }, + { id: "EXTRUDE", blenderOperators: ["CURVE_OT_extrude"], selection: "POINTS", parameters: ["position"] }, + { id: "MAKE_SEGMENT", blenderOperators: ["CURVE_OT_make_segment"], selection: "POINTS", parameters: [] }, + { id: "SEPARATE", blenderOperators: ["CURVE_OT_separate"], selection: "POINTS_OR_SPLINES", parameters: [] }, + { id: "SET_HANDLE_TYPE", blenderOperators: ["CURVE_OT_handle_type_set"], selection: "POINTS", parameters: ["handleType"] }, + { id: "SET_SPLINE_TYPE", blenderOperators: ["CURVE_OT_spline_type_set"], selection: "SPLINES", parameters: ["splineType"] }, + { id: "SPLIT", blenderOperators: ["CURVE_OT_split"], selection: "POINTS", parameters: [] }, + { id: "SUBDIVIDE", blenderOperators: ["CURVE_OT_subdivide"], selection: "POINTS_OR_SPLINES", parameters: ["cuts"] }, + { id: "SWITCH_DIRECTION", blenderOperators: ["CURVE_OT_switch_direction"], selection: "SPLINES", parameters: [] }, + { id: "TOGGLE_CYCLIC", blenderOperators: ["CURVE_OT_cyclic_toggle"], selection: "SPLINES", parameters: [] }, +] as const satisfies readonly { + id: string; + blenderOperators: readonly string[]; + selection: CurveTopologySelectionDomain; + parameters: readonly string[]; +}[]; + +export type CurveTopologyOperator = typeof CURVE_TOPOLOGY_EDITOR_OPERATORS[number]["id"]; + +// Keep this list limited to operators with an independent Main/undo/save/golden gate. +export const CURVE_TOPOLOGY_VERIFIED_OPERATORS = ["TOGGLE_CYCLIC"] as const satisfies readonly CurveTopologyOperator[]; + +export interface CurveTopologyOperationClaimIR { + schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION; + operator: CurveTopologyOperator; + dataId: string; + baseRevision: number; + inputSplineCount: number; + inputPointCount: number; + selectedSplineIndices: number[]; + selectedPointIndices: number[]; + addedSplineCount: number; + addedPointCount: number; + outputSplineCount: number; + outputPointCount: number; + payloadBytes: number; + subdivideCuts?: number; +} + +export interface CurveTopologyOperatorGateIR { + operator: CurveTopologyOperator; + status: "READY" | "BLOCKED"; + reasonCode?: "CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED"; +} + +export interface CurveTopologyEditorManifestIR { + schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION; + sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc"; + atomicMainTransaction: true; + budget: typeof CURVE_TOPOLOGY_EDITOR_BUDGET; + operators: Array<{ + id: CurveTopologyOperator; + blenderOperators: string[]; + selection: CurveTopologySelectionDomain; + parameters: string[]; + gate: CurveTopologyOperatorGateIR; + }>; +} + +export interface CurveToggleCyclicOperationInputIR { + schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION; + dataId: string; + baseRevision: number; + splineIndex: number; + splineCount: number; + pointCount: number; + cyclicU: boolean[]; +} + +export interface CurveToggleCyclicMainCommandIR { + type: "setCurveTopology"; + dataId: string; + baseRevision: number; + cyclicU: boolean[]; +} + +export interface CurveToggleCyclicOperationIR { + operator: "TOGGLE_CYCLIC"; + splineIndex: number; + previousCyclic: boolean; + nextCyclic: boolean; + claim: CurveTopologyOperationClaimIR; + command: CurveToggleCyclicMainCommandIR; +} + +export class CurveTopologyEditorValidationError extends Error { + constructor( + readonly code: "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED" | "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_PROPERTY_INVALID" | "REVISION_CONFLICT", + message: string, + ) { + super(`${code}: ${message}`); + this.name = "CurveTopologyEditorValidationError"; + } +} + +const OPERATOR_BY_ID = new Map( + CURVE_TOPOLOGY_EDITOR_OPERATORS.map((operator) => [operator.id, operator]), +); +const VERIFIED = new Set(CURVE_TOPOLOGY_VERIFIED_OPERATORS); +const CLAIM_FIELDS = new Set([ + "schemaVersion", "operator", "dataId", "baseRevision", "inputSplineCount", "inputPointCount", + "selectedSplineIndices", "selectedPointIndices", "addedSplineCount", "addedPointCount", + "outputSplineCount", "outputPointCount", "payloadBytes", "subdivideCuts", +]); + +function fail(code: CurveTopologyEditorValidationError["code"], message: string): never { + throw new CurveTopologyEditorValidationError(code, message); +} + +function integer(value: unknown, field: string, maximum: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) { + fail("NON_MESH_DATA_BUDGET_EXCEEDED", `${field} exceeds the frozen Curve topology budget`); + } + return value; +} + +function selection(value: unknown, field: string, inputCount: number): number[] { + if (!Array.isArray(value) || value.length > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSelectedElements) { + fail("NON_MESH_DATA_BUDGET_EXCEEDED", `${field} exceeds the frozen selection budget`); + } + if (inputCount === 0 && value.length !== 0) { + fail("NON_MESH_PROPERTY_INVALID", `${field} cannot select an empty input domain`); + } + const result = value.map((item) => integer(item, field, Math.max(0, inputCount - 1))); + if (result.some((item, index) => index > 0 && item <= result[index - 1])) { + fail("NON_MESH_PROPERTY_INVALID", `${field} must be strictly increasing and duplicate-free`); + } + return result; +} + +function assertSelectionDomain( + domain: CurveTopologySelectionDomain, + selectedSplines: readonly number[], + selectedPoints: readonly number[], +): void { + if (domain === "NONE" && (selectedSplines.length !== 0 || selectedPoints.length !== 0)) { + fail("NON_MESH_PROPERTY_INVALID", "operator does not accept a selection"); + } + if (domain === "POINTS" && (selectedPoints.length === 0 || selectedSplines.length !== 0)) { + fail("NON_MESH_PROPERTY_INVALID", "operator requires only a point selection"); + } + if (domain === "SPLINES" && (selectedSplines.length === 0 || selectedPoints.length !== 0)) { + fail("NON_MESH_PROPERTY_INVALID", "operator requires only a spline selection"); + } + if (domain === "POINTS_OR_SPLINES" && ((selectedPoints.length === 0) === (selectedSplines.length === 0))) { + fail("NON_MESH_PROPERTY_INVALID", "operator requires exactly one selection domain"); + } +} + +export function curveTopologyOperatorGate(operator: CurveTopologyOperator): CurveTopologyOperatorGateIR { + if (!OPERATOR_BY_ID.has(operator)) fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", `Curve operator ${String(operator)} is not allowlisted`); + return VERIFIED.has(operator) + ? { operator, status: "READY" } + : { operator, status: "BLOCKED", reasonCode: "CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED" }; +} + +export function createCurveTopologyEditorManifest(): CurveTopologyEditorManifestIR { + return { + schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION, + sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc", + atomicMainTransaction: true, + budget: { ...CURVE_TOPOLOGY_EDITOR_BUDGET }, + operators: CURVE_TOPOLOGY_EDITOR_OPERATORS.map((operator) => ({ + id: operator.id, + blenderOperators: [...operator.blenderOperators], + selection: operator.selection, + parameters: [...operator.parameters], + gate: curveTopologyOperatorGate(operator.id), + })), + }; +} + +export function parseCurveTopologyOperationClaim( + value: unknown, + expectedRevision?: number, +): CurveTopologyOperationClaimIR { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail("NON_MESH_PROPERTY_INVALID", "Curve topology operation claim must be an object"); + } + const claim = value as Record; + if (Object.keys(claim).some((field) => !CLAIM_FIELDS.has(field)) || + claim.schemaVersion !== CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION) { + fail("NON_MESH_PROPERTY_INVALID", "Curve topology operation claim schema is invalid"); + } + if (typeof claim.operator !== "string" || !OPERATOR_BY_ID.has(claim.operator as CurveTopologyOperator)) { + fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", `Curve operator ${String(claim.operator)} is not allowlisted`); + } + const operator = claim.operator as CurveTopologyOperator; + const descriptor = OPERATOR_BY_ID.get(operator)!; + if (typeof claim.dataId !== "string" || !claim.dataId.startsWith("curve:") || + new TextEncoder().encode(claim.dataId).byteLength > CURVE_TOPOLOGY_EDITOR_BUDGET.maxDataIdBytes) { + fail("NON_MESH_PROPERTY_INVALID", "Curve topology data ID is invalid"); + } + const baseRevision = integer(claim.baseRevision, "baseRevision", Number.MAX_SAFE_INTEGER); + if (expectedRevision !== undefined && baseRevision !== expectedRevision) { + fail("REVISION_CONFLICT", "Curve topology operation claim is stale"); + } + const inputSplineCount = integer(claim.inputSplineCount, "inputSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines); + const inputPointCount = integer(claim.inputPointCount, "inputPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints); + const selectedSplineIndices = selection(claim.selectedSplineIndices, "selectedSplineIndices", inputSplineCount); + const selectedPointIndices = selection(claim.selectedPointIndices, "selectedPointIndices", inputPointCount); + if (selectedSplineIndices.length + selectedPointIndices.length > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSelectedElements) { + fail("NON_MESH_DATA_BUDGET_EXCEEDED", "combined Curve topology selection exceeds the budget"); + } + assertSelectionDomain(descriptor.selection, selectedSplineIndices, selectedPointIndices); + const addedSplineCount = integer(claim.addedSplineCount, "addedSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxAddedSplinesPerOperation); + const addedPointCount = integer(claim.addedPointCount, "addedPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxAddedPointsPerOperation); + const outputSplineCount = integer(claim.outputSplineCount, "outputSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines); + const outputPointCount = integer(claim.outputPointCount, "outputPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints); + const payloadBytes = integer(claim.payloadBytes, "payloadBytes", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes); + if (outputSplineCount > inputSplineCount + addedSplineCount || outputPointCount > inputPointCount + addedPointCount) { + fail("NON_MESH_PROPERTY_INVALID", "Curve topology output contains undeclared additions"); + } + if (operator === "ADD_SPLINE" && + (addedSplineCount < 1 || addedPointCount < addedSplineCount * 2 || + outputSplineCount !== inputSplineCount + addedSplineCount || outputPointCount !== inputPointCount + addedPointCount)) { + fail("NON_MESH_PROPERTY_INVALID", "ADD_SPLINE must declare every added spline and point"); + } + let subdivideCuts: number | undefined; + if (operator === "SUBDIVIDE") { + subdivideCuts = integer(claim.subdivideCuts, "subdivideCuts", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSubdivideCuts); + if (subdivideCuts < 1) fail("NON_MESH_PROPERTY_INVALID", "SUBDIVIDE requires at least one cut"); + } + else if (claim.subdivideCuts !== undefined) { + fail("NON_MESH_PROPERTY_INVALID", "subdivideCuts is only valid for SUBDIVIDE"); + } + return { + schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION, + operator, + dataId: claim.dataId, + baseRevision, + inputSplineCount, + inputPointCount, + selectedSplineIndices, + selectedPointIndices, + addedSplineCount, + addedPointCount, + outputSplineCount, + outputPointCount, + payloadBytes, + ...(subdivideCuts === undefined ? {} : { subdivideCuts }), + }; +} + +export function buildCurveToggleCyclicOperation( + input: CurveToggleCyclicOperationInputIR, + expectedRevision: number, +): CurveToggleCyclicOperationIR { + const gate = curveTopologyOperatorGate("TOGGLE_CYCLIC"); + if (gate.status !== "READY") { + fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", "TOGGLE_CYCLIC has not passed its independent Main gate"); + } + if (input.schemaVersion !== CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION || + !Number.isSafeInteger(input.splineIndex) || input.splineIndex < 0 || input.splineIndex >= input.splineCount || + !Number.isSafeInteger(input.splineCount) || input.splineCount < 1 || input.splineCount > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines || + !Number.isSafeInteger(input.pointCount) || input.pointCount < 0 || input.pointCount > CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints || + !Array.isArray(input.cyclicU) || input.cyclicU.length !== input.splineCount || + input.cyclicU.some((value) => typeof value !== "boolean")) { + fail("NON_MESH_PROPERTY_INVALID", "TOGGLE_CYCLIC input does not describe one bounded Curve spline"); + } + const cyclicU = [...input.cyclicU]; + const previousCyclic = cyclicU[input.splineIndex]; + cyclicU[input.splineIndex] = !previousCyclic; + const command: CurveToggleCyclicMainCommandIR = { + type: "setCurveTopology", + dataId: input.dataId, + baseRevision: input.baseRevision, + cyclicU, + }; + const payloadBytes = new TextEncoder().encode(JSON.stringify(command)).byteLength; + const claim = parseCurveTopologyOperationClaim({ + schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION, + operator: "TOGGLE_CYCLIC", + dataId: input.dataId, + baseRevision: input.baseRevision, + inputSplineCount: input.splineCount, + inputPointCount: input.pointCount, + selectedSplineIndices: [input.splineIndex], + selectedPointIndices: [], + addedSplineCount: 0, + addedPointCount: 0, + outputSplineCount: input.splineCount, + outputPointCount: input.pointCount, + payloadBytes, + }, expectedRevision); + return { + operator: "TOGGLE_CYCLIC", + splineIndex: input.splineIndex, + previousCyclic, + nextCyclic: cyclicU[input.splineIndex], + claim, + command, + }; +} diff --git a/web/protocol/depsgraph.ts b/web/protocol/depsgraph.ts index 40541dbf..f401679e 100644 --- a/web/protocol/depsgraph.ts +++ b/web/protocol/depsgraph.ts @@ -1,3 +1,9 @@ +import { + GEOMETRY_NODE_FIELD_BUDGET, + parseGeometryNodeDomainCardinality, + type GeometryNodeDomainCardinalityIR, +} from "./geometry-nodes"; + export interface DepsgraphMeshEvaluationIR { objectId: string; meshId: string; @@ -9,6 +15,23 @@ export interface DepsgraphMeshEvaluationIR { worldMatrix: number[]; positions: number[]; indices: number[]; + domainCardinality?: GeometryNodeDomainCardinalityIR; + fieldMaterializations?: Array<{ + schemaVersion: 1; + fieldId: string; + domain: "POINT"; + dataType: "FLOAT"; + elementCount: number; + scalarValueCount: number; + materializedByteLength: number; + transport: "JSON" | "BINARY_REQUIRED"; + errorCode?: "GN_FIELD_JSON_BUDGET_EXCEEDED"; + }>; + attributes?: Record; } export interface DepsgraphModifierEvaluationIR { @@ -25,7 +48,8 @@ export interface DepsgraphModifierEvaluationIR { status: "EVALUATED" | "DISABLED" | "BLOCKED"; reason?: string; error?: string; - errorCode?: "UNSUPPORTED_MODIFIER_TYPE" | "MODIFIER_TARGET_MISSING" | "BLENDER_MODIFIER_ERROR"; + errorCode?: "UNSUPPORTED_MODIFIER_TYPE" | "MODIFIER_TARGET_MISSING" | "BLENDER_MODIFIER_ERROR" | + "GEOMETRY_NODES_SIMULATION_UNAVAILABLE" | "GEOMETRY_NODES_EVALUATOR_UNSUPPORTED"; suggestion?: string; targetObjectIds?: string[]; dependsOn?: string[]; @@ -125,6 +149,61 @@ function countField(record: Record, field: string): number { return value; } +function domainCardinality(value: unknown): GeometryNodeDomainCardinalityIR { + return parseGeometryNodeDomainCardinality(value, "meshes[].domainCardinality"); +} + +function fieldMaterializations( + value: unknown, + cardinality: GeometryNodeDomainCardinalityIR, +): NonNullable { + if (!Array.isArray(value) || value.length > 64) throw new Error("Depsgraph mesh fieldMaterializations are invalid"); + const fieldIds = new Set(); + return value.map((candidate) => { + if (!isRecord(candidate) || + Object.keys(candidate).some((key) => ![ + "schemaVersion", "fieldId", "domain", "dataType", "elementCount", + "scalarValueCount", "materializedByteLength", "transport", "errorCode", + ].includes(key)) || + candidate.schemaVersion !== 1 || candidate.domain !== "POINT" || candidate.dataType !== "FLOAT" || + (candidate.transport !== "JSON" && candidate.transport !== "BINARY_REQUIRED")) { + throw new Error("Depsgraph mesh field materialization is invalid"); + } + const fieldId = stringField(candidate, "fieldId"); + if (new TextEncoder().encode(fieldId).byteLength > GEOMETRY_NODE_FIELD_BUDGET.maxIdentifierBytes || + fieldIds.has(fieldId)) { + throw new Error("Depsgraph mesh field materialization identity is invalid"); + } + fieldIds.add(fieldId); + const elementCount = countField(candidate, "elementCount"); + const scalarValueCount = countField(candidate, "scalarValueCount"); + const materializedByteLength = countField(candidate, "materializedByteLength"); + if (elementCount !== cardinality.POINT || scalarValueCount !== elementCount || + materializedByteLength !== scalarValueCount * Float32Array.BYTES_PER_ELEMENT) { + throw new Error("Depsgraph mesh field materialization counts are inconsistent"); + } + const errorCode = candidate.errorCode; + if ((candidate.transport === "JSON" && errorCode !== undefined) || + (candidate.transport === "JSON" && scalarValueCount > GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField) || + (candidate.transport === "BINARY_REQUIRED" && + (errorCode !== "GN_FIELD_JSON_BUDGET_EXCEEDED" || + scalarValueCount <= GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField))) { + throw new Error("Depsgraph mesh field materialization error is inconsistent"); + } + return { + schemaVersion: 1 as const, + fieldId, + domain: "POINT" as const, + dataType: "FLOAT" as const, + elementCount, + scalarValueCount, + materializedByteLength, + transport: candidate.transport, + ...(errorCode === undefined ? {} : { errorCode: errorCode as "GN_FIELD_JSON_BUDGET_EXCEEDED" }), + }; + }); +} + function booleanField(record: Record, field: string): boolean { const value = record[field]; if (typeof value !== "boolean") throw new Error(`Depsgraph field ${field} is invalid`); @@ -194,7 +273,13 @@ function modifierReports(record: Record): DepsgraphModifierEval const suggestion = candidate.suggestion; if (reason !== undefined && typeof reason !== "string") throw new Error("Depsgraph modifier reason is invalid"); if (error !== undefined && typeof error !== "string") throw new Error("Depsgraph modifier error is invalid"); - if (errorCode !== undefined && !["UNSUPPORTED_MODIFIER_TYPE", "MODIFIER_TARGET_MISSING", "BLENDER_MODIFIER_ERROR"].includes(errorCode as string)) { + if (errorCode !== undefined && ![ + "UNSUPPORTED_MODIFIER_TYPE", + "MODIFIER_TARGET_MISSING", + "BLENDER_MODIFIER_ERROR", + "GEOMETRY_NODES_SIMULATION_UNAVAILABLE", + "GEOMETRY_NODES_EVALUATOR_UNSUPPORTED", + ].includes(errorCode as string)) { throw new Error("Depsgraph modifier errorCode is invalid"); } if (suggestion !== undefined && typeof suggestion !== "string") throw new Error("Depsgraph modifier suggestion is invalid"); @@ -258,6 +343,48 @@ export function parseDepsgraphEvaluation(value: unknown): DepsgraphEvaluationIR if (indices.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= vertexCount)) { throw new Error(`Depsgraph mesh ${stringField(candidate, "sourceMeshId")} has an invalid index`); } + const attributesValue = candidate.attributes; + const domainCardinalityValue = candidate.domainCardinality; + const parsedDomainCardinality = domainCardinalityValue === undefined ? undefined : domainCardinality(domainCardinalityValue); + const fieldMaterializationsValue = candidate.fieldMaterializations; + if (parsedDomainCardinality !== undefined && parsedDomainCardinality.POINT !== vertexCount) { + throw new Error("Depsgraph mesh POINT cardinality is inconsistent"); + } + const parsedFieldMaterializations = fieldMaterializationsValue === undefined ? undefined : (() => { + if (parsedDomainCardinality === undefined) { + throw new Error("Depsgraph mesh field materializations require domain cardinality"); + } + return fieldMaterializations(fieldMaterializationsValue, parsedDomainCardinality); + })(); + let attributes: DepsgraphMeshEvaluationIR["attributes"]; + if (attributesValue !== undefined) { + if (!isRecord(attributesValue) || Object.keys(attributesValue).length > 64) { + throw new Error("Depsgraph mesh attributes are invalid"); + } + attributes = {}; + for (const [name, attributeValue] of Object.entries(attributesValue)) { + if (name.length === 0 || name.length > 64 || !isRecord(attributeValue) || + Object.keys(attributeValue).some((key) => !["domain", "dataType", "values"].includes(key)) || + attributeValue.domain !== "POINT" || attributeValue.dataType !== "FLOAT") { + throw new Error(`Depsgraph mesh attribute ${name} is invalid`); + } + const values = numberArray(attributeValue, "values"); + if (values.length !== vertexCount || parsedDomainCardinality === undefined || + parsedDomainCardinality.POINT !== values.length) { + throw new Error(`Depsgraph mesh attribute ${name} length is inconsistent`); + } + const receipt = parsedFieldMaterializations?.find((entry) => entry.fieldId === `attribute:${name}`); + if (receipt === undefined || receipt.transport !== "JSON" || + receipt.materializedByteLength !== values.length * Float32Array.BYTES_PER_ELEMENT) { + throw new Error(`Depsgraph mesh attribute ${name} has no matching field materialization`); + } + attributes[name] = { domain: "POINT", dataType: "FLOAT", values }; + } + } + if (parsedFieldMaterializations?.some((entry) => entry.transport === "JSON" && + (attributes === undefined || !Object.hasOwn(attributes, entry.fieldId.replace(/^attribute:/, ""))))) { + throw new Error("Depsgraph mesh JSON field materialization has no matching attribute payload"); + } return { objectId: stringField(candidate, "objectId"), meshId: stringField(candidate, "meshId"), @@ -269,6 +396,9 @@ export function parseDepsgraphEvaluation(value: unknown): DepsgraphEvaluationIR worldMatrix, positions, indices, + ...(parsedDomainCardinality === undefined ? {} : { domainCardinality: parsedDomainCardinality }), + ...(parsedFieldMaterializations === undefined ? {} : { fieldMaterializations: parsedFieldMaterializations }), + ...(attributes === undefined ? {} : { attributes }), }; }); const objectCount = countField(value, "objectCount"); diff --git a/web/protocol/diagnostic-report.ts b/web/protocol/diagnostic-report.ts new file mode 100644 index 00000000..446da22b --- /dev/null +++ b/web/protocol/diagnostic-report.ts @@ -0,0 +1,160 @@ +export const APP_DIAGNOSTIC_SCHEMA_VERSION = 1 as const; +export const MAX_APP_DIAGNOSTIC_ENTRIES = 200; + +export const APP_DIAGNOSTIC_MESSAGES = { + VIEWPORT_INIT_FAILED: "Viewport: unavailable", + ACTION_CONFLICT: "Action: another project operation is running", + ACTION_LOCK_FAILED: "Action: retry required", + RECENT_PROJECT_LIST_FAILED: "Storage: recent projects unavailable", + RECENT_PROJECT_REPAIR_FAILED: "Storage: recent project repair failed", + RECENT_PROJECT_UPDATE_FAILED: "Storage: recent project update failed", + STORAGE_BUDGET_FAILED: "Storage: budget unavailable", + STORAGE_CLEANUP_FAILED: "Storage: cleanup failed", + OPERATION_LOG_FAILED: "Storage: operation log unavailable", + COMMAND_FAILED: "Engine: command failed", + IMAGE_IMPORT_FAILED: "Image import failed", + GREASE_PENCIL_EDIT_FAILED: "Grease Pencil edit failed", + CURVE_EDIT_FAILED: "Curve edit failed", + LOD_CACHE_READ_FAILED: "Storage: LOD cache unavailable; generated geometry retained", + LOD_GENERATION_FAILED: "Engine: LOD generation failed", + ENGINE_WORKER_TERMINATED: "Engine: Worker stopped; project remains available", + ENGINE_START_FAILED: "Engine: unavailable", + MANIFEST_REJECTED: "Manifest: rejected", + STORAGE_WORKER_TERMINATED: "Storage: Worker stopped; project remains available", + STORAGE_START_FAILED: "Storage: unavailable", + PBR_ASSET_INVALID: "PBR asset unavailable", + BLEND_OPEN_FAILED: "Engine: .blend open failed", + POST_COMMIT_MAINTENANCE_FAILED: "Storage: post-commit maintenance failed", + PROJECT_RECOVERY_FAILED: "Recovery: project could not be restored", + WORKER_RECOVERY_FAILED: "Recovery: Worker restart failed", + BLEND_SAVE_FAILED: "Engine: .blend save failed", + BLEND_DOWNLOAD_FAILED: "Engine: .blend download failed", + GLB_PROJECT_UNAVAILABLE: "GLB: no open project", + GLB_EXPORT_BLOCKED: "GLB: export blocked", + GLB_EXPORT_FAILED: "GLB: export failed", + AUTOSAVE_FAILED: "Engine: autosave failed", +} as const; + +export type AppDiagnosticCode = keyof typeof APP_DIAGNOSTIC_MESSAGES; +export type AppDiagnosticArea = "ACTION" | "ENGINE" | "STORAGE" | "VIEWPORT" | "EXPORT" | "RUNTIME"; +export type AppDiagnosticContextValue = string | number | boolean | null; + +export interface AppDiagnosticEntry { + schemaVersion: typeof APP_DIAGNOSTIC_SCHEMA_VERSION; + sequence: number; + occurredAt: string; + area: AppDiagnosticArea; + code: AppDiagnosticCode; + summary: string; + detail: string; + sourceCode?: string; + stack?: string; + cause?: string; + context?: Record; +} + +export interface AppDiagnosticReport { + schemaVersion: typeof APP_DIAGNOSTIC_SCHEMA_VERSION; + product: "Web Blender Modeler V1"; + generatedAt: string; + runtime: { + url: string; + userAgent: string; + language: string; + crossOriginIsolated: boolean; + }; + project: { + projectId: string; + revision: number; + }; + entries: AppDiagnosticEntry[]; +} + +function objectString(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) return undefined; + try { + const seen = new WeakSet(); + return JSON.stringify(value, (_key, candidate: unknown) => { + if (candidate instanceof Error) { + return { name: candidate.name, message: candidate.message, stack: candidate.stack, cause: candidate.cause }; + } + if (typeof candidate === "object" && candidate !== null) { + if (seen.has(candidate)) return "[Circular]"; + seen.add(candidate); + } + return candidate; + }); + } + catch { + return "[Unserializable diagnostic detail]"; + } +} + +function propertyString(value: unknown, property: string): string | undefined { + if (typeof value !== "object" || value === null || !(property in value)) return undefined; + const candidate = (value as Record)[property]; + return typeof candidate === "string" && candidate ? candidate : undefined; +} + +function diagnosticDetail(error: unknown): Pick { + if (error instanceof Error) { + return { + detail: error.message || error.name, + sourceCode: propertyString(error, "code"), + stack: error.stack, + cause: error.cause instanceof Error ? error.cause.message : typeof error.cause === "string" ? error.cause : objectString(error.cause), + }; + } + if (typeof error === "string") return { detail: error }; + const message = propertyString(error, "message"); + const extraDetail = propertyString(error, "detail"); + return { + detail: [message, extraDetail].filter(Boolean).join("; ") || objectString(error) || String(error), + sourceCode: propertyString(error, "code"), + stack: propertyString(error, "stack"), + cause: propertyString(error, "cause"), + }; +} + +export function createAppDiagnosticEntry(input: { + sequence: number; + occurredAt: string; + area: AppDiagnosticArea; + code: AppDiagnosticCode; + error: unknown; + context?: Record; +}): AppDiagnosticEntry { + if (!Number.isSafeInteger(input.sequence) || input.sequence <= 0) throw new Error("APP_DIAGNOSTIC_SEQUENCE_INVALID"); + if (!Number.isFinite(Date.parse(input.occurredAt)) || new Date(input.occurredAt).toISOString() !== input.occurredAt) throw new Error("APP_DIAGNOSTIC_TIMESTAMP_INVALID"); + const detail = diagnosticDetail(input.error); + return { + schemaVersion: APP_DIAGNOSTIC_SCHEMA_VERSION, + sequence: input.sequence, + occurredAt: input.occurredAt, + area: input.area, + code: input.code, + summary: APP_DIAGNOSTIC_MESSAGES[input.code], + detail: detail.detail, + ...(detail.sourceCode ? { sourceCode: detail.sourceCode } : {}), + ...(detail.stack ? { stack: detail.stack } : {}), + ...(detail.cause ? { cause: detail.cause } : {}), + ...(input.context ? { context: { ...input.context } } : {}), + }; +} + +export function appendAppDiagnostic(entries: readonly AppDiagnosticEntry[], entry: AppDiagnosticEntry, limit = MAX_APP_DIAGNOSTIC_ENTRIES): AppDiagnosticEntry[] { + if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("APP_DIAGNOSTIC_LIMIT_INVALID"); + return [...entries, entry].slice(-limit); +} + +export function createAppDiagnosticReport(input: Omit & { entries: readonly AppDiagnosticEntry[] }): AppDiagnosticReport { + if (!Number.isFinite(Date.parse(input.generatedAt)) || new Date(input.generatedAt).toISOString() !== input.generatedAt) throw new Error("APP_DIAGNOSTIC_REPORT_TIMESTAMP_INVALID"); + return { + schemaVersion: APP_DIAGNOSTIC_SCHEMA_VERSION, + product: "Web Blender Modeler V1", + generatedAt: input.generatedAt, + runtime: { ...input.runtime }, + project: { ...input.project }, + entries: [...input.entries].sort((left, right) => left.sequence - right.sequence), + }; +} diff --git a/web/protocol/editing-domain-recovery.ts b/web/protocol/editing-domain-recovery.ts new file mode 100644 index 00000000..99174ca2 --- /dev/null +++ b/web/protocol/editing-domain-recovery.ts @@ -0,0 +1,197 @@ +import type { SceneSnapshotIR } from "./scene-ir"; + +export const EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION = 1 as const; +export const EDITING_DOMAINS = ["CURVE", "GREASE_PENCIL", "PAINT"] as const; +export type EditingDomain = typeof EDITING_DOMAINS[number]; + +const SHA256 = /^[a-f0-9]{64}$/; +const DOMAIN_PREFIX: Record = { + CURVE: "", + GREASE_PENCIL: "grease-pencil:", + PAINT: "mesh:", +}; + +export interface EditingDomainIdentityIR { + objectIds: string[]; + dataIds: string[]; + objectCount: number; +} + +export interface EditingDomainRecoveryEvidenceIR { + schemaVersion: typeof EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION; + domain: EditingDomain; + baseline: EditingDomainIdentityIR & { revision: number; identityHash: string }; + workerRestart: { + status: "RECOVERED"; + workerGeneration: number; + revisionBefore: number; + revisionAfter: number; + hashBefore: string; + hashAfter: string; + liveHandles: number; + temporaryResourcesAfter: 0; + }; + oom: { + status: "RECOVERED"; + faultPoint: "GPU_GEOMETRY_UPLOAD"; + code: "GPU_GEOMETRY_BUDGET_EXCEEDED"; + revisionBefore: number; + revisionAfter: number; + hashBefore: string; + hashAfter: string; + releasedBytes: number; + temporaryResourcesAfter: 0; + }; + gpuRelease: { + status: "RECOVERED"; + backend: "WEBGL2"; + releaseCount: number; + reinitCount: number; + disposedResources: number; + visiblePixels: number; + pixelHashBefore: string; + pixelHashAfter: string; + }; + smallScene: { + status: "RECOVERED"; + revision: number; + identityHash: string; + objectCount: number; + dataIds: string[]; + visiblePixels: number; + }; +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`); + return value as Record; +} + +function exact(value: Record, fields: readonly string[], label: string): void { + const allowed = new Set(fields); + if (Object.keys(value).some((field) => !allowed.has(field))) throw new Error(`EDITING_RECOVERY_INVALID: ${label} contains undeclared fields`); +} + +function integer(value: unknown, label: string, minimum = 0): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`); + return value as number; +} + +function digest(value: unknown, label: string): string { + if (typeof value !== "string" || !SHA256.test(value)) throw new Error(`EDITING_RECOVERY_INVALID: ${label}`); + return value; +} + +function ids(value: unknown, label: string, prefix?: string): string[] { + if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0 || (prefix !== undefined && !item.startsWith(prefix)))) { + throw new Error(`EDITING_RECOVERY_INVALID: ${label}`); + } + const result = [...new Set(value as string[])].sort(); + if (result.length !== value.length) throw new Error(`EDITING_RECOVERY_INVALID: ${label} contains duplicates`); + return result; +} + +function parseIdentity(value: unknown, label: string, domain: EditingDomain): EditingDomainIdentityIR & { revision?: number; identityHash?: string } { + const source = record(value, label); + exact(source, ["objectIds", "dataIds", "objectCount", "revision", "identityHash"], label); + const objectIds = ids(source.objectIds, `${label}.objectIds`); + const dataIds = ids(source.dataIds, `${label}.dataIds`, DOMAIN_PREFIX[domain] || undefined); + const objectCount = integer(source.objectCount, `${label}.objectCount`, 1); + if (objectCount !== objectIds.length) throw new Error(`EDITING_RECOVERY_INVALID: ${label}.objectCount does not match objectIds`); + if (source.revision !== undefined) integer(source.revision, `${label}.revision`); + if (source.identityHash !== undefined) digest(source.identityHash, `${label}.identityHash`); + return { + objectIds, + dataIds, + objectCount, + ...(source.revision === undefined ? {} : { revision: source.revision as number }), + ...(source.identityHash === undefined ? {} : { identityHash: source.identityHash as string }), + }; +} + +function parseHashPair(value: unknown, label: string, preserveRevision: boolean): { revisionBefore: number; revisionAfter: number; hashBefore: string; hashAfter: string } { + const source = record(value, label); + const revisionBefore = integer(source.revisionBefore, `${label}.revisionBefore`); + const revisionAfter = integer(source.revisionAfter, `${label}.revisionAfter`); + const hashBefore = digest(source.hashBefore, `${label}.hashBefore`); + const hashAfter = digest(source.hashAfter, `${label}.hashAfter`); + if (hashBefore !== hashAfter || (preserveRevision && revisionBefore !== revisionAfter)) throw new Error(`EDITING_RECOVERY_INVALID: ${label} did not preserve the committed identity`); + return { revisionBefore, revisionAfter, hashBefore, hashAfter }; +} + +export function parseEditingDomainRecoveryEvidence(value: unknown): EditingDomainRecoveryEvidenceIR { + const source = record(value, "evidence must be an object"); + exact(source, ["schemaVersion", "domain", "baseline", "workerRestart", "oom", "gpuRelease", "smallScene"], "evidence"); + if (source.schemaVersion !== EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION || !EDITING_DOMAINS.includes(source.domain as EditingDomain)) { + throw new Error("EDITING_RECOVERY_INVALID: schemaVersion or domain"); + } + const domain = source.domain as EditingDomain; + const baseline = parseIdentity(source.baseline, "baseline", domain); + if (baseline.revision === undefined || baseline.identityHash === undefined) throw new Error("EDITING_RECOVERY_INVALID: baseline identity is incomplete"); + + const worker = record(source.workerRestart, "workerRestart"); + exact(worker, ["status", "workerGeneration", "revisionBefore", "revisionAfter", "hashBefore", "hashAfter", "liveHandles", "temporaryResourcesAfter"], "workerRestart"); + const workerPair = parseHashPair(worker, "workerRestart", false); + const workerGeneration = integer(worker.workerGeneration, "workerRestart.workerGeneration", 1); + const liveHandles = integer(worker.liveHandles, "workerRestart.liveHandles", 1); + if (worker.temporaryResourcesAfter !== 0) throw new Error("EDITING_RECOVERY_INVALID: workerRestart.temporaryResourcesAfter"); + + const oom = record(source.oom, "oom"); + exact(oom, ["status", "faultPoint", "code", "revisionBefore", "revisionAfter", "hashBefore", "hashAfter", "releasedBytes", "temporaryResourcesAfter"], "oom"); + const oomPair = parseHashPair(oom, "oom", true); + if (oom.faultPoint !== "GPU_GEOMETRY_UPLOAD" || oom.code !== "GPU_GEOMETRY_BUDGET_EXCEEDED" || integer(oom.releasedBytes, "oom.releasedBytes", 1) < 1 || oom.temporaryResourcesAfter !== 0) { + throw new Error("EDITING_RECOVERY_INVALID: oom fault mapping or cleanup"); + } + + const gpu = record(source.gpuRelease, "gpuRelease"); + exact(gpu, ["status", "backend", "releaseCount", "reinitCount", "disposedResources", "visiblePixels", "pixelHashBefore", "pixelHashAfter"], "gpuRelease"); + if (gpu.status !== "RECOVERED" || gpu.backend !== "WEBGL2") throw new Error("EDITING_RECOVERY_INVALID: gpuRelease status"); + const releaseCount = integer(gpu.releaseCount, "gpuRelease.releaseCount", 1); + const reinitCount = integer(gpu.reinitCount, "gpuRelease.reinitCount", 1); + const disposedResources = integer(gpu.disposedResources, "gpuRelease.disposedResources", 1); + const visiblePixels = integer(gpu.visiblePixels, "gpuRelease.visiblePixels", 1); + const pixelHashBefore = digest(gpu.pixelHashBefore, "gpuRelease.pixelHashBefore"); + const pixelHashAfter = digest(gpu.pixelHashAfter, "gpuRelease.pixelHashAfter"); + if (releaseCount !== 1 || reinitCount !== 1) throw new Error("EDITING_RECOVERY_INVALID: gpuRelease must release and reinitialize exactly once"); + + const small = record(source.smallScene, "smallScene"); + exact(small, ["status", "revision", "identityHash", "objectCount", "dataIds", "visiblePixels"], "smallScene"); + if (small.status !== "RECOVERED") throw new Error("EDITING_RECOVERY_INVALID: smallScene.status"); + const smallRevision = integer(small.revision, "smallScene.revision"); + const smallIdentityHash = digest(small.identityHash, "smallScene.identityHash"); + const smallObjectCount = integer(small.objectCount, "smallScene.objectCount", 1); + const smallDataIds = ids(small.dataIds, "smallScene.dataIds", DOMAIN_PREFIX[domain] || undefined); + const smallVisiblePixels = integer(small.visiblePixels, "smallScene.visiblePixels", 1); + if (smallIdentityHash !== baseline.identityHash || smallRevision !== baseline.revision || smallObjectCount !== baseline.objectCount || smallDataIds.join("\0") !== baseline.dataIds.join("\0")) { + throw new Error("EDITING_RECOVERY_INVALID: smallScene identity does not match the committed baseline"); + } + + return { + schemaVersion: EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION, + domain, + baseline: { ...baseline, revision: baseline.revision, identityHash: baseline.identityHash }, + workerRestart: { status: "RECOVERED", ...workerPair, workerGeneration, liveHandles, temporaryResourcesAfter: 0 }, + oom: { status: "RECOVERED", faultPoint: "GPU_GEOMETRY_UPLOAD", code: "GPU_GEOMETRY_BUDGET_EXCEEDED", ...oomPair, releasedBytes: oom.releasedBytes as number, temporaryResourcesAfter: 0 }, + gpuRelease: { status: "RECOVERED", backend: "WEBGL2", releaseCount, reinitCount, disposedResources, visiblePixels, pixelHashBefore, pixelHashAfter }, + smallScene: { status: "RECOVERED", revision: smallRevision, identityHash: smallIdentityHash, objectCount: smallObjectCount, dataIds: smallDataIds, visiblePixels: smallVisiblePixels }, + }; +} + +export function parseEditingDomainRecoverySuite(value: unknown): EditingDomainRecoveryEvidenceIR[] { + if (!Array.isArray(value) || value.length !== EDITING_DOMAINS.length) throw new Error("EDITING_RECOVERY_INVALID: suite must contain all editing domains"); + const reports = value.map(parseEditingDomainRecoveryEvidence); + if (new Set(reports.map((report) => report.domain)).size !== EDITING_DOMAINS.length) throw new Error("EDITING_RECOVERY_INVALID: duplicate editing domain"); + return EDITING_DOMAINS.map((domain) => reports.find((report) => report.domain === domain)!); +} + +export function summarizeEditingDomain(snapshot: SceneSnapshotIR, domain: EditingDomain): EditingDomainIdentityIR { + const nodes = snapshot.nodes.filter((node) => { + if (domain === "CURVE") return node.visible && (node.type === "CURVE" || node.type === "SURFACE") && node.dataId !== null; + if (domain === "GREASE_PENCIL") return node.visible && node.type === "GREASE_PENCIL" && node.dataId !== null; + return node.visible && node.type === "MESH" && node.dataId !== null; + }); + const objectIds = [...new Set(nodes.map((node) => node.id))].sort(); + const dataIds = [...new Set(nodes.flatMap((node) => node.dataId ? [node.dataId] : []))].sort(); + if (objectIds.length === 0 || dataIds.length === 0) throw new Error(`EDITING_RECOVERY_DOMAIN_MISSING: ${domain}`); + return { objectIds, dataIds, objectCount: objectIds.length }; +} diff --git a/web/protocol/error.ts b/web/protocol/error.ts index 4f6775dc..ba53acbf 100644 --- a/web/protocol/error.ts +++ b/web/protocol/error.ts @@ -20,6 +20,10 @@ export type ErrorCode = | "SCULPT_STROKE_BUDGET_EXCEEDED" | "SCULPT_ATTRIBUTE_INVALID" | "GN_INVALID_GRAPH" + | "GN_GRAPH_BUDGET_EXCEEDED" + | "GN_FIELD_BUDGET_EXCEEDED" + | "GN_FIELD_JSON_BUDGET_EXCEEDED" + | "GN_DOMAIN_CARDINALITY_MISMATCH" | "GN_NODE_UNSUPPORTED" | "GN_SOCKET_TYPE_MISMATCH" | "GN_GROUP_RECURSION" @@ -29,6 +33,10 @@ export type ErrorCode = | "SIMULATION_CACHE_INVALID" | "SIMULATION_CACHE_MISSING" | "SIMULATION_CACHE_HASH_MISMATCH" + | "SIMULATION_CACHE_REVISION_MISMATCH" + | "SIMULATION_CACHE_NOT_READY" + | "SIMULATION_CACHE_CANCELLED" + | "SIMULATION_CACHE_BUDGET_EXCEEDED" | "SHADER_NODE_UNSUPPORTED" | "SHADER_INVALID_GRAPH" | "SHADER_GRAPH_CYCLE" @@ -37,6 +45,8 @@ export type ErrorCode = | "GPU_TEXTURE_INVALID" | "GPU_TEXTURE_HASH_MISMATCH" | "GPU_TEXTURE_BUDGET_EXCEEDED" + | "GPU_LIGHT_BUDGET_EXCEEDED" + | "GPU_SHADOW_BUDGET_EXCEEDED" | "GPU_TEXTURE_DECODE_FAILED" | "GPU_GEOMETRY_BUDGET_EXCEEDED" | "UDIM_MANIFEST_INVALID" @@ -49,6 +59,7 @@ export type ErrorCode = | "WEBGPU_RENDERER_UNAVAILABLE" | "POSTPROCESS_PASS_UNAVAILABLE" | "NLA_INVALID_STACK" + | "NLA_BUDGET_EXCEEDED" | "NLA_ACTION_MISSING" | "NLA_PATH_INCOMPATIBLE" | "NLA_TIME_WARP_UNSUPPORTED" @@ -68,6 +79,9 @@ export type ErrorCode = | "NANOVDB_STREAM_INCOMPLETE" | "NANOVDB_GRID_UNSUPPORTED" | "NANOVDB_GPU_BUDGET_EXCEEDED" + | "NANOVDB_PAGE_FEEDBACK_OVERFLOW" + | "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" + | "NANOVDB_GOLDEN_MISMATCH" | "NON_MESH_DATA_SHARED" | "NON_MESH_PROPERTY_INVALID" | "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED" @@ -77,16 +91,41 @@ export type ErrorCode = | "SELECTION_UNDO_UNAVAILABLE" | "GREASE_PENCIL_SCHEMA_INVALID" | "GREASE_PENCIL_BUDGET_EXCEEDED" + | "GREASE_PENCIL_SELECTION_INVALID" + | "GREASE_PENCIL_SELECTION_SCOPE_INVALID" | "PAINT_SCHEMA_INVALID" | "PAINT_BUDGET_EXCEEDED" + | "PAINT_TILE_HASH_MISMATCH" + | "PAINT_PBVH_UNAVAILABLE" + | "PAINT_PBVH_CONTEXT_UNAVAILABLE" + | "PAINT_PBVH_BRUSH_UNVERIFIED" | "PHYSICS_MANIFEST_INVALID" | "PHYSICS_BUDGET_EXCEEDED" | "PHYSICS_DEPENDENCY_CYCLE" | "PHYSICS_CACHE_FRAME_MISMATCH" + | "PHYSICS_CACHE_SOURCE_MISMATCH" + | "PHYSICS_CACHE_HASH_MISMATCH" | "PHYSICS_CACHE_PLAYBACK_UNAVAILABLE" | "PHYSICS_SOLVER_UNAVAILABLE" | "PHYSICS_SERVER_UNAVAILABLE" | "RENDER_PROPERTY_INVALID" + | "RENDER_REFERENCE_MISMATCH" + | "SERVER_RENDER_REQUEST_INVALID" + | "SERVER_RENDER_SOURCE_INVALID" + | "SERVER_RENDER_SOURCE_HASH_MISMATCH" + | "SERVER_RENDER_BUILD_INVALID" + | "SERVER_RENDER_BUILD_MISMATCH" + | "SERVER_RENDER_SETTINGS_INVALID" + | "SERVER_RENDER_SETTINGS_HASH_MISMATCH" + | "SERVER_RENDER_HASH_INVALID" + | "SERVER_RENDER_REQUEST_HASH_MISMATCH" + | "SERVER_RENDER_BINDING_MISMATCH" + | "SERVER_RENDER_OUTPUT_INVALID" + | "SERVER_RENDER_OUTPUT_HASH_MISMATCH" + | "SERVER_RENDER_RESULT_INVALID" + | "SERVER_RENDER_RESULT_HASH_MISMATCH" + | "SERVER_RENDER_FAILED" + | "SERVER_RENDER_CANCELLED" | "COMPOSITOR_GRAPH_INVALID" | "COMPOSITOR_GRAPH_CYCLE" | "COMPOSITOR_BUDGET_EXCEEDED" @@ -100,6 +139,16 @@ export type ErrorCode = | "SEQUENCER_RESOURCE_OUTSIDE_PROJECT" | "SEQUENCER_CODEC_UNSUPPORTED" | "SEQUENCER_CANCELLED" + | "SEQUENCER_CACHE_SOURCE_MISMATCH" + | "SEQUENCER_CACHE_CAPABILITY_MISMATCH" + | "SEQUENCER_CACHE_IDENTITY_MISMATCH" + | "SEQUENCER_CACHE_HASH_MISMATCH" + | "SEQUENCER_EXPORT_REQUEST_INVALID" + | "SEQUENCER_EXPORT_SERVER_UNAVAILABLE" + | "SEQUENCER_AUDIO_CONTEXT_INVALID" + | "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE" + | "SEQUENCER_AUDIO_RESUME_FAILED" + | "SEQUENCER_AUDIO_SUSPEND_FAILED" | "TRACKING_SCHEMA_INVALID" | "TRACKING_BUDGET_EXCEEDED" | "TRACKING_RESOURCE_OUTSIDE_PROJECT" diff --git a/web/protocol/external-vfont.ts b/web/protocol/external-vfont.ts new file mode 100644 index 00000000..e4d89362 --- /dev/null +++ b/web/protocol/external-vfont.ts @@ -0,0 +1,210 @@ +import type { ErrorCode } from "./error"; +import { normalizeProjectAssetPath } from "./asset-path"; +import type { StorageAssetPutResult, StorageAssetReadResult } from "./storage"; + +export const EXTERNAL_VFONT_SCHEMA_VERSION = 1 as const; +export const EXTERNAL_VFONT_MAX_BYTES = 32 * 1024 * 1024; + +export type ExternalVFontFormat = "TTF" | "OTF" | "PFB"; + +export interface ExternalVFontImportRequestIR { + sourcePath: string; + mimeType: string; + byteLength: number; + sha256: string; + data: ArrayBuffer; +} + +export interface ValidatedExternalVFontIR { + schemaVersion: typeof EXTERNAL_VFONT_SCHEMA_VERSION; + sourcePath: string; + fileName: string; + format: ExternalVFontFormat; + mimeType: string; + byteLength: number; + sha256: string; + data: ArrayBuffer; +} + +export interface ExternalVFontMainImportProofIR { + schemaVersion: typeof EXTERNAL_VFONT_SCHEMA_VERSION; + projectId: string; + assetId: string; + assetPath: string; + sourcePath: string; + name: string; + format: ExternalVFontFormat; + mimeType: string; + byteLength: number; + sha256: string; +} + +export interface ExternalVFontMainImportIR extends ExternalVFontMainImportProofIR { + data: ArrayBuffer; +} + +export class ExternalVFontValidationError extends Error { + constructor(readonly code: ErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "ExternalVFontValidationError"; + } +} + +const SHA256 = /^[0-9a-f]{64}$/; +const PROJECT_ID = /^[A-Za-z0-9_-]{1,64}$/; +const FORMAT = { + ".ttf": { format: "TTF", mimeTypes: new Set(["font/ttf", "application/x-font-ttf", "application/font-sfnt"]) }, + ".otf": { format: "OTF", mimeTypes: new Set(["font/otf", "application/vnd.ms-opentype", "application/font-sfnt"]) }, + ".pfb": { format: "PFB", mimeTypes: new Set(["application/x-font-type1", "application/x-font-pfb"]) }, +} as const satisfies Record }>; + +function fail(code: ErrorCode, message: string): never { + throw new ExternalVFontValidationError(code, message); +} + +function classify(sourcePath: string, mimeType: string, data: ArrayBuffer): { format: ExternalVFontFormat; mimeType: string } { + const extension = sourcePath.slice(sourcePath.lastIndexOf(".")).toLowerCase() as keyof typeof FORMAT; + const declaration = FORMAT[extension]; + if (!declaration || !declaration.mimeTypes.has(mimeType as never)) { + fail("NON_MESH_BINARY_INVALID", "font extension and MIME type must agree on TTF, OTF or PFB"); + } + const bytes = new Uint8Array(data); + const sfnt = bytes.byteLength >= 4 && bytes[0] === 0x00 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00; + const otto = bytes.byteLength >= 4 && bytes[0] === 0x4f && bytes[1] === 0x54 && bytes[2] === 0x54 && bytes[3] === 0x4f; + const pfb = bytes.byteLength >= 6 && bytes[0] === 0x80 && bytes[1] === 0x01; + if ( + (declaration.format === "TTF" && !sfnt) || + (declaration.format === "OTF" && !otto) || + (declaration.format === "PFB" && !pfb) + ) { + fail("NON_MESH_BINARY_INVALID", `font bytes do not match the declared ${declaration.format} format`); + } + return { format: declaration.format, mimeType }; +} + +async function sha256(data: ArrayBuffer): Promise { + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data))).map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function mainName(fileName: string, digest: string): string { + const candidate = fileName.replace(/\.[^.]+$/, ""); + return candidate && new TextEncoder().encode(candidate).byteLength <= 63 + ? candidate + : `ExternalFont-${digest.slice(0, 12)}`; +} + +export function validateExternalVFontMainImportProof(value: ExternalVFontMainImportProofIR): ExternalVFontMainImportProofIR { + if (!value || typeof value !== "object" || value.schemaVersion !== EXTERNAL_VFONT_SCHEMA_VERSION) { + fail("NON_MESH_BINARY_INVALID", "external VFont Main import proof schema is invalid"); + } + if (!PROJECT_ID.test(value.projectId) || !SHA256.test(value.sha256)) { + fail("NON_MESH_BINARY_INVALID", "external VFont project or hash identity is invalid"); + } + if (value.assetId !== `sha256:${value.sha256}` || + value.assetPath !== `projects/${value.projectId}/assets/sha256/${value.sha256.slice(0, 2)}/${value.sha256}`) { + fail("NON_MESH_RESOURCE_MISSING", "external VFont must reference its verified OPFS content-addressed asset"); + } + let normalized: string; + try { + normalized = normalizeProjectAssetPath(value.sourcePath); + } + catch { + fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "external VFont Main source path is invalid"); + } + if (!normalized.startsWith("fonts/") || value.sourcePath !== `//${normalized}` || + typeof value.name !== "string" || value.name.length === 0 || new TextEncoder().encode(value.name).byteLength > 63 || + !Number.isSafeInteger(value.byteLength) || value.byteLength < 1 || value.byteLength > EXTERNAL_VFONT_MAX_BYTES) { + fail("NON_MESH_BINARY_INVALID", "external VFont Main metadata is invalid"); + } + const declared = FORMAT[normalized.slice(normalized.lastIndexOf(".")).toLowerCase() as keyof typeof FORMAT]; + if (!declared || declared.format !== value.format || !declared.mimeTypes.has(value.mimeType as never)) { + fail("NON_MESH_BINARY_INVALID", "external VFont Main format and MIME type do not agree"); + } + return { ...value }; +} + +export function createExternalVFontMainImport( + validated: ValidatedExternalVFontIR, + stored: StorageAssetPutResult, +): ExternalVFontMainImportIR { + const normalized = validated.sourcePath.slice(2); + if (!stored.persisted || stored.projectId.length === 0 || stored.assetId !== `sha256:${validated.sha256}` || + stored.sha256 !== validated.sha256 || stored.bytes !== validated.byteLength || stored.mimeType !== validated.mimeType || + stored.sourcePath !== normalized) { + fail("ASSET_SOURCE_HASH_MISMATCH", "stored external VFont receipt does not match the validated font"); + } + const proof = validateExternalVFontMainImportProof({ + schemaVersion: EXTERNAL_VFONT_SCHEMA_VERSION, + projectId: stored.projectId, + assetId: stored.assetId, + assetPath: stored.path, + sourcePath: validated.sourcePath, + name: mainName(validated.fileName, validated.sha256), + format: validated.format, + mimeType: validated.mimeType, + byteLength: validated.byteLength, + sha256: validated.sha256, + }); + return { ...proof, data: validated.data.slice(0) }; +} + +export async function validateStoredExternalVFontAsset( + projectId: string, + declaredSha256: string, + stored: StorageAssetReadResult, +): Promise { + if (!PROJECT_ID.test(projectId) || !SHA256.test(declaredSha256) || !stored || + typeof stored !== "object" || !(stored.data instanceof ArrayBuffer)) { + fail("NON_MESH_RESOURCE_MISSING", "stored external VFont asset identity is invalid"); + } + const asset = stored.asset; + const expectedPath = `projects/${projectId}/assets/sha256/${declaredSha256.slice(0, 2)}/${declaredSha256}`; + if (!asset || asset.projectId !== projectId || asset.assetId !== `sha256:${declaredSha256}` || + asset.sha256 !== declaredSha256 || asset.path !== expectedPath || typeof asset.sourcePath !== "string") { + fail("NON_MESH_RESOURCE_MISSING", "stored external VFont asset is not the declared project asset"); + } + if (asset.bytes !== stored.data.byteLength) { + fail("ASSET_SOURCE_HASH_MISMATCH", "stored external VFont byte length does not match its metadata"); + } + return validateExternalVFontImport({ + sourcePath: `//${asset.sourcePath}`, + mimeType: asset.mimeType, + byteLength: asset.bytes, + sha256: asset.sha256, + data: stored.data, + }); +} + +export async function validateExternalVFontImport(request: ExternalVFontImportRequestIR): Promise { + if (!request || typeof request !== "object") fail("NON_MESH_BINARY_INVALID", "font import request is missing"); + let normalized: string; + try { + normalized = normalizeProjectAssetPath(request.sourcePath); + } + catch { + fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "font source must be a project-relative path without traversal or URI syntax"); + } + if (!normalized.startsWith("fonts/") || new TextEncoder().encode(normalized).byteLength > 1021) { + fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "font source must be a bounded path under the project fonts directory"); + } + if (!(request.data instanceof ArrayBuffer)) fail("NON_MESH_BINARY_INVALID", "font payload must be an ArrayBuffer"); + if (!Number.isSafeInteger(request.byteLength) || request.byteLength < 1 || request.byteLength > EXTERNAL_VFONT_MAX_BYTES) { + fail("NON_MESH_DATA_BUDGET_EXCEEDED", "font payload exceeds the 32 MiB import budget"); + } + if (request.data.byteLength !== request.byteLength) fail("NON_MESH_BINARY_INVALID", "font declared byte length does not match its payload"); + if (typeof request.mimeType !== "string" || request.mimeType.length > 128) fail("NON_MESH_BINARY_INVALID", "font MIME type is invalid"); + const classified = classify(normalized, request.mimeType.toLowerCase(), request.data); + if (typeof request.sha256 !== "string" || !SHA256.test(request.sha256)) fail("ASSET_SOURCE_HASH_MISMATCH", "font SHA-256 declaration is invalid"); + const actualSha256 = await sha256(request.data); + if (actualSha256 !== request.sha256) fail("ASSET_SOURCE_HASH_MISMATCH", "font bytes do not match the declared SHA-256"); + return { + schemaVersion: EXTERNAL_VFONT_SCHEMA_VERSION, + sourcePath: `//${normalized}`, + fileName: normalized.slice(normalized.lastIndexOf("/") + 1), + format: classified.format, + mimeType: classified.mimeType, + byteLength: request.byteLength, + sha256: actualSha256, + data: request.data.slice(0), + }; +} diff --git a/web/protocol/geometry-nodes.ts b/web/protocol/geometry-nodes.ts index 36a17323..f4b30ade 100644 --- a/web/protocol/geometry-nodes.ts +++ b/web/protocol/geometry-nodes.ts @@ -2,10 +2,77 @@ import type { ErrorCode } from "./error"; import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; export const GEOMETRY_NODE_GRAPH_SCHEMA = 1 as const; -export type GeometryNodeDataType = "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "STRING" | "GEOMETRY" | "INSTANCE" | "OBJECT" | "IMAGE"; -export type GeometryNodeDomain = "POINT" | "EDGE" | "FACE" | "CORNER" | "INSTANCE"; +export const GEOMETRY_NODE_GRAPH_BUDGET = Object.freeze({ + maxGraphs: 4_096, + maxNodesPerGraph: 4_096, + maxLinksPerGraph: 16_384, + maxSocketsPerGraph: 65_536, + maxInterfaceSocketsPerGraph: 4_096, + maxIdentifierBytes: 256, + maxNameBytes: 1_024, +}); +export type GeometryNodeDataType = + | "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "INT_VECTOR" | "COLOR" | "STRING" + | "GEOMETRY" | "INSTANCE" | "OBJECT" | "IMAGE" | "COLLECTION" | "TEXTURE" + | "MATERIAL" | "ROTATION" | "MENU" | "MATRIX" | "SHADER" | "BUNDLE" | "CLOSURE" + | "FONT" | "SCENE" | "TEXT" | "MASK" | "SOUND" | "CUSTOM"; +export type GeometryNodeDomain = "POINT" | "EDGE" | "FACE" | "CORNER" | "CURVE" | "INSTANCE" | "LAYER"; export type GeometryNodeSocketDirection = "INPUT" | "OUTPUT"; +export const GEOMETRY_NODE_FIELD_SCHEMA = 1 as const; +export const GEOMETRY_NODE_FIELD_DOMAIN_BUDGET = Object.freeze({ + POINT: 1_000_000, + EDGE: 2_000_000, + FACE: 2_000_000, + CORNER: 4_000_000, + CURVE: 100_000, + INSTANCE: 100_000, + LAYER: 4_096, +} satisfies Record); +export const GEOMETRY_NODE_FIELD_BUDGET = Object.freeze({ + maxFieldsPerBatch: 64, + maxDomainConversionsPerBatch: 32, + maxMaterializedElementsPerBatch: 4_000_000, + maxMaterializedBytesPerBatch: 64 * 1024 * 1024, + maxJsonScalarValuesPerField: 65_536, + maxIdentifierBytes: 256, +}); + +export type GeometryNodeFieldDataType = "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "COLOR"; +export type GeometryNodeFieldSourceDomain = GeometryNodeDomain | "CONSTANT"; +export type GeometryNodeFieldTransport = "JSON" | "BINARY"; +export type GeometryNodeDomainCardinalityIR = Record; + +export interface GeometryNodeFieldMaterializationIR { + schemaVersion: typeof GEOMETRY_NODE_FIELD_SCHEMA; + graphId: string; + graphHash: string; + fieldId: string; + revision: number; + sourceDomain: GeometryNodeFieldSourceDomain; + targetDomain: GeometryNodeDomain; + dataType: GeometryNodeFieldDataType; + transport: GeometryNodeFieldTransport; + domainCardinality: GeometryNodeDomainCardinalityIR; +} + +export interface GeometryNodeFieldMaterializationReceiptIR extends GeometryNodeFieldMaterializationIR { + sourceElementCount: number; + targetElementCount: number; + scalarValueCount: number; + materializedByteLength: number; + domainConversion: boolean; +} + +export interface GeometryNodeFieldMaterializationBatchIR { + schemaVersion: typeof GEOMETRY_NODE_FIELD_SCHEMA; + fields: GeometryNodeFieldMaterializationReceiptIR[]; + fieldCount: number; + domainConversionCount: number; + materializedElementCount: number; + materializedByteLength: number; +} + export interface GeometryNodeSocketIR { id: string; name: string; @@ -75,7 +142,8 @@ export class GeometryNodeGraphError extends Error { } } -const supportedNodeTypes = new Set([ +export const GEOMETRY_NODE_ALLOWLIST_SCHEMA = 1 as const; +export const GEOMETRY_NODE_ALLOWLIST = Object.freeze([ "NodeGroupInput", "NodeGroupOutput", "GeometryNodeTransform", @@ -85,14 +153,15 @@ const supportedNodeTypes = new Set([ "GeometryNodeRealizeInstances", "GeometryNodeStoreNamedAttribute", "FunctionNodeInputInt", - "FunctionNodeInputFloat", "FunctionNodeInputVector", "FunctionNodeCompare", + "ShaderNodeValue", "ShaderNodeMath", "GeometryNodeObjectInfo", "GeometryNodeCollectionInfo", "GeometryNodeImageInfo", -]); +] as const); +const supportedNodeTypes = new Set(GEOMETRY_NODE_ALLOWLIST); const externalResourceNodeTypes = new Set([ "GeometryNodeObjectInfo", @@ -109,35 +178,262 @@ function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +const geometryNodeDomains = Object.freeze([ + "POINT", "EDGE", "FACE", "CORNER", "CURVE", "INSTANCE", "LAYER", +] as const); +const geometryNodeDomainSet = new Set(geometryNodeDomains); +const fieldLayout: Readonly> = { + BOOLEAN: { components: 1, bytesPerComponent: 1 }, + INT: { components: 1, bytesPerComponent: 4 }, + FLOAT: { components: 1, bytesPerComponent: 4 }, + VECTOR: { components: 3, bytesPerComponent: 4 }, + COLOR: { components: 4, bytesPerComponent: 4 }, +}; + +function exactKeys(value: Record, allowed: readonly string[], path: string): void { + const allowedSet = new Set(allowed); + const unexpected = Object.keys(value).filter((key) => !allowedSet.has(key)); + if (unexpected.length > 0) { + const code: ErrorCode = unexpected.some((key) => key === "values" || key === "jsonValues") ? + "GN_FIELD_JSON_BUDGET_EXCEEDED" : "GN_INVALID_GRAPH"; + throw new GeometryNodeGraphError(code, `${path} contains undeclared fields: ${unexpected.join(", ")}`, path); + } +} + +function safeProduct(values: readonly number[], path: string): number { + let result = 1; + for (const value of values) { + if (!Number.isSafeInteger(value) || value < 0 || (value !== 0 && result > Number.MAX_SAFE_INTEGER / value)) { + throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path} overflows its numeric budget`, path); + } + result *= value; + } + return result; +} + +export function parseGeometryNodeDomainCardinality( + value: unknown, + path = "domainCardinality", +): GeometryNodeDomainCardinalityIR { + if (!record(value)) { + throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path} must declare every domain`, path); + } + const unexpected = Object.keys(value).filter((domain) => !geometryNodeDomainSet.has(domain)); + if (unexpected.length > 0) { + throw new GeometryNodeGraphError( + "GN_DOMAIN_CARDINALITY_MISMATCH", + `${path} contains undeclared domains: ${unexpected.join(", ")}`, + path, + ); + } + const result = {} as GeometryNodeDomainCardinalityIR; + for (const domain of geometryNodeDomains) { + const count = value[domain]; + if (!Number.isSafeInteger(count) || (count as number) < 0) { + throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.${domain} is not a non-negative integer`, `${path}.${domain}`); + } + if ((count as number) > GEOMETRY_NODE_FIELD_DOMAIN_BUDGET[domain]) { + throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path}.${domain} exceeds ${GEOMETRY_NODE_FIELD_DOMAIN_BUDGET[domain]}`, `${path}.${domain}`); + } + result[domain] = count as number; + } + return result; +} + +export function parseGeometryNodeFieldMaterialization( + value: unknown, + path = "field", +): GeometryNodeFieldMaterializationReceiptIR { + if (!record(value) || value.schemaVersion !== GEOMETRY_NODE_FIELD_SCHEMA) { + throw new GeometryNodeGraphError("PROTOCOL_MISMATCH", "Unsupported Geometry Node field materialization schema", path); + } + exactKeys(value, [ + "schemaVersion", "graphId", "graphHash", "fieldId", "revision", "sourceDomain", + "targetDomain", "dataType", "transport", "domainCardinality", + ], path); + boundedText(value.graphId, `${path}.graphId`, GEOMETRY_NODE_FIELD_BUDGET.maxIdentifierBytes); + boundedText(value.fieldId, `${path}.fieldId`, GEOMETRY_NODE_FIELD_BUDGET.maxIdentifierBytes); + if (typeof value.graphHash !== "string" || !/^[0-9a-f]{64}$/.test(value.graphHash)) { + throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.graphHash must be a lowercase SHA-256`, `${path}.graphHash`); + } + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) { + throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.revision is invalid`, `${path}.revision`); + } + if (value.sourceDomain !== "CONSTANT" && !geometryNodeDomainSet.has(value.sourceDomain as string)) { + throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.sourceDomain is invalid`, `${path}.sourceDomain`); + } + if (!geometryNodeDomainSet.has(value.targetDomain as string)) { + throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.targetDomain is invalid`, `${path}.targetDomain`); + } + if (!Object.hasOwn(fieldLayout, value.dataType as PropertyKey)) { + throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.dataType is not materializable`, `${path}.dataType`); + } + if (value.transport !== "JSON" && value.transport !== "BINARY") { + throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.transport is invalid`, `${path}.transport`); + } + const domainCardinality = parseGeometryNodeDomainCardinality(value.domainCardinality, `${path}.domainCardinality`); + const sourceDomain = value.sourceDomain as GeometryNodeFieldSourceDomain; + const targetDomain = value.targetDomain as GeometryNodeDomain; + const dataType = value.dataType as GeometryNodeFieldDataType; + const layout = fieldLayout[dataType]; + const sourceElementCount = sourceDomain === "CONSTANT" ? 1 : domainCardinality[sourceDomain]; + const targetElementCount = domainCardinality[targetDomain]; + const scalarValueCount = safeProduct([targetElementCount, layout.components], `${path}.scalarValueCount`); + const materializedByteLength = safeProduct([scalarValueCount, layout.bytesPerComponent], `${path}.materializedByteLength`); + if (materializedByteLength > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedBytesPerBatch) { + throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path} exceeds the field byte budget`, path); + } + if (value.transport === "JSON" && scalarValueCount > GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField) { + throw new GeometryNodeGraphError("GN_FIELD_JSON_BUDGET_EXCEEDED", `${path} must use binary transport above ${GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField} scalar values`, `${path}.transport`); + } + return { + schemaVersion: GEOMETRY_NODE_FIELD_SCHEMA, + graphId: value.graphId as string, + graphHash: value.graphHash, + fieldId: value.fieldId as string, + revision: value.revision as number, + sourceDomain, + targetDomain, + dataType, + transport: value.transport, + domainCardinality, + sourceElementCount, + targetElementCount, + scalarValueCount, + materializedByteLength, + domainConversion: sourceDomain !== "CONSTANT" && sourceDomain !== targetDomain, + }; +} + +export function parseGeometryNodeFieldMaterializationBatch( + values: unknown, +): GeometryNodeFieldMaterializationBatchIR { + if (!Array.isArray(values)) { + throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "Geometry Node field batch must be an array", "fields"); + } + if (values.length > GEOMETRY_NODE_FIELD_BUDGET.maxFieldsPerBatch) { + throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", "Geometry Node field batch exceeds 64 fields", "fields"); + } + const fields = values.map((value, index) => parseGeometryNodeFieldMaterialization(value, `fields[${index}]`)); + const identities = new Set(); + let domainConversionCount = 0; + let materializedElementCount = 0; + let materializedByteLength = 0; + for (const [index, field] of fields.entries()) { + const identity = `${field.graphId}:${field.fieldId}`; + if (identities.has(identity)) { + throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate field materialization: ${identity}`, `fields[${index}].fieldId`); + } + identities.add(identity); + domainConversionCount += field.domainConversion ? 1 : 0; + materializedElementCount += field.targetElementCount; + materializedByteLength += field.materializedByteLength; + } + if (domainConversionCount > GEOMETRY_NODE_FIELD_BUDGET.maxDomainConversionsPerBatch || + materializedElementCount > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedElementsPerBatch || + materializedByteLength > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedBytesPerBatch) + { + throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", "Geometry Node field batch exceeds its aggregate materialization budget", "fields"); + } + return { + schemaVersion: GEOMETRY_NODE_FIELD_SCHEMA, + fields, + fieldCount: fields.length, + domainConversionCount, + materializedElementCount, + materializedByteLength, + }; +} + +const geometryNodeDataTypes = new Set([ + "BOOLEAN", "INT", "FLOAT", "VECTOR", "INT_VECTOR", "COLOR", "STRING", "GEOMETRY", + "INSTANCE", "OBJECT", "IMAGE", "COLLECTION", "TEXTURE", "MATERIAL", "ROTATION", + "MENU", "MATRIX", "SHADER", "BUNDLE", "CLOSURE", "FONT", "SCENE", "TEXT", "MASK", + "SOUND", "CUSTOM", +]); + +function boundedText(value: unknown, path: string, maximum: number, allowEmpty = false): value is string { + if (typeof value !== "string" || (!allowEmpty && value.length === 0) || new TextEncoder().encode(value).byteLength > maximum) { + throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is outside the string budget`, path); + } + return true; +} + +function boundedLiteral(value: unknown, path: string): boolean { + if (typeof value === "boolean") return true; + if (typeof value === "number") { + if (Number.isFinite(value)) return true; + throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not finite`, path); + } + if (typeof value === "string") { + boundedText(value, path, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true); + return true; + } + if (Array.isArray(value) && value.length <= 16 && value.every((item) => typeof item === "number" && Number.isFinite(item))) return true; + throw new GeometryNodeGraphError("GN_FIELD_JSON_BUDGET_EXCEEDED", `${path} is outside the bounded literal array budget`, path); +} + function validSocket(value: unknown, path: string): value is GeometryNodeSocketIR { - if (!record(value) || typeof value.id !== "string" || typeof value.name !== "string" || !["INPUT", "OUTPUT"].includes(value.direction as string) || !["BOOLEAN", "INT", "FLOAT", "VECTOR", "STRING", "GEOMETRY", "INSTANCE", "OBJECT", "IMAGE"].includes(value.dataType as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not a valid socket`, path); - if (value.domain !== undefined && !["POINT", "EDGE", "FACE", "CORNER", "INSTANCE"].includes(value.domain as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.domain is invalid`, `${path}.domain`); - if (value.defaultValue !== undefined && !(typeof value.defaultValue === "boolean" || typeof value.defaultValue === "number" || typeof value.defaultValue === "string" || (Array.isArray(value.defaultValue) && value.defaultValue.every((item) => typeof item === "number" && Number.isFinite(item))))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.defaultValue is invalid`, `${path}.defaultValue`); + if (!record(value) || !["INPUT", "OUTPUT"].includes(value.direction as string) || !geometryNodeDataTypes.has(value.dataType as GeometryNodeDataType)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not a valid socket`, path); + boundedText(value.id, `${path}.id`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes); + boundedText(value.name, `${path}.name`, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true); + if (value.domain !== undefined && !["POINT", "EDGE", "FACE", "CORNER", "CURVE", "INSTANCE", "LAYER"].includes(value.domain as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.domain is invalid`, `${path}.domain`); + if (value.defaultValue !== undefined) boundedLiteral(value.defaultValue, `${path}.defaultValue`); return true; } export function parseGeometryNodeGraph(value: unknown): GeometryNodeGraphIR { if (!record(value) || value.schemaVersion !== GEOMETRY_NODE_GRAPH_SCHEMA) throw new GeometryNodeGraphError("PROTOCOL_MISMATCH", "Unsupported GeometryNodeGraph schema"); - for (const field of ["id", "name"] as const) if (typeof value[field] !== "string" || value[field].length === 0) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${field} is required`, field); + boundedText(value.id, "id", GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes); + boundedText(value.name, "name", GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes); if (!Array.isArray(value.interfaceInputs) || !Array.isArray(value.interfaceOutputs) || !Array.isArray(value.nodes) || !Array.isArray(value.links)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "Graph arrays are required"); + if (value.interfaceInputs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxInterfaceSocketsPerGraph || value.interfaceOutputs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxInterfaceSocketsPerGraph || value.nodes.length > GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph || value.links.length > GEOMETRY_NODE_GRAPH_BUDGET.maxLinksPerGraph) { + throw new GeometryNodeGraphError("GN_GRAPH_BUDGET_EXCEEDED", "Geometry Node graph exceeds its topology budget"); + } + const interfaceSocketIds = new Set(); value.interfaceInputs.forEach((socket, index) => { validSocket(socket, `interfaceInputs[${index}]`); if (record(socket) && socket.direction !== "INPUT") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "interface input must be an INPUT socket", `interfaceInputs[${index}].direction`); + if (record(socket) && interfaceSocketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate interface socket ID: ${String(socket.id)}`, `interfaceInputs[${index}].id`); + if (record(socket)) interfaceSocketIds.add(socket.id as string); }); value.interfaceOutputs.forEach((socket, index) => { validSocket(socket, `interfaceOutputs[${index}]`); if (record(socket) && socket.direction !== "OUTPUT") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "interface output must be an OUTPUT socket", `interfaceOutputs[${index}].direction`); + if (record(socket) && interfaceSocketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate interface socket ID: ${String(socket.id)}`, `interfaceOutputs[${index}].id`); + if (record(socket)) interfaceSocketIds.add(socket.id as string); }); + let socketCount = 0; + const nodeIds = new Set(); value.nodes.forEach((node, index) => { - if (!record(node) || typeof node.id !== "string" || typeof node.type !== "string" || typeof node.name !== "string" || !Array.isArray(node.sockets)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`); - node.sockets.forEach((socket, socketIndex) => validSocket(socket, `nodes[${index}].sockets[${socketIndex}]`)); - if (node.groupTreeId !== undefined && node.groupTreeId !== null && typeof node.groupTreeId !== "string") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].groupTreeId is invalid`, `nodes[${index}].groupTreeId`); - if (node.properties !== undefined && (!record(node.properties) || Object.values(node.properties).some((item) => !(typeof item === "boolean" || typeof item === "number" || typeof item === "string" || (Array.isArray(item) && item.every((entry) => typeof entry === "number" && Number.isFinite(entry))))))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`); + if (!record(node) || !Array.isArray(node.sockets)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`); + boundedText(node.id, `nodes[${index}].id`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes); + boundedText(node.type, `nodes[${index}].type`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes); + boundedText(node.name, `nodes[${index}].name`, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true); + if (nodeIds.has(node.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate node ID: ${String(node.id)}`, `nodes[${index}].id`); + nodeIds.add(node.id as string); + const socketIds = new Set(); + node.sockets.forEach((socket, socketIndex) => { + validSocket(socket, `nodes[${index}].sockets[${socketIndex}]`); + socketCount++; + if (socketCount > GEOMETRY_NODE_GRAPH_BUDGET.maxSocketsPerGraph) throw new GeometryNodeGraphError("GN_GRAPH_BUDGET_EXCEEDED", "Geometry Node graph exceeds its socket budget", `nodes[${index}].sockets`); + if (record(socket) && socketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate socket ID: ${String(node.id)}:${String(socket.id)}`, `nodes[${index}].sockets[${socketIndex}].id`); + if (record(socket)) socketIds.add(socket.id as string); + }); + if (node.groupTreeId !== undefined && node.groupTreeId !== null) boundedText(node.groupTreeId, `nodes[${index}].groupTreeId`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes); + if (node.properties !== undefined) { + if (!record(node.properties) || Object.keys(node.properties).length > 64) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`); + for (const [name, property] of Object.entries(node.properties)) { + boundedText(name, `nodes[${index}].properties.${name}`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes); + boundedLiteral(property, `nodes[${index}].properties.${name}`); + } + } }); value.links.forEach((link, index) => { if (!record(link) || typeof link.fromNodeId !== "string" || typeof link.fromSocketId !== "string" || typeof link.toNodeId !== "string" || typeof link.toSocketId !== "string") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `links[${index}] is invalid`, `links[${index}]`); }); - if (value.groupReferences !== undefined && (!Array.isArray(value.groupReferences) || value.groupReferences.some((item) => typeof item !== "string"))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "groupReferences must contain strings", "groupReferences"); + if (value.groupReferences !== undefined && (!Array.isArray(value.groupReferences) || value.groupReferences.some((item) => typeof item !== "string" || item.length === 0 || new TextEncoder().encode(item).byteLength > GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes) || new Set(value.groupReferences).size !== value.groupReferences.length)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "groupReferences must contain unique bounded strings", "groupReferences"); + if (value.graphHash !== undefined && (typeof value.graphHash !== "string" || !/^[0-9a-f]{64}$/.test(value.graphHash))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "graphHash must be a lowercase SHA-256", "graphHash"); return value as unknown as GeometryNodeGraphIR; } @@ -222,6 +518,9 @@ export function validateGeometryNodeGraphSet(values: readonly unknown[]): Geomet const issues: GeometryNodeGraphSetValidation["issues"] = []; const graphs: GeometryNodeGraphIR[] = []; const graphIds = new Set(); + if (values.length > GEOMETRY_NODE_GRAPH_BUDGET.maxGraphs) { + return { status: "BLOCKED", issues: [{ code: "GN_GRAPH_BUDGET_EXCEEDED", message: "Geometry Node graph set exceeds 4096 graphs", path: "graphs" }], cycles: [] }; + } for (const [index, value] of values.entries()) { try { const graph = parseGeometryNodeGraph(value); diff --git a/web/protocol/grease-pencil-marquee.ts b/web/protocol/grease-pencil-marquee.ts new file mode 100644 index 00000000..ea562893 --- /dev/null +++ b/web/protocol/grease-pencil-marquee.ts @@ -0,0 +1,225 @@ +export const GREASE_PENCIL_MARQUEE_SCHEMA_VERSION = 1 as const; + +export const GREASE_PENCIL_MARQUEE_BUDGET = { + maxCandidates: 1_000_000, + maxIdBytes: 256, +} as const; + +export interface GreasePencilDrawingScopeIR { + dataId: string; + layerId: string; + frame: number; + drawingId: string; +} + +export interface GreasePencilStablePointRefIR extends GreasePencilDrawingScopeIR { + strokeId: string; + pointId: string; + strokeIndex: number; + pointIndex: number; +} + +export interface GreasePencilMarqueeCandidateIR extends GreasePencilStablePointRefIR { + viewportPosition: [number, number]; +} + +export interface GreasePencilMarqueeBoxIR { + left: number; + top: number; + right: number; + bottom: number; +} + +export interface GreasePencilMarqueeRequestIR { + schemaVersion: typeof GREASE_PENCIL_MARQUEE_SCHEMA_VERSION; + baseRevision: number; + baseSelectionRevision: number; + drawing: GreasePencilDrawingScopeIR; + box: GreasePencilMarqueeBoxIR; + candidates: GreasePencilMarqueeCandidateIR[]; +} + +export interface GreasePencilMarqueeResultIR { + schemaVersion: typeof GREASE_PENCIL_MARQUEE_SCHEMA_VERSION; + baseRevision: number; + baseSelectionRevision: number; + drawing: GreasePencilDrawingScopeIR; + selectedStrokeIds: string[]; + selectedPoints: GreasePencilStablePointRefIR[]; +} + +export type GreasePencilMarqueeErrorCode = + | "GREASE_PENCIL_SELECTION_INVALID" + | "GREASE_PENCIL_SELECTION_SCOPE_INVALID" + | "GREASE_PENCIL_BUDGET_EXCEEDED" + | "REVISION_CONFLICT"; + +export class GreasePencilMarqueeValidationError extends Error { + constructor(readonly code: GreasePencilMarqueeErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "GreasePencilMarqueeValidationError"; + } +} + +const DRAWING_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId"]); +const BOX_FIELDS = new Set(["left", "top", "right", "bottom"]); +const CANDIDATE_FIELDS = new Set([ + "dataId", "layerId", "frame", "drawingId", "strokeId", "pointId", "strokeIndex", + "pointIndex", "viewportPosition", +]); +const REQUEST_FIELDS = new Set(["schemaVersion", "baseRevision", "baseSelectionRevision", "drawing", "box", "candidates"]); +const encoder = new TextEncoder(); + +function fail(code: GreasePencilMarqueeErrorCode, message: string): never { + throw new GreasePencilMarqueeValidationError(code, message); +} + +function record(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an object`); + } + return value as Record; +} + +function exactFields(value: Record, fields: ReadonlySet, path: string): void { + if (Object.keys(value).some((field) => !fields.has(field))) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains undeclared fields`); + } +} + +function stableId(value: unknown, prefix: string, path: string): string { + if (typeof value !== "string" || !value.startsWith(prefix) || + encoder.encode(value).byteLength > GREASE_PENCIL_MARQUEE_BUDGET.maxIdBytes) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is not a bounded ${prefix} identity`); + } + return value; +} + +function safeInteger(value: unknown, path: string, allowNegative = false): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || (!allowNegative && value < 0) || + value < -1_000_000 || value > 1_000_000) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is outside the supported integer range`); + } + return value; +} + +function finite(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 8) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be finite and bounded`); + } + return value; +} + +function drawingScope(value: unknown, path: string): GreasePencilDrawingScopeIR { + const drawing = record(value, path); + exactFields(drawing, DRAWING_FIELDS, path); + return { + dataId: stableId(drawing.dataId, "grease-pencil:", `${path}.dataId`), + layerId: stableId(drawing.layerId, "grease-pencil-layer:", `${path}.layerId`), + frame: safeInteger(drawing.frame, `${path}.frame`, true), + drawingId: stableId(drawing.drawingId, "grease-pencil-drawing:", `${path}.drawingId`), + }; +} + +function sameDrawing(left: GreasePencilDrawingScopeIR, right: GreasePencilDrawingScopeIR): boolean { + return left.dataId === right.dataId && left.layerId === right.layerId && + left.frame === right.frame && left.drawingId === right.drawingId; +} + +function box(value: unknown): GreasePencilMarqueeBoxIR { + const candidate = record(value, "request.box"); + exactFields(candidate, BOX_FIELDS, "request.box"); + const result = { + left: finite(candidate.left, "request.box.left"), + top: finite(candidate.top, "request.box.top"), + right: finite(candidate.right, "request.box.right"), + bottom: finite(candidate.bottom, "request.box.bottom"), + }; + if (result.left < 0 || result.top < 0 || result.right > 1 || result.bottom > 1 || + result.left >= result.right || result.top >= result.bottom) { + fail("GREASE_PENCIL_SELECTION_INVALID", "request.box must be a non-empty normalized viewport rectangle"); + } + return result; +} + +function candidate(value: unknown, index: number, drawing: GreasePencilDrawingScopeIR): GreasePencilMarqueeCandidateIR { + const path = `request.candidates[${index}]`; + const point = record(value, path); + exactFields(point, CANDIDATE_FIELDS, path); + const scope = drawingScope({ + dataId: point.dataId, + layerId: point.layerId, + frame: point.frame, + drawingId: point.drawingId, + }, path); + if (!sameDrawing(scope, drawing)) { + fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", `${path} does not belong to the current drawing`); + } + if (!Array.isArray(point.viewportPosition) || point.viewportPosition.length !== 2) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path}.viewportPosition must contain two numbers`); + } + return { + ...scope, + strokeId: stableId(point.strokeId, "grease-pencil-stroke:", `${path}.strokeId`), + pointId: stableId(point.pointId, "grease-pencil-point:", `${path}.pointId`), + strokeIndex: safeInteger(point.strokeIndex, `${path}.strokeIndex`), + pointIndex: safeInteger(point.pointIndex, `${path}.pointIndex`), + viewportPosition: [ + finite(point.viewportPosition[0], `${path}.viewportPosition[0]`), + finite(point.viewportPosition[1], `${path}.viewportPosition[1]`), + ], + }; +} + +export function selectGreasePencilMarquee( + requestValue: unknown, + currentRevision: number, +): GreasePencilMarqueeResultIR { + const request = record(requestValue, "request"); + exactFields(request, REQUEST_FIELDS, "request"); + if (request.schemaVersion !== GREASE_PENCIL_MARQUEE_SCHEMA_VERSION) { + fail("GREASE_PENCIL_SELECTION_INVALID", "request.schemaVersion is unsupported"); + } + const baseRevision = safeInteger(request.baseRevision, "request.baseRevision"); + const baseSelectionRevision = safeInteger(request.baseSelectionRevision, "request.baseSelectionRevision"); + if (baseRevision !== currentRevision) { + fail("REVISION_CONFLICT", "Grease Pencil marquee request is stale"); + } + const drawing = drawingScope(request.drawing, "request.drawing"); + const selectionBox = box(request.box); + if (!Array.isArray(request.candidates)) { + fail("GREASE_PENCIL_SELECTION_INVALID", "request.candidates must be an array"); + } + if (request.candidates.length > GREASE_PENCIL_MARQUEE_BUDGET.maxCandidates) { + fail("GREASE_PENCIL_BUDGET_EXCEEDED", "Grease Pencil marquee candidate budget exceeded"); + } + const candidates = request.candidates.map((value, index) => candidate(value, index, drawing)); + const pointIds = new Set(); + const strokeIndices = new Map(); + for (const point of candidates) { + if (pointIds.has(point.pointId)) { + fail("GREASE_PENCIL_SELECTION_INVALID", `duplicate point identity ${point.pointId}`); + } + pointIds.add(point.pointId); + const priorStrokeIndex = strokeIndices.get(point.strokeId); + if (priorStrokeIndex !== undefined && priorStrokeIndex !== point.strokeIndex) { + fail("GREASE_PENCIL_SELECTION_INVALID", `stroke identity ${point.strokeId} maps to multiple indices`); + } + strokeIndices.set(point.strokeId, point.strokeIndex); + } + const selectedPoints = candidates + .filter((point) => point.viewportPosition[0] >= selectionBox.left && + point.viewportPosition[0] <= selectionBox.right && + point.viewportPosition[1] >= selectionBox.top && + point.viewportPosition[1] <= selectionBox.bottom) + .map(({ viewportPosition: _viewportPosition, ...point }) => point) + .sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex || left.pointId.localeCompare(right.pointId)); + return { + schemaVersion: GREASE_PENCIL_MARQUEE_SCHEMA_VERSION, + baseRevision, + baseSelectionRevision, + drawing, + selectedStrokeIds: [...new Set(selectedPoints.map((point) => point.strokeId))].sort(), + selectedPoints, + }; +} diff --git a/web/protocol/grease-pencil-reorder.ts b/web/protocol/grease-pencil-reorder.ts new file mode 100644 index 00000000..d5a6e22d --- /dev/null +++ b/web/protocol/grease-pencil-reorder.ts @@ -0,0 +1,122 @@ +import type { GreasePencilDataIR } from "./grease-pencil"; + +export const GREASE_PENCIL_REORDER_SCHEMA_VERSION = 1 as const; +export const GREASE_PENCIL_REORDER_BUDGET = { + maxIdBytes: 256, + minFrame: -1_000_000, + maxFrame: 1_000_000, +} as const; + +export type GreasePencilLayerMoveDirection = "UP" | "DOWN" | "TOP" | "BOTTOM"; + +export type GreasePencilReorderCommand = + | { + type: "moveGreasePencilLayer"; + schemaVersion: typeof GREASE_PENCIL_REORDER_SCHEMA_VERSION; + dataId: string; + layerId: string; + direction: GreasePencilLayerMoveDirection; + baseRevision: number; + } + | { + type: "moveGreasePencilFrame"; + schemaVersion: typeof GREASE_PENCIL_REORDER_SCHEMA_VERSION; + dataId: string; + layerId: string; + frame: number; + targetFrame: number; + drawingId: string; + baseRevision: number; + }; + +export type GreasePencilReorderErrorCode = "GREASE_PENCIL_SCHEMA_INVALID" | "REVISION_CONFLICT"; + +export class GreasePencilReorderValidationError extends Error { + constructor(readonly code: GreasePencilReorderErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "GreasePencilReorderValidationError"; + } +} + +const encoder = new TextEncoder(); +const LAYER_FIELDS = new Set(["type", "schemaVersion", "dataId", "layerId", "direction", "baseRevision"]); +const FRAME_FIELDS = new Set(["type", "schemaVersion", "dataId", "layerId", "frame", "targetFrame", "drawingId", "baseRevision"]); + +function fail(code: GreasePencilReorderErrorCode, message: string): never { + throw new GreasePencilReorderValidationError(code, message); +} + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail("GREASE_PENCIL_SCHEMA_INVALID", "reorder command must be an object"); + return value as Record; +} + +function exact(value: Record, fields: ReadonlySet): void { + if (Object.keys(value).some((field) => !fields.has(field))) fail("GREASE_PENCIL_SCHEMA_INVALID", "reorder command contains undeclared fields"); +} + +function boundedId(value: unknown, prefix: string, field: string): string { + if (typeof value !== "string" || !value.startsWith(prefix) || encoder.encode(value).byteLength > GREASE_PENCIL_REORDER_BUDGET.maxIdBytes) { + fail("GREASE_PENCIL_SCHEMA_INVALID", `${field} is not a bounded ${prefix} identity`); + } + return value; +} + +function integer(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < GREASE_PENCIL_REORDER_BUDGET.minFrame || value > GREASE_PENCIL_REORDER_BUDGET.maxFrame) { + fail("GREASE_PENCIL_SCHEMA_INVALID", `${field} is outside the supported frame range`); + } + return value; +} + +function revision(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail("GREASE_PENCIL_SCHEMA_INVALID", "baseRevision is invalid"); + return value; +} + +function dataFor(command: { dataId: string; layerId: string }, currentRevision: number, data: readonly GreasePencilDataIR[]): GreasePencilDataIR { + if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("GREASE_PENCIL_SCHEMA_INVALID", "current revision is invalid"); + const candidate = data.find((item) => item.id === command.dataId); + if (!candidate) fail("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil data block was not found"); + if (!candidate.layers.some((layer) => layer.id === command.layerId)) fail("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil layer was not found"); + return candidate; +} + +export function validateGreasePencilReorderCommand( + value: unknown, + currentRevision: number, + data: readonly GreasePencilDataIR[], +): GreasePencilReorderCommand { + const command = record(value); + if (command.schemaVersion !== GREASE_PENCIL_REORDER_SCHEMA_VERSION) fail("GREASE_PENCIL_SCHEMA_INVALID", "unsupported reorder schema version"); + const type = command.type; + const dataId = boundedId(command.dataId, "grease-pencil:", "dataId"); + const layerId = boundedId(command.layerId, "grease-pencil-layer:", "layerId"); + const baseRevision = revision(command.baseRevision); + if (baseRevision !== currentRevision) fail("REVISION_CONFLICT", "reorder command is stale"); + const candidate = dataFor({ dataId, layerId }, currentRevision, data); + const layerIndex = candidate.layers.findIndex((layer) => layer.id === layerId); + + if (type === "moveGreasePencilLayer") { + exact(command, LAYER_FIELDS); + const direction = command.direction; + if (direction !== "UP" && direction !== "DOWN" && direction !== "TOP" && direction !== "BOTTOM") fail("GREASE_PENCIL_SCHEMA_INVALID", "layer move direction is invalid"); + const noOp = (direction === "UP" && layerIndex === candidate.layers.length - 1) || + (direction === "DOWN" && layerIndex === 0) || + (direction === "TOP" && layerIndex === candidate.layers.length - 1) || + (direction === "BOTTOM" && layerIndex === 0); + if (noOp) fail("GREASE_PENCIL_SCHEMA_INVALID", "layer is already at the requested boundary"); + return { type, schemaVersion: GREASE_PENCIL_REORDER_SCHEMA_VERSION, dataId, layerId, direction, baseRevision }; + } + + if (type !== "moveGreasePencilFrame") fail("GREASE_PENCIL_SCHEMA_INVALID", "unsupported Grease Pencil reorder command"); + exact(command, FRAME_FIELDS); + const frame = integer(command.frame, "frame"); + const targetFrame = integer(command.targetFrame, "targetFrame"); + if (frame === targetFrame) fail("GREASE_PENCIL_SCHEMA_INVALID", "frame move must change the frame number"); + const drawingId = boundedId(command.drawingId, "grease-pencil-drawing:", "drawingId"); + const source = candidate.layers[layerIndex].frames.find((entry) => entry.frame === frame); + if (!source || source.drawing.id !== drawingId) fail("GREASE_PENCIL_SCHEMA_INVALID", "source frame or drawing identity was not found"); + if (candidate.layers[layerIndex].frames.some((entry) => entry.frame === targetFrame)) fail("GREASE_PENCIL_SCHEMA_INVALID", "target frame already exists"); + return { type, schemaVersion: GREASE_PENCIL_REORDER_SCHEMA_VERSION, dataId, layerId, frame, targetFrame, drawingId, baseRevision }; +} diff --git a/web/protocol/grease-pencil-selection.ts b/web/protocol/grease-pencil-selection.ts new file mode 100644 index 00000000..339245cf --- /dev/null +++ b/web/protocol/grease-pencil-selection.ts @@ -0,0 +1,212 @@ +import type { GreasePencilDrawingScopeIR, GreasePencilStablePointRefIR } from "./grease-pencil-marquee"; + +export const GREASE_PENCIL_SELECTION_SCHEMA_VERSION = 1 as const; +export const GREASE_PENCIL_SELECTION_BUDGET = { maxPoints: 1_000_000, maxIdBytes: 256 } as const; + +export type GreasePencilSelectionSource = "CANVAS_2D" | "VIEWPORT_3D"; +export type GreasePencilSelectionOperation = "REPLACE" | "ADD" | "TOGGLE" | "CLEAR"; + +export interface GreasePencilSelectionStateIR { + schemaVersion: typeof GREASE_PENCIL_SELECTION_SCHEMA_VERSION; + revision: number; + drawing: GreasePencilDrawingScopeIR; + selectedPoints: GreasePencilStablePointRefIR[]; + lastSource: GreasePencilSelectionSource | null; +} + +export interface GreasePencilSelectionEditIR { + schemaVersion: typeof GREASE_PENCIL_SELECTION_SCHEMA_VERSION; + baseSelectionRevision: number; + source: GreasePencilSelectionSource; + operation: GreasePencilSelectionOperation; + points: GreasePencilStablePointRefIR[]; +} + +export type GreasePencilSelectionErrorCode = + | "GREASE_PENCIL_SELECTION_INVALID" + | "GREASE_PENCIL_SELECTION_SCOPE_INVALID" + | "GREASE_PENCIL_BUDGET_EXCEEDED" + | "REVISION_CONFLICT"; + +export class GreasePencilSelectionValidationError extends Error { + constructor(readonly code: GreasePencilSelectionErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "GreasePencilSelectionValidationError"; + } +} + +const encoder = new TextEncoder(); +const SCOPE_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId"]); +const POINT_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId", "strokeId", "pointId", "strokeIndex", "pointIndex"]); +const STATE_FIELDS = new Set(["schemaVersion", "revision", "drawing", "selectedPoints", "lastSource"]); +const EDIT_FIELDS = new Set(["schemaVersion", "baseSelectionRevision", "source", "operation", "points"]); + +function fail(code: GreasePencilSelectionErrorCode, message: string): never { + throw new GreasePencilSelectionValidationError(code, message); +} + +function record(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an object`); + } + return value as Record; +} + +function exact(value: Record, fields: ReadonlySet, path: string): void { + if (Object.keys(value).some((field) => !fields.has(field))) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains undeclared fields`); + } +} + +function integer(value: unknown, path: string, allowNegative = false): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || (!allowNegative && value < 0) || + value < -1_000_000 || value > 1_000_000) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is outside the supported integer range`); + } + return value; +} + +function id(value: unknown, prefix: string, path: string): string { + if (typeof value !== "string" || !value.startsWith(prefix) || + encoder.encode(value).byteLength > GREASE_PENCIL_SELECTION_BUDGET.maxIdBytes) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is not a bounded ${prefix} identity`); + } + return value; +} + +function scope(value: unknown, path: string): GreasePencilDrawingScopeIR { + const candidate = record(value, path); + exact(candidate, SCOPE_FIELDS, path); + return { + dataId: id(candidate.dataId, "grease-pencil:", `${path}.dataId`), + layerId: id(candidate.layerId, "grease-pencil-layer:", `${path}.layerId`), + frame: integer(candidate.frame, `${path}.frame`, true), + drawingId: id(candidate.drawingId, "grease-pencil-drawing:", `${path}.drawingId`), + }; +} + +function sameDrawing(left: GreasePencilDrawingScopeIR, right: GreasePencilDrawingScopeIR): boolean { + return left.dataId === right.dataId && left.layerId === right.layerId && + left.frame === right.frame && left.drawingId === right.drawingId; +} + +function point(value: unknown, path: string, drawing: GreasePencilDrawingScopeIR): GreasePencilStablePointRefIR { + const candidate = record(value, path); + exact(candidate, POINT_FIELDS, path); + const pointScope = scope({ + dataId: candidate.dataId, + layerId: candidate.layerId, + frame: candidate.frame, + drawingId: candidate.drawingId, + }, path); + if (!sameDrawing(pointScope, drawing)) { + fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", `${path} does not belong to the current drawing`); + } + return { + ...pointScope, + strokeId: id(candidate.strokeId, "grease-pencil-stroke:", `${path}.strokeId`), + pointId: id(candidate.pointId, "grease-pencil-point:", `${path}.pointId`), + strokeIndex: integer(candidate.strokeIndex, `${path}.strokeIndex`), + pointIndex: integer(candidate.pointIndex, `${path}.pointIndex`), + }; +} + +function points(value: unknown, path: string, drawing: GreasePencilDrawingScopeIR): GreasePencilStablePointRefIR[] { + if (!Array.isArray(value)) fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an array`); + if (value.length > GREASE_PENCIL_SELECTION_BUDGET.maxPoints) { + fail("GREASE_PENCIL_BUDGET_EXCEEDED", `${path} exceeds the selection point budget`); + } + const parsed = value.map((item, index) => point(item, `${path}[${index}]`, drawing)); + if (new Set(parsed.map((item) => item.pointId)).size !== parsed.length) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains duplicate point identities`); + } + const strokeIndices = new Map(); + const pointIndices = new Map(); + for (const item of parsed) { + const priorStrokeIndex = strokeIndices.get(item.strokeId); + if (priorStrokeIndex !== undefined && priorStrokeIndex !== item.strokeIndex) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} maps one stroke identity to multiple indices`); + } + strokeIndices.set(item.strokeId, item.strokeIndex); + const indexKey = `${item.strokeIndex}:${item.pointIndex}`; + const priorPointId = pointIndices.get(indexKey); + if (priorPointId !== undefined && priorPointId !== item.pointId) { + fail("GREASE_PENCIL_SELECTION_INVALID", `${path} maps one point index to multiple identities`); + } + pointIndices.set(indexKey, item.pointId); + } + return parsed.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex || left.pointId.localeCompare(right.pointId)); +} + +export function parseGreasePencilSelectionState(value: unknown): GreasePencilSelectionStateIR { + const state = record(value, "state"); + exact(state, STATE_FIELDS, "state"); + if (state.schemaVersion !== GREASE_PENCIL_SELECTION_SCHEMA_VERSION) { + fail("GREASE_PENCIL_SELECTION_INVALID", "state.schemaVersion is unsupported"); + } + const drawing = scope(state.drawing, "state.drawing"); + if (state.lastSource !== null && state.lastSource !== "CANVAS_2D" && state.lastSource !== "VIEWPORT_3D") { + fail("GREASE_PENCIL_SELECTION_INVALID", "state.lastSource is invalid"); + } + return { + schemaVersion: GREASE_PENCIL_SELECTION_SCHEMA_VERSION, + revision: integer(state.revision, "state.revision"), + drawing, + selectedPoints: points(state.selectedPoints, "state.selectedPoints", drawing), + lastSource: state.lastSource, + }; +} + +export function createGreasePencilSelectionState(drawing: GreasePencilDrawingScopeIR): GreasePencilSelectionStateIR { + return parseGreasePencilSelectionState({ + schemaVersion: GREASE_PENCIL_SELECTION_SCHEMA_VERSION, + revision: 0, + drawing, + selectedPoints: [], + lastSource: null, + }); +} + +export function applyGreasePencilSelectionEdit( + stateValue: unknown, + editValue: unknown, + currentDrawing: GreasePencilDrawingScopeIR, +): GreasePencilSelectionStateIR { + const state = parseGreasePencilSelectionState(stateValue); + const expectedDrawing = scope(currentDrawing, "currentDrawing"); + if (!sameDrawing(state.drawing, expectedDrawing)) { + fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", "selection state is not bound to the current drawing"); + } + const edit = record(editValue, "edit"); + exact(edit, EDIT_FIELDS, "edit"); + if (edit.schemaVersion !== GREASE_PENCIL_SELECTION_SCHEMA_VERSION || + (edit.source !== "CANVAS_2D" && edit.source !== "VIEWPORT_3D") || + !["REPLACE", "ADD", "TOGGLE", "CLEAR"].includes(String(edit.operation))) { + fail("GREASE_PENCIL_SELECTION_INVALID", "selection edit schema is invalid"); + } + const baseSelectionRevision = integer(edit.baseSelectionRevision, "edit.baseSelectionRevision"); + if (baseSelectionRevision !== state.revision) { + fail("REVISION_CONFLICT", "Grease Pencil selection edit is stale"); + } + const editedPoints = points(edit.points, "edit.points", expectedDrawing); + if (edit.operation === "CLEAR" && editedPoints.length !== 0) { + fail("GREASE_PENCIL_SELECTION_INVALID", "CLEAR cannot include points"); + } + const selected = new Map(state.selectedPoints.map((item) => [item.pointId, item])); + if (edit.operation === "REPLACE" || edit.operation === "CLEAR") selected.clear(); + if (edit.operation === "REPLACE" || edit.operation === "ADD") { + for (const item of editedPoints) selected.set(item.pointId, item); + } + else if (edit.operation === "TOGGLE") { + for (const item of editedPoints) { + if (selected.has(item.pointId)) selected.delete(item.pointId); + else selected.set(item.pointId, item); + } + } + return parseGreasePencilSelectionState({ + ...state, + revision: state.revision + 1, + selectedPoints: [...selected.values()], + lastSource: edit.source, + }); +} diff --git a/web/protocol/grease-pencil.ts b/web/protocol/grease-pencil.ts index 19559a81..41031476 100644 --- a/web/protocol/grease-pencil.ts +++ b/web/protocol/grease-pencil.ts @@ -11,6 +11,7 @@ export type GreasePencilAttributeDomain = "POINT" | "STROKE" | "CURVE" | "INSTAN export type GreasePencilAttributeDataType = "BOOL" | "INT" | "FLOAT" | "FLOAT2" | "FLOAT3" | "FLOAT4" | "BYTE_COLOR" | "FLOAT_COLOR"; export interface GreasePencilPointIR { + id: string; position: [number, number, number]; radius: number; opacity: number; @@ -26,7 +27,7 @@ export interface GreasePencilAttributeIR { } export interface GreasePencilStrokeIR { - id?: string; + id: string; cyclic: boolean; pointCount: number; points?: GreasePencilPointIR[]; @@ -67,6 +68,7 @@ export interface GreasePencilDataIR { strokeCount: number; pointCount: number; layers: GreasePencilLayerIR[]; + activeLayerId?: string; attributes?: GreasePencilAttributeIR[]; errorCode?: "GREASE_PENCIL_SCHEMA_INVALID" | "GREASE_PENCIL_BUDGET_EXCEEDED"; } @@ -123,24 +125,28 @@ function parseAttribute(value: unknown, path: string, domainCount: number): Grea function parsePoint(value: unknown, path: string): GreasePencilPointIR { const point = record(value, path); + const id = string(point.id, `${path}.id`); + if (!id.startsWith("grease-pencil-point:")) fail(`${path}.id`, "must be a stable Grease Pencil point identity"); const position = tuple(point.position, 3, `${path}.position`) as [number, number, number]; const radius = number(point.radius, `${path}.radius`); const opacity = number(point.opacity, `${path}.opacity`); if (radius < 0 || radius > 1_000_000) fail(`${path}.radius`, "is outside the bounded range"); if (opacity < 0 || opacity > 1) fail(`${path}.opacity`, "must be in [0,1]"); - const result: GreasePencilPointIR = { position, radius, opacity }; + const result: GreasePencilPointIR = { id, position, radius, opacity }; if (point.vertexColor !== undefined) result.vertexColor = tuple(point.vertexColor, 4, `${path}.vertexColor`) as [number, number, number, number]; return result; } function parseStroke(value: unknown, path: string): GreasePencilStrokeIR { const stroke = record(value, path); + const id = string(stroke.id, `${path}.id`); + if (!id.startsWith("grease-pencil-stroke:")) fail(`${path}.id`, "must be a stable Grease Pencil stroke identity"); const result: GreasePencilStrokeIR = { + id, cyclic: stroke.cyclic === true, pointCount: count(stroke.pointCount, `${path}.pointCount`), }; if (typeof stroke.cyclic !== "boolean") fail(`${path}.cyclic`, "must be a boolean"); - if (stroke.id !== undefined) result.id = string(stroke.id, `${path}.id`); if (stroke.materialIndex !== undefined) result.materialIndex = count(stroke.materialIndex, `${path}.materialIndex`); if (stroke.points !== undefined) { if (!Array.isArray(stroke.points)) fail(`${path}.points`, "must be an array"); @@ -162,6 +168,9 @@ function parseDrawing(value: unknown, path: string): GreasePencilDrawingIR { if (!Array.isArray(drawing.strokes)) fail(`${path}.strokes`, "must be an array"); if (drawing.strokes.length !== strokeCount) fail(`${path}.strokes`, "length must match strokeCount"); const strokes = drawing.strokes.map((stroke, index) => parseStroke(stroke, `${path}.strokes[${index}]`)); + if (new Set(strokes.map((stroke) => stroke.id)).size !== strokes.length) fail(`${path}.strokes`, "contains duplicate stable stroke identities"); + const pointIds = strokes.flatMap((stroke) => (stroke.points ?? []).map((point) => point.id)); + if (new Set(pointIds).size !== pointIds.length) fail(`${path}.strokes`, "contains duplicate stable point identities"); if (strokes.reduce((sum, stroke) => sum + stroke.pointCount, 0) !== pointCount) fail(`${path}.pointCount`, "must equal the sum of stroke point counts"); const result: GreasePencilDrawingIR = { id, strokeCount, pointCount, strokes }; if (drawing.attributes !== undefined) { @@ -224,6 +233,11 @@ export function parseGreasePencilData(value: unknown, path = "greasePencils"): G const actualPoints = layers.reduce((sum, layer) => sum + layer.frames.reduce((frameSum, frame) => frameSum + frame.drawing.pointCount, 0), 0); if (!budgetBlocked && (actualFrames !== frameCount || actualStrokes !== strokeCount || actualPoints !== pointCount)) fail(path, "declared counts do not match layer/frame/drawing contents"); const result: GreasePencilDataIR = { id, name, geometryStatus, layerCount, frameCount, strokeCount, pointCount, layers }; + if (data.activeLayerId !== undefined) { + const activeLayerId = string(data.activeLayerId, `${path}.activeLayerId`); + if (!layers.some((layer) => layer.id === activeLayerId)) fail(`${path}.activeLayerId`, "must reference a declared layer"); + result.activeLayerId = activeLayerId; + } if (data.errorCode !== undefined) { if (data.errorCode !== "GREASE_PENCIL_SCHEMA_INVALID" && data.errorCode !== "GREASE_PENCIL_BUDGET_EXCEEDED") fail(`${path}.errorCode`, "is invalid"); result.errorCode = data.errorCode; diff --git a/web/protocol/nanovdb-device-recovery.ts b/web/protocol/nanovdb-device-recovery.ts new file mode 100644 index 00000000..b712276b --- /dev/null +++ b/web/protocol/nanovdb-device-recovery.ts @@ -0,0 +1,36 @@ +export interface NanoVDBDeviceLossReplayPlanIR { + schemaVersion: 1; + visiblePageIds: readonly number[]; + replayedPageIds: readonly number[]; + skippedPageIds: readonly number[]; + pageCount: number; + residentPageCapacity: number; +} + +function invalid(message: string): never { + throw new Error(`NANOVDB_INVALID_ARGUMENT: ${message}`); +} + +export function planNanoVDBDeviceLossReplay( + visiblePageIds: readonly number[], + pageCount: number, + residentPageCapacity: number, +): NanoVDBDeviceLossReplayPlanIR { + if (!Array.isArray(visiblePageIds)) invalid("visible page IDs must be an array"); + if (!Number.isSafeInteger(pageCount) || pageCount < 1 || pageCount > 8192) invalid("page count is outside the manifest limit"); + if (!Number.isSafeInteger(residentPageCapacity) || residentPageCapacity < 1 || residentPageCapacity > pageCount) { + invalid("resident page capacity is outside the virtual grid"); + } + const visible = [...new Set(visiblePageIds.map((pageId) => { + if (!Number.isSafeInteger(pageId) || pageId < 0 || pageId >= pageCount) invalid("visible page ID is outside the virtual grid"); + return pageId; + }))].sort((left, right) => left - right); + return { + schemaVersion: 1, + visiblePageIds: visible, + replayedPageIds: visible.slice(0, residentPageCapacity), + skippedPageIds: visible.slice(residentPageCapacity), + pageCount, + residentPageCapacity, + }; +} diff --git a/web/protocol/nanovdb-page-feedback.ts b/web/protocol/nanovdb-page-feedback.ts new file mode 100644 index 00000000..6168bf6f --- /dev/null +++ b/web/protocol/nanovdb-page-feedback.ts @@ -0,0 +1,234 @@ +import type { ErrorCode } from "./error"; + +export const NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION = 1 as const; +export const NANOVDB_PAGE_FEEDBACK_HEADER_WORDS = 4; +export const NANOVDB_PAGE_FEEDBACK_DEFAULT_CAPACITY = 1024; +export const NANOVDB_PAGE_FEEDBACK_MAX_CAPACITY = 8192; +export const NANOVDB_PAGE_FEEDBACK_EMPTY_PAGE_ID = 0xffffffff; +export const NANOVDB_PAGE_FEEDBACK_OVERFLOW_CODE = "NANOVDB_PAGE_FEEDBACK_OVERFLOW" as const satisfies ErrorCode; + +export const NANOVDB_PAGE_FEEDBACK_WORD = { + schemaVersion: 0, + capacity: 1, + count: 2, + overflow: 3, + pageIds: NANOVDB_PAGE_FEEDBACK_HEADER_WORDS, +} as const; + +export const NANOVDB_PAGE_FEEDBACK_WGSL = /* wgsl */` +struct NanoVDBPageFeedback { + schema_version: u32, + capacity: u32, + count: atomic, + overflow: atomic, + page_ids: array>, +} +`; + +export const NANOVDB_PAGE_FEEDBACK_RECORD_WGSL = /* wgsl */` +fn nanovdb_record_page_fault(page_id: u32) { + if (page_id == 0xffffffffu || nanovdb_page_feedback.schema_version != 1u) { return; } + let physical_capacity = arrayLength(&nanovdb_page_feedback.page_ids); + let capacity = min(nanovdb_page_feedback.capacity, physical_capacity); + if (capacity == 0u) { return; } + for (var slot = 0u; slot < capacity; slot += 1u) { + loop { + let current = atomicLoad(&nanovdb_page_feedback.page_ids[slot]); + if (current == page_id) { return; } + if (current != 0xffffffffu) { break; } + let claim = atomicCompareExchangeWeak(&nanovdb_page_feedback.page_ids[slot], 0xffffffffu, page_id); + if (claim.exchanged) { + atomicAdd(&nanovdb_page_feedback.count, 1u); + return; + } + if (claim.old_value == page_id) { return; } + if (claim.old_value != 0xffffffffu) { break; } + } + } + atomicStore(&nanovdb_page_feedback.overflow, 1u); + atomicAdd(&nanovdb_page_feedback.count, 1u); +} +`; + +export type NanoVDBPageFeedbackStatus = "READY" | "OVERFLOW"; + +export interface NanoVDBPageFeedbackResult { + schemaVersion: typeof NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION; + capacity: number; + attemptedCount: number; + storedCount: number; + pageIds: number[]; + status: NanoVDBPageFeedbackStatus; + errorCode: typeof NANOVDB_PAGE_FEEDBACK_OVERFLOW_CODE | null; +} + +export interface NanoVDBPageFeedbackBatch { + schemaVersion: typeof NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION; + renderRevision: number; + attemptedCount: number; + gpuStoredCount: number; + uniqueCount: number; + pageIds: number[]; + status: NanoVDBPageFeedbackStatus; + errorCode: typeof NANOVDB_PAGE_FEEDBACK_OVERFLOW_CODE | null; +} + +export interface NanoVDBPageFeedbackDispatchResult { + schemaVersion: typeof NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION; + renderRevision: number; + currentRenderRevision: number; + status: "ACCEPTED" | "STALE"; + requestedPageIds: number[]; + requestedCount: number; + errorCode: "REVISION_CONFLICT" | null; +} + +export type NanoVDBPageRequester = (pageId: number, renderRevision: number) => Promise | void; + +export class NanoVDBPageFeedbackError extends Error { + constructor(public readonly code: "INVALID_ARGUMENT" | "PROTOCOL_MISMATCH", message: string) { + super(`${code}: ${message}`); + this.name = "NanoVDBPageFeedbackError"; + } +} + +function feedbackError(code: NanoVDBPageFeedbackError["code"], message: string): never { + throw new NanoVDBPageFeedbackError(code, message); +} + +function validateCapacity(capacity: number, code: NanoVDBPageFeedbackError["code"] = "INVALID_ARGUMENT"): number { + if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > NANOVDB_PAGE_FEEDBACK_MAX_CAPACITY) { + feedbackError(code, "NanoVDB page feedback capacity is outside the bounded range"); + } + return capacity; +} + +export function nanoVDBPageFeedbackByteLength(capacity = NANOVDB_PAGE_FEEDBACK_DEFAULT_CAPACITY): number { + return (NANOVDB_PAGE_FEEDBACK_HEADER_WORDS + validateCapacity(capacity)) * Uint32Array.BYTES_PER_ELEMENT; +} + +export function createNanoVDBPageFeedbackBuffer(capacity = NANOVDB_PAGE_FEEDBACK_DEFAULT_CAPACITY): ArrayBuffer { + const words = new Uint32Array(nanoVDBPageFeedbackByteLength(capacity) / Uint32Array.BYTES_PER_ELEMENT); + words[NANOVDB_PAGE_FEEDBACK_WORD.schemaVersion] = NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION; + words[NANOVDB_PAGE_FEEDBACK_WORD.capacity] = capacity; + words.fill(NANOVDB_PAGE_FEEDBACK_EMPTY_PAGE_ID, NANOVDB_PAGE_FEEDBACK_WORD.pageIds); + return words.buffer; +} + +export function resetNanoVDBPageFeedbackBuffer(buffer: ArrayBuffer): void { + const words = feedbackWords(buffer); + const capacity = validateHeader(words, buffer.byteLength); + words[NANOVDB_PAGE_FEEDBACK_WORD.count] = 0; + words[NANOVDB_PAGE_FEEDBACK_WORD.overflow] = 0; + words.fill(NANOVDB_PAGE_FEEDBACK_EMPTY_PAGE_ID, NANOVDB_PAGE_FEEDBACK_WORD.pageIds, NANOVDB_PAGE_FEEDBACK_WORD.pageIds + capacity); +} + +export function parseNanoVDBPageFeedbackBuffer(buffer: ArrayBuffer, pageCount: number): NanoVDBPageFeedbackResult { + if (!Number.isSafeInteger(pageCount) || pageCount < 1 || pageCount > NANOVDB_PAGE_FEEDBACK_MAX_CAPACITY) { + feedbackError("INVALID_ARGUMENT", "NanoVDB virtual page count is outside the bounded range"); + } + const words = feedbackWords(buffer); + const capacity = validateHeader(words, buffer.byteLength); + const attemptedCount = words[NANOVDB_PAGE_FEEDBACK_WORD.count]; + const overflow = words[NANOVDB_PAGE_FEEDBACK_WORD.overflow]; + if (overflow !== 0 && overflow !== 1) feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback overflow flag is invalid"); + if ((attemptedCount > capacity) !== (overflow === 1)) { + feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback count and overflow flag disagree"); + } + const storedCount = Math.min(attemptedCount, capacity); + const pageIds = [...words.slice(NANOVDB_PAGE_FEEDBACK_WORD.pageIds, NANOVDB_PAGE_FEEDBACK_WORD.pageIds + storedCount)]; + if (pageIds.some((pageId) => pageId === NANOVDB_PAGE_FEEDBACK_EMPTY_PAGE_ID || pageId >= pageCount)) { + feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback contains an invalid virtual page ID"); + } + const overflowed = overflow === 1; + return { + schemaVersion: NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION, + capacity, + attemptedCount, + storedCount, + pageIds, + status: overflowed ? "OVERFLOW" : "READY", + errorCode: overflowed ? NANOVDB_PAGE_FEEDBACK_OVERFLOW_CODE : null, + }; +} + +function bindNanoVDBPageFeedbackToRender( + feedback: NanoVDBPageFeedbackResult, + renderRevision: number, +): NanoVDBPageFeedbackBatch { + if (!Number.isSafeInteger(renderRevision) || renderRevision < 0) { + feedbackError("INVALID_ARGUMENT", "NanoVDB feedback render revision is invalid"); + } + const pageIds = [...new Set(feedback.pageIds)].sort((left, right) => left - right); + return { + schemaVersion: NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION, + renderRevision, + attemptedCount: feedback.attemptedCount, + gpuStoredCount: feedback.storedCount, + uniqueCount: pageIds.length, + pageIds, + status: feedback.status, + errorCode: feedback.errorCode, + }; +} + +export function parseNanoVDBPageFeedbackBatch( + buffer: ArrayBuffer, + pageCount: number, + renderRevision: number, +): NanoVDBPageFeedbackBatch { + return bindNanoVDBPageFeedbackToRender(parseNanoVDBPageFeedbackBuffer(buffer, pageCount), renderRevision); +} + +export async function dispatchNanoVDBPageFeedbackBatch( + batch: NanoVDBPageFeedbackBatch, + currentRenderRevision: number, + requestPage: NanoVDBPageRequester, +): Promise { + if (!Number.isSafeInteger(currentRenderRevision) || currentRenderRevision < 0) { + feedbackError("INVALID_ARGUMENT", "NanoVDB current render revision is invalid"); + } + if (batch.renderRevision !== currentRenderRevision) { + return { + schemaVersion: NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION, + renderRevision: batch.renderRevision, + currentRenderRevision, + status: "STALE", + requestedPageIds: [], + requestedCount: 0, + errorCode: "REVISION_CONFLICT", + }; + } + const requestedPageIds: number[] = []; + for (const pageId of batch.pageIds) { + await requestPage(pageId, batch.renderRevision); + requestedPageIds.push(pageId); + } + return { + schemaVersion: NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION, + renderRevision: batch.renderRevision, + currentRenderRevision, + status: "ACCEPTED", + requestedPageIds, + requestedCount: requestedPageIds.length, + errorCode: null, + }; +} + +function feedbackWords(buffer: ArrayBuffer): Uint32Array { + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < NANOVDB_PAGE_FEEDBACK_HEADER_WORDS * Uint32Array.BYTES_PER_ELEMENT || buffer.byteLength % Uint32Array.BYTES_PER_ELEMENT !== 0) { + feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback buffer byte length is invalid"); + } + return new Uint32Array(buffer); +} + +function validateHeader(words: Uint32Array, byteLength: number): number { + if (words[NANOVDB_PAGE_FEEDBACK_WORD.schemaVersion] !== NANOVDB_PAGE_FEEDBACK_SCHEMA_VERSION) { + feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback schema version is unsupported"); + } + const capacity = validateCapacity(words[NANOVDB_PAGE_FEEDBACK_WORD.capacity], "PROTOCOL_MISMATCH"); + if (byteLength !== nanoVDBPageFeedbackByteLength(capacity)) { + feedbackError("PROTOCOL_MISMATCH", "NanoVDB page feedback buffer does not match its declared capacity"); + } + return capacity; +} diff --git a/web/protocol/nanovdb-progressive-redraw.ts b/web/protocol/nanovdb-progressive-redraw.ts new file mode 100644 index 00000000..09c19c2b --- /dev/null +++ b/web/protocol/nanovdb-progressive-redraw.ts @@ -0,0 +1,37 @@ +export const NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES = 32; +export const NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE = "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" as const; + +export interface NanoVDBProgressiveRedrawBudgetResult { + allowed: boolean; + redrawCount: number; + capped: boolean; + errorCode: typeof NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE | null; +} + +export function validateNanoVDBProgressiveRedrawLimit(value: number): number { + if (!Number.isSafeInteger(value) || value < 1 || value > 1024) { + throw new Error("NANOVDB_INVALID_ARGUMENT: progressive redraw limit must be an integer from 1 to 1024"); + } + return value; +} + +export function consumeNanoVDBProgressiveRedrawBudget( + redrawCount: number, + maxRedraws: number, +): NanoVDBProgressiveRedrawBudgetResult { + if (!Number.isSafeInteger(redrawCount) || redrawCount < 0) { + throw new Error("NANOVDB_INVALID_ARGUMENT: progressive redraw count must be a non-negative integer"); + } + const limit = validateNanoVDBProgressiveRedrawLimit(maxRedraws); + if (redrawCount >= limit) { + return { allowed: false, redrawCount, capped: true, errorCode: NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE }; + } + const nextCount = redrawCount + 1; + const capped = nextCount >= limit; + return { + allowed: true, + redrawCount: nextCount, + capped, + errorCode: capped ? NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE : null, + }; +} diff --git a/web/protocol/nanovdb-render-golden.ts b/web/protocol/nanovdb-render-golden.ts new file mode 100644 index 00000000..e47da216 --- /dev/null +++ b/web/protocol/nanovdb-render-golden.ts @@ -0,0 +1,90 @@ +import type { ErrorCode } from "./error"; + +export const NANOVDB_RENDER_GOLDEN_SCHEMA_VERSION = 1 as const; +export const NANOVDB_GOLDEN_MISMATCH_CODE = "NANOVDB_GOLDEN_MISMATCH" as const satisfies ErrorCode; + +export interface NanoVDBRenderGoldenThresholdsIR { + maxChannelError: number; + meanAbsoluteError: number; + rmsError: number; + alphaCoverageDeltaRatio: number; +} + +export interface NanoVDBRenderGoldenComparisonIR { + schemaVersion: typeof NANOVDB_RENDER_GOLDEN_SCHEMA_VERSION; + status: "READY" | "BLOCKED"; + pixelCount: number; + comparedChannels: number; + maxChannelError: number; + meanAbsoluteError: number; + rmsError: number; + referenceAlphaPixels: number; + actualAlphaPixels: number; + alphaCoverageDeltaRatio: number; + thresholds: NanoVDBRenderGoldenThresholdsIR; + errorCode: typeof NANOVDB_GOLDEN_MISMATCH_CODE | null; +} + +function validateThresholds(value: NanoVDBRenderGoldenThresholdsIR): NanoVDBRenderGoldenThresholdsIR { + if ( + !Number.isInteger(value.maxChannelError) || value.maxChannelError < 0 || value.maxChannelError > 255 || + !Number.isFinite(value.meanAbsoluteError) || value.meanAbsoluteError < 0 || value.meanAbsoluteError > 255 || + !Number.isFinite(value.rmsError) || value.rmsError < 0 || value.rmsError > 255 || + !Number.isFinite(value.alphaCoverageDeltaRatio) || value.alphaCoverageDeltaRatio < 0 || value.alphaCoverageDeltaRatio > 1 + ) { + throw new Error("NANOVDB_INVALID_ARGUMENT: render golden thresholds are invalid"); + } + return { ...value }; +} + +export function compareNanoVDBRenderGolden( + reference: Uint8Array, + actual: Uint8Array, + thresholdsValue: NanoVDBRenderGoldenThresholdsIR, +): NanoVDBRenderGoldenComparisonIR { + if ( + !(reference instanceof Uint8Array) || !(actual instanceof Uint8Array) || + reference.byteLength === 0 || reference.byteLength !== actual.byteLength || + reference.byteLength % 4 !== 0 + ) { + throw new Error("NANOVDB_INVALID_ARGUMENT: render golden images must be equal non-empty RGBA8 buffers"); + } + const thresholds = validateThresholds(thresholdsValue); + let maximum = 0; + let absoluteTotal = 0; + let squaredTotal = 0; + let referenceAlphaPixels = 0; + let actualAlphaPixels = 0; + for (let index = 0; index < reference.byteLength; index++) { + const difference = Math.abs(reference[index] - actual[index]); + maximum = Math.max(maximum, difference); + absoluteTotal += difference; + squaredTotal += difference * difference; + if ((index & 3) === 3) { + if (reference[index] > 0) referenceAlphaPixels++; + if (actual[index] > 0) actualAlphaPixels++; + } + } + const pixelCount = reference.byteLength / 4; + const meanAbsoluteError = absoluteTotal / reference.byteLength; + const rmsError = Math.sqrt(squaredTotal / reference.byteLength); + const alphaCoverageDeltaRatio = Math.abs(referenceAlphaPixels - actualAlphaPixels) / pixelCount; + const matches = maximum <= thresholds.maxChannelError && + meanAbsoluteError <= thresholds.meanAbsoluteError && + rmsError <= thresholds.rmsError && + alphaCoverageDeltaRatio <= thresholds.alphaCoverageDeltaRatio; + return { + schemaVersion: NANOVDB_RENDER_GOLDEN_SCHEMA_VERSION, + status: matches ? "READY" : "BLOCKED", + pixelCount, + comparedChannels: reference.byteLength, + maxChannelError: maximum, + meanAbsoluteError, + rmsError, + referenceAlphaPixels, + actualAlphaPixels, + alphaCoverageDeltaRatio, + thresholds, + errorCode: matches ? null : NANOVDB_GOLDEN_MISMATCH_CODE, + }; +} diff --git a/web/protocol/nla.ts b/web/protocol/nla.ts index 434e3e4b..9d1582c6 100644 --- a/web/protocol/nla.ts +++ b/web/protocol/nla.ts @@ -2,6 +2,14 @@ import type { ErrorCode } from "./error"; import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; export const NLA_PROTOCOL_SCHEMA = 1 as const; +export const NLA_STACK_BUDGET = Object.freeze({ + maxTracks: 4_096, + maxStripsPerTrack: 16_384, + maxTotalStrips: 65_536, + maxIdentifierBytes: 256, + maxNameBytes: 1_024, + maxUnsupportedReasonBytes: 4_096, +}); export type NlaBlendMode = "REPLACE" | "ADD" | "MULTIPLY" | "COMBINE"; export type NlaExtrapolation = "NOTHING" | "HOLD" | "HOLD_FORWARD"; @@ -51,6 +59,15 @@ export interface NlaValidationResult { issues: Array<{ code: ErrorCode; message: string; path?: string }>; } +export interface NlaMoveStripCommand { + type: "moveNLAStrip"; + objectId: string; + trackId: string; + stripId: string; + frameStart: number; + baseRevision: number; +} + export class NlaValidationError extends Error { readonly code: ErrorCode; readonly path?: string; @@ -67,14 +84,36 @@ function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function finite(value: unknown, path: string): number { - if (typeof value !== "number" || !Number.isFinite(value)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be finite`, path); +function exactKeys(value: Record, allowed: readonly string[], path: string): void { + const allowedSet = new Set(allowed); + if (Object.keys(value).some((key) => !allowedSet.has(key))) { + throw new NlaValidationError("NLA_INVALID_STACK", `${path} contains undeclared fields`, path); + } +} + +function boundedText(value: unknown, path: string, maximumBytes: number): string { + if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maximumBytes) { + throw new NlaValidationError("NLA_BUDGET_EXCEEDED", `${path} is outside its text budget`, path); + } + return value; +} + +function finite(value: unknown, path: string, maximumMagnitude = 1_000_000): number { + if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > maximumMagnitude) { + throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be finite and bounded`, path); + } return value; } function parseStrip(value: unknown, path: string): NlaStripIR { if (!record(value)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be an object`, path); - if (typeof value.id !== "string" || value.id.length === 0 || typeof value.actionId !== "string" || value.actionId.length === 0) throw new NlaValidationError("NLA_INVALID_STACK", `${path} requires id and actionId`, path); + exactKeys(value, [ + "id", "actionId", "frameStart", "frameEnd", "actionFrameStart", "actionFrameEnd", + "scale", "repeat", "blendIn", "blendOut", "influence", "blendMode", "extrapolation", + "muted", "selected", "reverse", "useTimeWarp", "stripType", "unsupportedReason", + ], path); + boundedText(value.id, `${path}.id`, NLA_STACK_BUDGET.maxIdentifierBytes); + boundedText(value.actionId, `${path}.actionId`, NLA_STACK_BUDGET.maxIdentifierBytes); const strip = value as Record; const frameStart = finite(strip.frameStart, `${path}.frameStart`); const frameEnd = finite(strip.frameEnd, `${path}.frameEnd`); @@ -92,19 +131,34 @@ function parseStrip(value: unknown, path: string): NlaStripIR { if (strip.reverse !== undefined && typeof strip.reverse !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.reverse must be boolean`, `${path}.reverse`); if (strip.useTimeWarp !== undefined && typeof strip.useTimeWarp !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.useTimeWarp must be boolean`, `${path}.useTimeWarp`); if (strip.stripType !== undefined && !["CLIP", "TRANSITION", "META", "SOUND", "UNKNOWN"].includes(strip.stripType as string)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.stripType is invalid`, `${path}.stripType`); - if (strip.unsupportedReason !== undefined && (typeof strip.unsupportedReason !== "string" || strip.unsupportedReason.length === 0)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.unsupportedReason is invalid`, `${path}.unsupportedReason`); + if (strip.unsupportedReason !== undefined) boundedText(strip.unsupportedReason, `${path}.unsupportedReason`, NLA_STACK_BUDGET.maxUnsupportedReasonBytes); return value as unknown as NlaStripIR; } export function parseNlaTracks(value: unknown): NlaTrackIR[] { if (!Array.isArray(value)) throw new NlaValidationError("NLA_INVALID_STACK", "nlaTracks must be an array", "nlaTracks"); + if (value.length > NLA_STACK_BUDGET.maxTracks) { + throw new NlaValidationError("NLA_BUDGET_EXCEEDED", "NLA track count exceeds the bounded stack budget", "nlaTracks"); + } const tracks: NlaTrackIR[] = []; const ids = new Set(); + let totalStrips = 0; for (const [index, item] of value.entries()) { const path = `nlaTracks[${index}]`; - if (!record(item) || item.schemaVersion !== NLA_PROTOCOL_SCHEMA || typeof item.id !== "string" || item.id.length === 0 || typeof item.ownerId !== "string" || item.ownerId.length === 0 || typeof item.name !== "string" || item.name.length === 0 || !Array.isArray(item.strips)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} is invalid`, path); - if (ids.has(item.id)) throw new NlaValidationError("NLA_INVALID_STACK", `duplicate NLA track ID: ${item.id}`, path); - ids.add(item.id); + if (!record(item) || item.schemaVersion !== NLA_PROTOCOL_SCHEMA || !Array.isArray(item.strips)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} is invalid`, path); + exactKeys(item, ["schemaVersion", "id", "ownerId", "name", "strips", "muted", "solo", "selected"], path); + const trackId = boundedText(item.id, `${path}.id`, NLA_STACK_BUDGET.maxIdentifierBytes); + boundedText(item.ownerId, `${path}.ownerId`, NLA_STACK_BUDGET.maxIdentifierBytes); + boundedText(item.name, `${path}.name`, NLA_STACK_BUDGET.maxNameBytes); + if (item.strips.length > NLA_STACK_BUDGET.maxStripsPerTrack) { + throw new NlaValidationError("NLA_BUDGET_EXCEEDED", `${path}.strips exceeds the per-track budget`, `${path}.strips`); + } + totalStrips += item.strips.length; + if (!Number.isSafeInteger(totalStrips) || totalStrips > NLA_STACK_BUDGET.maxTotalStrips) { + throw new NlaValidationError("NLA_BUDGET_EXCEEDED", "NLA strip count exceeds the total stack budget", "nlaTracks"); + } + if (ids.has(trackId)) throw new NlaValidationError("NLA_INVALID_STACK", `duplicate NLA track ID: ${trackId}`, path); + ids.add(trackId); if (typeof item.muted !== "boolean" || typeof item.solo !== "boolean" || typeof item.selected !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path} track flags are invalid`, path); tracks.push({ ...item, strips: item.strips.map((strip, stripIndex) => parseStrip(strip, `${path}.strips[${stripIndex}]`)) } as NlaTrackIR); } @@ -158,3 +212,39 @@ export function gateNlaTracks(value: unknown, context: NlaValidationContext): Ca return blockedGate("N-014", "NLA_STRIP_STACK", [capabilityIssue(issue.code ?? "NLA_INVALID_STACK", issue.message, issue.path)]); } } + +export function moveNlaStrip( + value: unknown, + command: NlaMoveStripCommand, + context: NlaValidationContext, +): NlaTrackIR[] { + if (!Number.isSafeInteger(command.baseRevision) || command.baseRevision < 0 || + typeof command.objectId !== "string" || command.objectId.length === 0 || + typeof command.trackId !== "string" || command.trackId.length === 0 || + typeof command.stripId !== "string" || command.stripId.length === 0 || + !Number.isFinite(command.frameStart) || Math.abs(command.frameStart) > 1_000_000) { + throw new NlaValidationError("NLA_INVALID_STACK", "moveNLAStrip command is invalid"); + } + if (context.ownerId !== undefined && command.objectId !== context.ownerId) { + throw new NlaValidationError("NLA_PATH_INCOMPATIBLE", "moveNLAStrip owner does not match the current object", "objectId"); + } + const tracks = structuredClone(parseNlaTracks(value)); + const track = tracks.find((candidate) => candidate.id === command.trackId); + if (!track || track.ownerId !== command.objectId) { + throw new NlaValidationError("NLA_INVALID_STACK", `NLA track was not found: ${command.trackId}`, "trackId"); + } + const strip = track.strips.find((candidate) => candidate.id === command.stripId); + if (!strip) { + throw new NlaValidationError("NLA_INVALID_STACK", `NLA strip was not found: ${command.stripId}`, "stripId"); + } + const duration = strip.frameEnd - strip.frameStart; + strip.frameStart = command.frameStart; + strip.frameEnd = command.frameStart + duration; + track.strips.sort((left, right) => left.frameStart - right.frameStart || left.id.localeCompare(right.id)); + const validation = validateNlaTracks(tracks, context); + if (validation.status === "BLOCKED") { + const issue = validation.issues[0]; + throw new NlaValidationError(issue.code, issue.message, issue.path); + } + return tracks; +} diff --git a/web/protocol/oom-recovery.ts b/web/protocol/oom-recovery.ts index 2950a017..bebf8739 100644 --- a/web/protocol/oom-recovery.ts +++ b/web/protocol/oom-recovery.ts @@ -20,6 +20,7 @@ export const OOM_FAULT_POINTS = [ "GPU_TEXTURE_UPLOAD", "NANOVDB_RESIDENT_BUFFER", "NANOVDB_PAGE_TABLE", + "NANOVDB_FEEDBACK_BUFFER", ] as const; export type OOMFaultPoint = typeof OOM_FAULT_POINTS[number]; @@ -33,6 +34,7 @@ export const OOM_FAULT_ERROR: Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", `${label} must be an object`); + return value as Record; +} + +function exact(value: Record, fields: ReadonlySet, label: string): void { + if (Object.keys(value).some((field) => !fields.has(field))) fail("PAINT_SCHEMA_INVALID", `${label} contains undeclared fields`); +} + +function identity(value: unknown, prefix: "object:" | "mesh:", label: string): string { + if (typeof value !== "string" || !value.startsWith(prefix) || value.length <= prefix.length || encoder.encode(value).byteLength > 256) { + fail("PAINT_SCHEMA_INVALID", `${label} is not a bounded ${prefix} identity`); + } + return value; +} + +function nonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail("PAINT_SCHEMA_INVALID", `${label} must be a non-negative safe integer`); + return value; +} + +function vertexIndices(value: unknown, label: string): number[] { + if (!Array.isArray(value) || value.length === 0) fail("PAINT_SCHEMA_INVALID", `${label} must contain at least one vertex`); + if (value.length > PAINT_DEPTH_VISIBILITY_BUDGET.maxVertexSamples) fail("PAINT_BUDGET_EXCEEDED", `${label} exceeds the depth sample budget`); + const parsed = value.map((item, index) => nonNegativeInteger(item, `${label}[${index}]`)); + const unique = new Set(parsed); + if (unique.size !== parsed.length) fail("PAINT_SCHEMA_INVALID", `${label} contains duplicate vertex identities`); + return [...unique].sort((left, right) => left - right); +} + +function resultVertexIndices(value: unknown): number[] { + if (!Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", "visibleVertexIndices must be an array"); + if (value.length === 0) return []; + return vertexIndices(value, "visibleVertexIndices"); +} + +export function validatePaintDepthVisibilityRequest(value: unknown, currentRevision: number): PaintDepthVisibilityRequestIR { + const request = record(value, "Paint depth visibility request"); + exact(request, REQUEST_FIELDS, "Paint depth visibility request"); + if (request.schemaVersion !== PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint depth visibility schema is unsupported"); + const revision = nonNegativeInteger(request.revision, "revision"); + if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("PAINT_SCHEMA_INVALID", "current revision is invalid"); + if (revision !== currentRevision) fail("REVISION_CONFLICT", "Paint depth visibility request is stale"); + return { + schemaVersion: PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION, + objectId: identity(request.objectId, "object:", "objectId"), + meshId: identity(request.meshId, "mesh:", "meshId"), + revision, + vertexIndices: vertexIndices(request.vertexIndices, "vertexIndices"), + }; +} + +export function validatePaintDepthVisibilityResult( + value: unknown, + requestValue: PaintDepthVisibilityRequestIR, +): PaintDepthVisibilityResultIR { + const result = record(value, "Paint depth visibility result"); + exact(result, RESULT_FIELDS, "Paint depth visibility result"); + const request = validatePaintDepthVisibilityRequest({ + schemaVersion: result.schemaVersion, + objectId: result.objectId, + meshId: result.meshId, + revision: result.revision, + vertexIndices: result.vertexIndices, + }, requestValue.revision); + if (request.objectId !== requestValue.objectId || request.meshId !== requestValue.meshId || + request.vertexIndices.length !== requestValue.vertexIndices.length || + request.vertexIndices.some((index, offset) => index !== requestValue.vertexIndices[offset])) { + fail("PAINT_SCHEMA_INVALID", "Paint depth visibility result does not match its request"); + } + const backend = result.backend; + if (backend !== "MAIN_THREAD_WEBGL2" && backend !== "OFFSCREEN_WEBGL2") fail("PAINT_SCHEMA_INVALID", "Paint depth backend is invalid"); + if (result.source !== "GPU_RGBA_DEPTH_READBACK") fail("PAINT_SCHEMA_INVALID", "Paint depth source is invalid"); + const width = nonNegativeInteger(result.width, "width"); + const height = nonNegativeInteger(result.height, "height"); + if (width < 1 || height < 1 || width > PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension || height > PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension) { + fail("PAINT_BUDGET_EXCEEDED", "Paint depth dimensions exceed the readback budget"); + } + const depthReadbackBytes = nonNegativeInteger(result.depthReadbackBytes, "depthReadbackBytes"); + if (depthReadbackBytes !== width * height * 4 || depthReadbackBytes > PAINT_DEPTH_VISIBILITY_BUDGET.maxReadbackBytes) { + fail("PAINT_BUDGET_EXCEEDED", "Paint depth readback byte length is invalid"); + } + const occluderPixelCount = nonNegativeInteger(result.occluderPixelCount, "occluderPixelCount"); + if (occluderPixelCount > width * height) fail("PAINT_SCHEMA_INVALID", "Paint depth occluder count exceeds the target"); + const visibleVertexIndices = resultVertexIndices(result.visibleVertexIndices); + const requested = new Set(request.vertexIndices); + if (visibleVertexIndices.some((index) => !requested.has(index))) fail("PAINT_SCHEMA_INVALID", "Paint depth result contains an unrequested vertex"); + return { + ...request, + backend, + source: "GPU_RGBA_DEPTH_READBACK", + width, + height, + depthReadbackBytes, + occluderPixelCount, + visibleVertexIndices, + }; +} diff --git a/web/protocol/paint-pbvh-capability.ts b/web/protocol/paint-pbvh-capability.ts new file mode 100644 index 00000000..21747b8b --- /dev/null +++ b/web/protocol/paint-pbvh-capability.ts @@ -0,0 +1,150 @@ +import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; +import type { ErrorCode } from "./error"; + +export const PAINT_PBVH_CAPABILITY_SCHEMA = 1 as const; +export const PAINT_PBVH_WASM_ENTRYPOINT = "_web_engine_apply_pbvh_stroke" as const; + +export type PaintPBVHDomain = "SCULPT" | "VERTEX_COLOR" | "WEIGHT" | "TEXTURE"; + +const BRUSH_INVENTORY = { + SCULPT: [ + "DRAW", "SMOOTH", "PINCH", "INFLATE", "GRAB", "LAYER", "CLAY", "NUDGE", "THUMB", + "SNAKE_HOOK", "ROTATE", "SIMPLIFY", "CREASE", "BLOB", "CLAY_STRIPS", "MASK", + "DRAW_SHARP", "ELASTIC_DEFORM", "POSE", "MULTIPLANE_SCRAPE", "SLIDE_RELAX", + "CLAY_THUMB", "CLOTH", "DRAW_FACE_SETS", "PAINT", "SMEAR", "BOUNDARY", + "DISPLACEMENT_ERASER", "DISPLACEMENT_SMEAR", "PLANE", "BLUR", "SCENE_PROJECT", + ], + VERTEX_COLOR: ["DRAW", "BLUR", "AVERAGE", "SMEAR"], + WEIGHT: ["DRAW", "BLUR", "AVERAGE", "SMEAR"], + TEXTURE: ["DRAW", "SOFTEN", "SMEAR", "CLONE", "FILL", "MASK"], +} as const satisfies Record; + +export interface PaintPBVHBrushInventoryEntry { + domain: PaintPBVHDomain; + brush: string; + source: "blender-5.2.0/source/blender/makesdna/DNA_brush_enums.h"; +} + +export interface PaintPBVHCapabilityRequest { + schemaVersion: typeof PAINT_PBVH_CAPABILITY_SCHEMA; + operation: "PBVH_BRUSH"; + domain: PaintPBVHDomain; + brush: string; + objectId: string; + meshId: string; + baseRevision: number; +} + +export interface PaintPBVHCapabilityContext { + nativeEntrypointPresent: boolean; + sessionContextReady: boolean; + verifiedBrushes: ReadonlySet; + currentRevision?: number; + currentObjectId?: string; + currentMeshId?: string; +} + +export class PaintPBVHCapabilityError extends Error { + readonly code: ErrorCode; + readonly path?: string; + + constructor(code: ErrorCode, message: string, path?: string) { + super(message); + this.name = "PaintPBVHCapabilityError"; + this.code = code; + this.path = path; + } +} + +function fail(code: ErrorCode, message: string, path?: string): never { + throw new PaintPBVHCapabilityError(code, message, path); +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stableId(value: unknown, prefix: "object:" | "mesh:", path: string): string { + if (typeof value !== "string" || !value.startsWith(prefix) || value.length <= prefix.length || value.length > 512) { + fail("PAINT_SCHEMA_INVALID", `${path} must be a bounded ${prefix.slice(0, -1)} stable ID`, path); + } + return value; +} + +export function paintPBVHBrushInventory(): PaintPBVHBrushInventoryEntry[] { + return (Object.entries(BRUSH_INVENTORY) as Array<[PaintPBVHDomain, readonly string[]]>).flatMap(([domain, brushes]) => + brushes.map((brush) => ({ + domain, + brush, + source: "blender-5.2.0/source/blender/makesdna/DNA_brush_enums.h" as const, + })), + ); +} + +export function parsePaintPBVHCapabilityRequest(value: unknown): PaintPBVHCapabilityRequest { + if (!record(value)) fail("PAINT_SCHEMA_INVALID", "PBVH capability request must be an object"); + const allowed = ["schemaVersion", "operation", "domain", "brush", "objectId", "meshId", "baseRevision"]; + const extra = Object.keys(value).find((key) => !allowed.includes(key)); + if (extra) fail("PAINT_SCHEMA_INVALID", `PBVH capability request contains unsupported field ${extra}`, extra); + if (value.schemaVersion !== PAINT_PBVH_CAPABILITY_SCHEMA) fail("PROTOCOL_MISMATCH", "Unsupported PBVH capability schema", "schemaVersion"); + if (value.operation !== "PBVH_BRUSH") fail("PAINT_SCHEMA_INVALID", "operation must be PBVH_BRUSH", "operation"); + if (typeof value.domain !== "string" || !(value.domain in BRUSH_INVENTORY)) { + fail("PAINT_SCHEMA_INVALID", "domain is not a PBVH paint domain", "domain"); + } + const domain = value.domain as PaintPBVHDomain; + if (typeof value.brush !== "string" || !(BRUSH_INVENTORY[domain] as readonly string[]).includes(value.brush)) { + fail("PAINT_PBVH_BRUSH_UNVERIFIED", `Brush ${String(value.brush)} is not in the Blender 5.2 ${domain} inventory`, "brush"); + } + if (!Number.isSafeInteger(value.baseRevision) || (value.baseRevision as number) < 0) { + fail("PAINT_SCHEMA_INVALID", "baseRevision must be a non-negative safe integer", "baseRevision"); + } + return { + schemaVersion: PAINT_PBVH_CAPABILITY_SCHEMA, + operation: "PBVH_BRUSH", + domain, + brush: value.brush, + objectId: stableId(value.objectId, "object:", "objectId"), + meshId: stableId(value.meshId, "mesh:", "meshId"), + baseRevision: value.baseRevision as number, + }; +} + +export function gatePaintPBVHCapability( + value: unknown, + context: PaintPBVHCapabilityContext, +): CapabilityGateResult { + const request = parsePaintPBVHCapabilityRequest(value); + const capability = `PBVH_${request.domain}_${request.brush}`; + if (context.currentRevision !== undefined && request.baseRevision !== context.currentRevision) { + return blockedGate("N-017", capability, [ + capabilityIssue("REVISION_CONFLICT", "PBVH capability request is stale", "baseRevision"), + ]); + } + if ((context.currentObjectId !== undefined && request.objectId !== context.currentObjectId) || + (context.currentMeshId !== undefined && request.meshId !== context.currentMeshId)) { + return blockedGate("N-017", capability, [ + capabilityIssue("PAINT_SCHEMA_INVALID", "PBVH request does not target the current Mesh object", "objectId"), + ]); + } + if (!context.nativeEntrypointPresent) { + return blockedGate("N-017", capability, [ + capabilityIssue( + "PAINT_PBVH_UNAVAILABLE", + `${PAINT_PBVH_WASM_ENTRYPOINT} is not present in the WebEngine WASM build`, + "operation", + false, + ), + ]); + } + if (!context.sessionContextReady) { + return blockedGate("N-017", capability, [ + capabilityIssue("PAINT_PBVH_CONTEXT_UNAVAILABLE", "The Blender PBVH paint session context is not initialized", "operation"), + ]); + } + if (!context.verifiedBrushes.has(`${request.domain}:${request.brush}`)) { + return blockedGate("N-017", capability, [ + capabilityIssue("PAINT_PBVH_BRUSH_UNVERIFIED", "This PBVH brush has no desktop/WASM golden", "brush"), + ]); + } + return readyGate("N-017", capability); +} diff --git a/web/protocol/paint-stroke-session.ts b/web/protocol/paint-stroke-session.ts new file mode 100644 index 00000000..451947a1 --- /dev/null +++ b/web/protocol/paint-stroke-session.ts @@ -0,0 +1,282 @@ +import { PAINT_BUDGET } from "./paint"; +import type { WebEngineEditCommand } from "./web-engine"; + +export const PAINT_STROKE_SESSION_SCHEMA_VERSION = 1 as const; +export const PAINT_STROKE_SESSION_BUDGET = { + maxActiveSessions: 8, + maxChunks: 4096, + maxEntriesPerChunk: 16_384, + maxEntries: PAINT_BUDGET.maxWeightEntries, + maxBytes: PAINT_BUDGET.maxStrokeBytes, +} as const; + +export type PaintStrokeSessionTargetIR = + | { mode: "VERTEX_COLOR"; meshId: string; attributeName: string; domain: "POINT" | "CORNER" } + | { mode: "WEIGHT"; objectId: string; vertexGroup: string; normalize: boolean; limit?: number; mirror: boolean; mirrorAxis?: 0 | 1 | 2; mirrorTolerance?: number }; + +export interface PaintStrokeSessionBeginIR { + schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; + pointerSessionId: string; + baseRevision: number; + target: PaintStrokeSessionTargetIR; +} + +export interface PaintStrokeSessionChunkIR { + schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; + pointerSessionId: string; + baseRevision: number; + chunkIndex: number; + indices: number[]; + values: number[]; +} + +export interface PaintStrokeSessionCommitIR { + schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; + pointerSessionId: string; + baseRevision: number; + expectedChunkCount: number; +} + +export interface PaintStrokeSessionCancelIR { + schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; + pointerSessionId: string; + baseRevision: number; +} + +export interface PaintStrokeSessionReceiptIR { + schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; + pointerSessionId: string; + mode: PaintStrokeSessionTargetIR["mode"]; + state: "OPEN" | "READY" | "COMMITTED" | "CANCELLED"; + baseRevision: number; + chunkCount: number; + receivedEntryCount: number; + uniqueEntryCount: number; + bufferedBytes: number; + committedRevision?: number; +} + +export type PaintStrokeSessionErrorCode = "PAINT_SCHEMA_INVALID" | "PAINT_BUDGET_EXCEEDED" | "REVISION_CONFLICT"; + +export class PaintStrokeSessionError extends Error { + constructor(readonly code: PaintStrokeSessionErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "PaintStrokeSessionError"; + } +} + +interface BufferedSession { + begin: PaintStrokeSessionBeginIR; + chunkCount: number; + receivedEntryCount: number; + bufferedBytes: number; + values: Map; +} + +const encoder = new TextEncoder(); +const BEGIN_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "target"]); +const COLOR_TARGET_FIELDS = new Set(["mode", "meshId", "attributeName", "domain"]); +const WEIGHT_TARGET_FIELDS = new Set(["mode", "objectId", "vertexGroup", "normalize", "limit", "mirror", "mirrorAxis", "mirrorTolerance"]); +const CHUNK_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "chunkIndex", "indices", "values"]); +const COMMIT_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "expectedChunkCount"]); +const CANCEL_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision"]); + +function fail(code: PaintStrokeSessionErrorCode, message: string): never { + throw new PaintStrokeSessionError(code, message); +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", `${label} must be an object`); + return value as Record; +} + +function exact(value: Record, fields: ReadonlySet, label: string): void { + if (Object.keys(value).some((field) => !fields.has(field))) fail("PAINT_SCHEMA_INVALID", `${label} contains undeclared fields`); +} + +function integer(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail("PAINT_SCHEMA_INVALID", `${label} must be a non-negative safe integer`); + return value; +} + +function boundedString(value: unknown, label: string, prefix?: string, maxBytes = 255): string { + if (typeof value !== "string" || value.length === 0 || (prefix !== undefined && !value.startsWith(prefix)) || encoder.encode(value).byteLength > maxBytes) { + fail("PAINT_SCHEMA_INVALID", `${label} is outside the bounded identity range`); + } + return value; +} + +function revision(value: unknown, currentRevision: number, label: string): number { + const parsed = integer(value, label); + if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("PAINT_SCHEMA_INVALID", "current revision is invalid"); + if (parsed !== currentRevision) fail("REVISION_CONFLICT", "Paint pointer session is stale"); + return parsed; +} + +function pointerSessionId(value: unknown): string { + return boundedString(value, "pointerSessionId", "paint-pointer:", 128); +} + +function parseTarget(value: unknown): PaintStrokeSessionTargetIR { + const target = record(value, "Paint stroke target"); + if (target.mode === "VERTEX_COLOR") { + exact(target, COLOR_TARGET_FIELDS, "Paint color target"); + const domain = target.domain; + if (domain !== "POINT" && domain !== "CORNER") fail("PAINT_SCHEMA_INVALID", "Paint color domain is invalid"); + return { + mode: "VERTEX_COLOR", + meshId: boundedString(target.meshId, "meshId", "mesh:", 256), + attributeName: boundedString(target.attributeName, "attributeName", undefined, 63), + domain, + }; + } + if (target.mode === "WEIGHT") { + exact(target, WEIGHT_TARGET_FIELDS, "Paint weight target"); + if (typeof target.normalize !== "boolean" || typeof target.mirror !== "boolean") fail("PAINT_SCHEMA_INVALID", "Paint weight options must be boolean"); + const limit = target.limit === undefined ? undefined : integer(target.limit, "limit"); + if (limit !== undefined && (limit < 1 || limit > 32)) fail("PAINT_SCHEMA_INVALID", "Paint weight limit is outside [1,32]"); + const mirrorAxis = target.mirrorAxis === undefined ? 0 : integer(target.mirrorAxis, "mirrorAxis"); + if (mirrorAxis > 2) fail("PAINT_SCHEMA_INVALID", "Paint mirror axis must be 0, 1 or 2"); + const mirrorTolerance = target.mirrorTolerance === undefined ? 1e-4 : target.mirrorTolerance; + if (typeof mirrorTolerance !== "number" || !Number.isFinite(mirrorTolerance) || mirrorTolerance <= 0 || mirrorTolerance > 1) fail("PAINT_SCHEMA_INVALID", "Paint mirror tolerance is outside (0,1]"); + if (!target.mirror && (target.mirrorAxis !== undefined || target.mirrorTolerance !== undefined)) fail("PAINT_SCHEMA_INVALID", "Paint mirror axis/tolerance require mirror=true"); + return { + mode: "WEIGHT", + objectId: boundedString(target.objectId, "objectId", "object:", 256), + vertexGroup: boundedString(target.vertexGroup, "vertexGroup", undefined, 63), + normalize: target.normalize, + ...(limit === undefined ? {} : { limit }), + mirror: target.mirror, + ...(target.mirrorAxis === undefined ? {} : { mirrorAxis: mirrorAxis as 0 | 1 | 2 }), + ...(target.mirrorTolerance === undefined ? {} : { mirrorTolerance }), + }; + } + fail("PAINT_SCHEMA_INVALID", "Paint stroke mode is invalid"); +} + +function receipt(session: BufferedSession, state: PaintStrokeSessionReceiptIR["state"]): PaintStrokeSessionReceiptIR { + return { + schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION, + pointerSessionId: session.begin.pointerSessionId, + mode: session.begin.target.mode, + state, + baseRevision: session.begin.baseRevision, + chunkCount: session.chunkCount, + receivedEntryCount: session.receivedEntryCount, + uniqueEntryCount: session.values.size, + bufferedBytes: session.bufferedBytes, + }; +} + +function parseControl( + value: unknown, + fields: ReadonlySet, + withChunkCount: boolean, +): T { + const input = record(value, "Paint stroke session control"); + exact(input, fields, "Paint stroke session control"); + if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported"); + const parsed = { + schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION, + pointerSessionId: pointerSessionId(input.pointerSessionId), + baseRevision: integer(input.baseRevision, "baseRevision"), + } as PaintStrokeSessionCancelIR & Partial; + if (withChunkCount) parsed.expectedChunkCount = integer(input.expectedChunkCount, "expectedChunkCount"); + return parsed as T; +} + +export class PaintStrokeSessionStore { + private readonly sessions = new Map(); + + get activeCount(): number { + return this.sessions.size; + } + + begin(value: unknown, currentRevision: number): PaintStrokeSessionReceiptIR { + const input = record(value, "Paint stroke session begin"); + exact(input, BEGIN_FIELDS, "Paint stroke session begin"); + if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported"); + const begin: PaintStrokeSessionBeginIR = { + schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION, + pointerSessionId: pointerSessionId(input.pointerSessionId), + baseRevision: revision(input.baseRevision, currentRevision, "baseRevision"), + target: parseTarget(input.target), + }; + if (this.sessions.has(begin.pointerSessionId)) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is already open"); + if (this.sessions.size >= PAINT_STROKE_SESSION_BUDGET.maxActiveSessions) fail("PAINT_BUDGET_EXCEEDED", "Paint pointer session capacity is exhausted"); + const session: BufferedSession = { begin, chunkCount: 0, receivedEntryCount: 0, bufferedBytes: 0, values: new Map() }; + this.sessions.set(begin.pointerSessionId, session); + return receipt(session, "OPEN"); + } + + append(value: unknown, currentRevision: number): PaintStrokeSessionReceiptIR { + const input = record(value, "Paint stroke chunk"); + exact(input, CHUNK_FIELDS, "Paint stroke chunk"); + if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported"); + const id = pointerSessionId(input.pointerSessionId); + const session = this.sessions.get(id); + if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open"); + const baseRevision = revision(input.baseRevision, currentRevision, "baseRevision"); + if (baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke chunk revision does not match its pointer session"); + const chunkIndex = integer(input.chunkIndex, "chunkIndex"); + if (chunkIndex !== session.chunkCount) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunks must be contiguous and ordered"); + if (session.chunkCount >= PAINT_STROKE_SESSION_BUDGET.maxChunks) fail("PAINT_BUDGET_EXCEEDED", "Paint stroke exceeds the chunk budget"); + if (!Array.isArray(input.indices) || input.indices.length === 0) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk must contain indices"); + if (input.indices.length > PAINT_STROKE_SESSION_BUDGET.maxEntriesPerChunk) fail("PAINT_BUDGET_EXCEEDED", "Paint stroke chunk exceeds the entry budget"); + const indices = input.indices.map((item, index) => integer(item, `indices[${index}]`)); + if (new Set(indices).size !== indices.length) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk contains duplicate indices"); + if (!Array.isArray(input.values)) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk values must be an array"); + const width = session.begin.target.mode === "VERTEX_COLOR" ? 4 : 1; + if (input.values.length !== indices.length * width) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk values do not match its mode"); + const values = input.values.map((item, index) => { + if (typeof item !== "number" || !Number.isFinite(item) || item < 0 || item > 1) fail("PAINT_SCHEMA_INVALID", `values[${index}] must be in [0,1]`); + return item; + }); + const nextEntryCount = session.receivedEntryCount + indices.length; + const nextBytes = session.bufferedBytes + indices.length * 4 + values.length * 4; + if (nextEntryCount > PAINT_STROKE_SESSION_BUDGET.maxEntries || nextBytes > PAINT_STROKE_SESSION_BUDGET.maxBytes) { + fail("PAINT_BUDGET_EXCEEDED", "Paint stroke exceeds the pointer session budget"); + } + indices.forEach((index, offset) => session.values.set(index, values.slice(offset * width, (offset + 1) * width))); + session.chunkCount += 1; + session.receivedEntryCount = nextEntryCount; + session.bufferedBytes = nextBytes; + return receipt(session, "OPEN"); + } + + commit(value: unknown, currentRevision: number): { command: WebEngineEditCommand; receipt: PaintStrokeSessionReceiptIR } { + const input = parseControl(value, COMMIT_FIELDS, true); + const session = this.sessions.get(input.pointerSessionId); + if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open"); + try { + revision(input.baseRevision, currentRevision, "baseRevision"); + if (input.baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke commit revision does not match its pointer session"); + if (input.expectedChunkCount !== session.chunkCount || session.chunkCount === 0 || session.values.size === 0) { + fail("PAINT_SCHEMA_INVALID", "Paint stroke commit does not match its buffered chunks"); + } + const indices = [...session.values.keys()].sort((left, right) => left - right); + const values = indices.flatMap((index) => session.values.get(index) ?? []); + const target = session.begin.target; + const command: WebEngineEditCommand = target.mode === "VERTEX_COLOR" + ? { type: "setVertexColors", meshId: target.meshId, attributeName: target.attributeName, domain: target.domain, indices, colors: values } + : { type: "setVertexWeights", objectId: target.objectId, vertexGroup: target.vertexGroup, indices, values, normalize: target.normalize, ...(target.limit === undefined ? {} : { limit: target.limit }), mirror: target.mirror, ...(target.mirrorAxis === undefined ? {} : { mirrorAxis: target.mirrorAxis }), ...(target.mirrorTolerance === undefined ? {} : { mirrorTolerance: target.mirrorTolerance }) }; + return { command, receipt: receipt(session, "READY") }; + } + finally { + this.sessions.delete(input.pointerSessionId); + } + } + + cancel(value: unknown): PaintStrokeSessionReceiptIR { + const input = parseControl(value, CANCEL_FIELDS, false); + const session = this.sessions.get(input.pointerSessionId); + if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open"); + if (input.baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke cancel revision does not match its pointer session"); + this.sessions.delete(input.pointerSessionId); + return receipt(session, "CANCELLED"); + } + + clear(): void { + this.sessions.clear(); + } +} diff --git a/web/protocol/paint.ts b/web/protocol/paint.ts index fda06b43..bbf6f8d2 100644 --- a/web/protocol/paint.ts +++ b/web/protocol/paint.ts @@ -40,7 +40,10 @@ export interface WeightPatchIR { indices: number[]; values: number[]; normalize?: boolean; + limit?: number; mirror?: boolean; + mirrorAxis?: 0 | 1 | 2; + mirrorTolerance?: number; } export interface PaintBrushVertexIR { @@ -278,10 +281,26 @@ export function parseWeightPatch(value: unknown): WeightPatchIR { if (typeof patch.normalize !== "boolean") fail("weightPatch.normalize", "must be boolean"); result.normalize = patch.normalize; } + if (patch.limit !== undefined) { + const limit = integer(patch.limit, "weightPatch.limit"); + if (limit < 1 || limit > 32) fail("weightPatch.limit", "must be in [1,32]"); + result.limit = limit; + } if (patch.mirror !== undefined) { if (typeof patch.mirror !== "boolean") fail("weightPatch.mirror", "must be boolean"); result.mirror = patch.mirror; } + if (patch.mirrorAxis !== undefined) { + const axis = integer(patch.mirrorAxis, "weightPatch.mirrorAxis"); + if (axis > 2) fail("weightPatch.mirrorAxis", "must be 0, 1 or 2"); + result.mirrorAxis = axis as 0 | 1 | 2; + } + if (patch.mirrorTolerance !== undefined) { + const tolerance = finite(patch.mirrorTolerance, "weightPatch.mirrorTolerance"); + if (tolerance <= 0 || tolerance > 1) fail("weightPatch.mirrorTolerance", "must be in (0,1]"); + result.mirrorTolerance = tolerance; + } + if (!result.mirror && (result.mirrorAxis !== undefined || result.mirrorTolerance !== undefined)) fail("weightPatch.mirrorAxis/mirrorTolerance", "require mirror=true"); return result; } diff --git a/web/protocol/physics-simulation.ts b/web/protocol/physics-simulation.ts index 606e7daf..4accd367 100644 --- a/web/protocol/physics-simulation.ts +++ b/web/protocol/physics-simulation.ts @@ -2,12 +2,16 @@ import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } fr import type { ErrorCode } from "./error"; export const PHYSICS_SIMULATION_SCHEMA = 1 as const; +export const PHYSICS_CACHE_SCHEMA = 1 as const; +export const PHYSICS_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const; export const PHYSICS_SIMULATION_BUDGET = { maxSystems: 4_096, maxDependenciesPerSystem: 1_024, maxSettings: 256, maxSettingsBytes: 64 * 1024, maxFrames: 100_000, + maxFrameBytes: 512 * 1024 * 1024, + maxCacheBytes: 16 * 1024 * 1024 * 1024, } as const; export const PHYSICS_FAMILIES = [ @@ -24,16 +28,27 @@ export type PhysicsFamily = typeof PHYSICS_FAMILIES[number]; export type PhysicsExecutionRequest = "METADATA" | "CACHE_MANIFEST" | "CACHE_PLAYBACK" | "LOCAL_SOLVER" | "SERVER_JOB"; export type PhysicsSettingValue = boolean | number | string | null; +export interface PhysicsCacheFrameIR { + frame: number; + byteOffset: number; + byteLength: number; + sha256: string; +} + export interface PhysicsCacheBindingIR { + schemaVersion: typeof PHYSICS_CACHE_SCHEMA; cacheKey: string; - source: "BLENDER_DESKTOP_BAKE"; + family: PhysicsFamily; + source: "BLENDER_DESKTOP_BAKE" | "BLENDER_SERVER_BAKE"; + blenderVersion: string; sourceBlendSha256: string; settingsHash: string; inputHash: string; cacheSha256: string; frameStart: number; frameEnd: number; - cachedFrames: number[]; + byteLength: number; + frames: PhysicsCacheFrameIR[]; status: "COMPLETE" | "PARTIAL"; } @@ -57,10 +72,42 @@ export interface PhysicsFamilyCapabilityIR { metadata: "LOCAL_BOUNDED"; cacheManifest: "LOCAL_BOUNDED"; cachePlayback: "BLOCKED"; - localSolver: "BLOCKED"; + localSolver: "READY" | "BLOCKED"; + solverProbe: PhysicsSolverProbeStatus; + unsupportedRoute: "DESKTOP_SERVER_BAKE"; serverJob: "BLOCKED"; } +export type PhysicsSolverProbeStatus = + | "READY" + | "EXPORT_UNAVAILABLE" + | "INITIALIZATION_FAILED" + | "THREADS_UNAVAILABLE" + | "MEMORY_UNAVAILABLE" + | "INVALID_RESULT"; + +export interface PhysicsSolverProbeEnvironmentIR { + threadMode: "SINGLE" | "PTHREAD"; + memoryLimitBytes: number; +} + +export interface PhysicsSolverInitializationIR { + initialized: boolean; + requiredThreadMode: "SINGLE" | "PTHREAD"; + requiredMemoryBytes: number; +} + +export interface PhysicsSolverRuntimeProbe { + hasFamilyExport(family: PhysicsFamily): boolean; + initializeFamily(family: PhysicsFamily): PhysicsSolverInitializationIR | Promise; +} + +export interface PhysicsExecutionRouteIR { + family: PhysicsFamily; + mode: "LOCAL_SOLVER" | "DESKTOP_SERVER_BAKE"; + probe: PhysicsSolverProbeStatus; +} + export interface BrowserTransformCacheObjectIR { objectId: string; translation: [number, number, number]; @@ -105,6 +152,20 @@ function digest(value: unknown, name: string): string { return value; } +function integer(value: unknown, name: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is outside its integer range`); + } + return value; +} + +function exactKeys(value: Record, allowed: readonly string[], name: string): void { + const allowedSet = new Set(allowed); + if (Object.keys(value).some((key) => !allowedSet.has(key))) { + throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} contains undeclared fields`); + } +} + function frame(value: unknown, name: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) { throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `${name} is outside the supported frame range`); @@ -135,23 +196,77 @@ function parseSettings(value: unknown, systemIndex: number): Record PHYSICS_SIMULATION_BUDGET.maxFrames || !Array.isArray(value.cachedFrames)) { + const byteLength = integer(value.byteLength, `systems[${systemIndex}].cache.byteLength`, 1); + if (byteLength > PHYSICS_SIMULATION_BUDGET.maxCacheBytes) { + throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache exceeds the byte budget`); + } + if (frameEnd < frameStart || frameEnd - frameStart + 1 > PHYSICS_SIMULATION_BUDGET.maxFrames || !Array.isArray(value.frames)) { throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache frame range exceeds the budget`); } - const cachedFrames = value.cachedFrames.map((item, frameIndex) => frame(item, `systems[${systemIndex}].cache.cachedFrames[${frameIndex}]`)); - if (cachedFrames.length === 0 || cachedFrames.length > PHYSICS_SIMULATION_BUDGET.maxFrames || - cachedFrames.some((item, index) => item < frameStart || item > frameEnd || (index > 0 && item <= cachedFrames[index - 1]))) { + if (value.frames.length === 0 || value.frames.length > PHYSICS_SIMULATION_BUDGET.maxFrames) { + throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames are invalid`); + } + let nextOffset = 0; + const frames = value.frames.map((item, frameIndex): PhysicsCacheFrameIR => { + if (!record(item)) { + throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache.frames[${frameIndex}] is invalid`); + } + exactKeys(item, ["frame", "byteOffset", "byteLength", "sha256"], `systems[${systemIndex}].cache.frames[${frameIndex}]`); + const frameNumber = frame(item.frame, `systems[${systemIndex}].cache.frames[${frameIndex}].frame`); + const byteOffset = integer(item.byteOffset, `systems[${systemIndex}].cache.frames[${frameIndex}].byteOffset`); + const frameByteLength = integer(item.byteLength, `systems[${systemIndex}].cache.frames[${frameIndex}].byteLength`, 1); + if (frameByteLength > PHYSICS_SIMULATION_BUDGET.maxFrameBytes) { + throw new PhysicsSimulationValidationError("PHYSICS_BUDGET_EXCEEDED", `systems[${systemIndex}].cache.frames[${frameIndex}] exceeds the byte budget`); + } + if (frameNumber < frameStart || frameNumber > frameEnd || + byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}].cache.frames[${frameIndex}] is not ordered or contiguous`); + } + nextOffset += frameByteLength; + return { + frame: frameNumber, + byteOffset, + byteLength: frameByteLength, + sha256: digest(item.sha256, `systems[${systemIndex}].cache.frames[${frameIndex}].sha256`), + }; + }); + if (nextOffset !== byteLength) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}].cache frame ranges do not cover the payload`); + } + if (frames.some((item, index) => index > 0 && item.frame <= frames[index - 1].frame)) { throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${systemIndex}].cache frames must be unique, ordered and in range`); } - if (value.status === "COMPLETE" && (cachedFrames.length !== frameEnd - frameStart + 1 || cachedFrames.some((item, index) => item !== frameStart + index))) { + if (value.status === "COMPLETE" && (frames.length !== frameEnd - frameStart + 1 || frames.some((item, index) => item.frame !== frameStart + index))) { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] declares an incomplete cache as COMPLETE`); } const cacheSettingsHash = digest(value.settingsHash, `systems[${systemIndex}].cache.settingsHash`); @@ -159,15 +274,19 @@ function parseCache(value: unknown, settingsHash: string, systemIndex: number): throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `systems[${systemIndex}] cache settings do not match the current system`); } return { + schemaVersion: PHYSICS_CACHE_SCHEMA, cacheKey: value.cacheKey as string, - source: "BLENDER_DESKTOP_BAKE", + family, + source: value.source, + blenderVersion, sourceBlendSha256: digest(value.sourceBlendSha256, `systems[${systemIndex}].cache.sourceBlendSha256`), settingsHash: cacheSettingsHash, inputHash: digest(value.inputHash, `systems[${systemIndex}].cache.inputHash`), cacheSha256: digest(value.cacheSha256, `systems[${systemIndex}].cache.cacheSha256`), frameStart, frameEnd, - cachedFrames, + byteLength, + frames, status: value.status, }; } @@ -195,15 +314,16 @@ export function parsePhysicsSimulationManifest(value: unknown): PhysicsSimulatio if (new Set(dependencyIds).size !== dependencyIds.length) { throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `systems[${index}] contains duplicate dependencies`); } + const family = item.family as PhysicsFamily; const settingsHash = digest(item.settingsHash, `systems[${index}].settingsHash`); return { id, - family: item.family as PhysicsFamily, + family, ownerObjectId: text(item.ownerObjectId, `systems[${index}].ownerObjectId`, "object:"), settingsHash, settings: parseSettings(item.settings, index), dependencyIds, - cache: parseCache(item.cache, settingsHash, index), + cache: parseCache(item.cache, settingsHash, family, index), }; }); @@ -229,28 +349,146 @@ export function physicsCapabilityInventory(): PhysicsFamilyCapabilityIR[] { cacheManifest: "LOCAL_BOUNDED", cachePlayback: "BLOCKED", localSolver: "BLOCKED", + solverProbe: "EXPORT_UNAVAILABLE", + unsupportedRoute: "DESKTOP_SERVER_BAKE", serverJob: "BLOCKED", })); } -export function gatePhysicsExecution(family: PhysicsFamily, request: PhysicsExecutionRequest): CapabilityGateResult { +function solverCapability(family: PhysicsFamily, status: PhysicsSolverProbeStatus): PhysicsFamilyCapabilityIR { + return { + family, + metadata: "LOCAL_BOUNDED", + cacheManifest: "LOCAL_BOUNDED", + cachePlayback: "BLOCKED", + localSolver: status === "READY" ? "READY" : "BLOCKED", + solverProbe: status, + unsupportedRoute: "DESKTOP_SERVER_BAKE", + serverJob: "BLOCKED", + }; +} + +function validProbeEnvironment(value: PhysicsSolverProbeEnvironmentIR): boolean { + return (value.threadMode === "SINGLE" || value.threadMode === "PTHREAD") && + Number.isSafeInteger(value.memoryLimitBytes) && value.memoryLimitBytes > 0 && value.memoryLimitBytes <= 2_147_483_648; +} + +function validInitialization(value: unknown): value is PhysicsSolverInitializationIR { + return record(value) && typeof value.initialized === "boolean" && + (value.requiredThreadMode === "SINGLE" || value.requiredThreadMode === "PTHREAD") && + typeof value.requiredMemoryBytes === "number" && Number.isSafeInteger(value.requiredMemoryBytes) && + value.requiredMemoryBytes > 0 && value.requiredMemoryBytes <= 2_147_483_648; +} + +export async function probePhysicsSolverCapabilities( + runtime: PhysicsSolverRuntimeProbe | undefined, + environment: PhysicsSolverProbeEnvironmentIR, +): Promise { + if (!validProbeEnvironment(environment)) { + throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", "Physics solver probe environment is invalid"); + } + const capabilities: PhysicsFamilyCapabilityIR[] = []; + for (const family of PHYSICS_FAMILIES) { + if (!runtime) { + capabilities.push(solverCapability(family, "EXPORT_UNAVAILABLE")); + continue; + } + let hasExport = false; + try { hasExport = runtime.hasFamilyExport(family) === true; } + catch { /* A failed symbol lookup is unavailable, not implicit support. */ } + if (!hasExport) { + capabilities.push(solverCapability(family, "EXPORT_UNAVAILABLE")); + continue; + } + let initialized: unknown; + try { initialized = await runtime.initializeFamily(family); } + catch { + capabilities.push(solverCapability(family, "INITIALIZATION_FAILED")); + continue; + } + if (!validInitialization(initialized)) { + capabilities.push(solverCapability(family, "INVALID_RESULT")); + continue; + } + if (!initialized.initialized) { + capabilities.push(solverCapability(family, "INITIALIZATION_FAILED")); + continue; + } + if (initialized.requiredThreadMode === "PTHREAD" && environment.threadMode !== "PTHREAD") { + capabilities.push(solverCapability(family, "THREADS_UNAVAILABLE")); + continue; + } + if (initialized.requiredMemoryBytes > environment.memoryLimitBytes) { + capabilities.push(solverCapability(family, "MEMORY_UNAVAILABLE")); + continue; + } + capabilities.push(solverCapability(family, "READY")); + } + return capabilities; +} + +export function selectPhysicsExecutionRoute( + family: PhysicsFamily, + capabilities: readonly PhysicsFamilyCapabilityIR[], +): PhysicsExecutionRouteIR { + const capability = capabilities.find((entry) => entry.family === family); + if (!capability || capability.localSolver !== "READY" || capability.solverProbe !== "READY") { + return { family, mode: "DESKTOP_SERVER_BAKE", probe: capability?.solverProbe ?? "EXPORT_UNAVAILABLE" }; + } + return { family, mode: "LOCAL_SOLVER", probe: "READY" }; +} + +export function gatePhysicsExecution( + family: PhysicsFamily, + request: PhysicsExecutionRequest, + capabilities: readonly PhysicsFamilyCapabilityIR[] = physicsCapabilityInventory(), +): CapabilityGateResult { if (request === "METADATA" || request === "CACHE_MANIFEST") return readyGate("N-018", `${family}_${request}`); + const route = selectPhysicsExecutionRoute(family, capabilities); + if (request === "LOCAL_SOLVER" && route.mode === "LOCAL_SOLVER") return readyGate("N-018", `${family}_${request}`); const issue = request === "CACHE_PLAYBACK" ? capabilityIssue("PHYSICS_CACHE_PLAYBACK_UNAVAILABLE", `${family} cache playback is not connected to frame evaluation`) : request === "LOCAL_SOLVER" ? - capabilityIssue("PHYSICS_SOLVER_UNAVAILABLE", `${family} has no verified local WASM solver`) : + capabilityIssue("PHYSICS_SOLVER_UNAVAILABLE", `${family} local WASM solver probe is ${route.probe}; use a verified desktop/server bake`) : capabilityIssue("PHYSICS_SERVER_UNAVAILABLE", `${family} server job execution is not configured`); return blockedGate("N-018", `${family}_${request}`, [issue]); } export function selectPhysicsCacheFrame(system: PhysicsSystemIR, requestedFrame: number): { cacheKey: string; frame: number } { const cache = system.cache; - if (!Number.isSafeInteger(requestedFrame) || !cache || !cache.cachedFrames.includes(requestedFrame)) { + if (!Number.isSafeInteger(requestedFrame) || !cache || !cache.frames.some((item) => item.frame === requestedFrame)) { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Physics cache has no verified frame ${requestedFrame}`); } return { cacheKey: cache.cacheKey, frame: requestedFrame }; } +async function sha256(value: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest("SHA-256", value); + return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function verifyPhysicsCachePayload( + system: PhysicsSystemIR, + sourceBlend: ArrayBuffer, + payload: ArrayBuffer, +): Promise { + const cache = parseCache(system.cache, system.settingsHash, system.family, 0); + if (!cache) throw new PhysicsSimulationValidationError("PHYSICS_MANIFEST_INVALID", `Physics system ${system.id} has no cache`); + if (!(sourceBlend instanceof ArrayBuffer) || await sha256(sourceBlend) !== cache.sourceBlendSha256) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_SOURCE_MISMATCH", `Physics ${cache.family} cache source does not match the current blend`); + } + if (!(payload instanceof ArrayBuffer) || payload.byteLength !== cache.byteLength || await sha256(payload) !== cache.cacheSha256) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_HASH_MISMATCH", `Physics ${cache.family} cache payload failed SHA-256 verification`); + } + for (const cacheFrame of cache.frames) { + const bytes = payload.slice(cacheFrame.byteOffset, cacheFrame.byteOffset + cacheFrame.byteLength); + if (await sha256(bytes) !== cacheFrame.sha256) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_HASH_MISMATCH", `Physics ${cache.family} frame ${cacheFrame.frame} failed SHA-256 verification`); + } + } + return cache; +} + const BROWSER_TRANSFORM_CACHE_MAGIC = 0x31465442; // BTF1 const BROWSER_TRANSFORM_CACHE_HEADER_BYTES = 16; const BROWSER_TRANSFORM_CACHE_OBJECT_BYTES = 72; diff --git a/web/protocol/recent-projects.ts b/web/protocol/recent-projects.ts new file mode 100644 index 00000000..53266773 --- /dev/null +++ b/web/protocol/recent-projects.ts @@ -0,0 +1,134 @@ +export const RECENT_PROJECTS_SCHEMA_VERSION = 1; +export const RECENT_PROJECTS_MAX_COUNT = 50; + +export type RecentProjectBackend = "opfs" | "indexeddb" | "unknown"; + +export interface RecentProjectRecord { + schemaVersion: typeof RECENT_PROJECTS_SCHEMA_VERSION; + projectId: string; + displayName: string; + revision: number; + bytes: number; + sha256: string; + updatedAt: string; + lastOpenedAt: string; + backend: RecentProjectBackend; +} + +export interface RecentProjectIndex { + schemaVersion: typeof RECENT_PROJECTS_SCHEMA_VERSION; + projects: RecentProjectRecord[]; +} + +export type RecentProjectIssueCode = "MISSING" | "HASH_MISMATCH" | "METADATA_MISMATCH"; + +export interface RecentProjectIdentity { + revision: number; + bytes: number; + sha256: string; +} + +export interface RecentProjectIssue { + project: RecentProjectRecord; + code: RecentProjectIssueCode; +} + +export interface RecentProjectParseResult { + index: RecentProjectIndex; + quarantined: number; +} + +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + +function canonicalTimestamp(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const milliseconds = Date.parse(value); + return Number.isFinite(milliseconds) ? new Date(milliseconds).toISOString() : undefined; +} + +export function createRecentProjectIndex(): RecentProjectIndex { + return { schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, projects: [] }; +} + +export function parseRecentProjectRecord(value: unknown): RecentProjectRecord | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const candidate = value as Record; + const updatedAt = canonicalTimestamp(candidate.updatedAt); + const lastOpenedAt = canonicalTimestamp(candidate.lastOpenedAt); + if (candidate.schemaVersion !== RECENT_PROJECTS_SCHEMA_VERSION || + typeof candidate.projectId !== "string" || !PROJECT_ID_PATTERN.test(candidate.projectId) || + typeof candidate.displayName !== "string" || candidate.displayName.trim().length === 0 || candidate.displayName.length > 128 || + !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || + !Number.isInteger(candidate.bytes) || Number(candidate.bytes) <= 0 || + typeof candidate.sha256 !== "string" || !SHA256_PATTERN.test(candidate.sha256) || + !updatedAt || !lastOpenedAt || + !["opfs", "indexeddb", "unknown"].includes(candidate.backend as string)) { + return undefined; + } + return { + schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, + projectId: candidate.projectId, + displayName: candidate.displayName, + revision: Number(candidate.revision), + bytes: Number(candidate.bytes), + sha256: candidate.sha256, + updatedAt, + lastOpenedAt, + backend: candidate.backend as RecentProjectBackend, + }; +} + +function compareRecentProjects(left: RecentProjectRecord, right: RecentProjectRecord): number { + return right.lastOpenedAt.localeCompare(left.lastOpenedAt) || + right.updatedAt.localeCompare(left.updatedAt) || + right.revision - left.revision || + left.projectId.localeCompare(right.projectId) || + left.displayName.localeCompare(right.displayName) || + left.backend.localeCompare(right.backend) || + left.sha256.localeCompare(right.sha256) || + left.bytes - right.bytes; +} + +export function normalizeRecentProjects(records: readonly unknown[], limit = RECENT_PROJECTS_MAX_COUNT): RecentProjectParseResult { + const byProjectId = new Map(); + let quarantined = 0; + for (const value of records) { + const parsed = parseRecentProjectRecord(value); + if (!parsed) { + quarantined += 1; + continue; + } + const previous = byProjectId.get(parsed.projectId); + if (!previous || compareRecentProjects(parsed, previous) < 0) byProjectId.set(parsed.projectId, parsed); + } + const normalizedLimit = Number.isFinite(limit) + ? Math.max(0, Math.min(RECENT_PROJECTS_MAX_COUNT, Math.floor(limit))) + : RECENT_PROJECTS_MAX_COUNT; + const projects = [...byProjectId.values()].sort(compareRecentProjects).slice(0, normalizedLimit); + return { index: { schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, projects }, quarantined }; +} + +export function parseRecentProjectIndex(value: unknown): RecentProjectParseResult { + if (!value || typeof value !== "object" || Array.isArray(value)) return { index: createRecentProjectIndex(), quarantined: 1 }; + const candidate = value as Record; + if (candidate.schemaVersion !== RECENT_PROJECTS_SCHEMA_VERSION || !Array.isArray(candidate.projects)) { + return { index: createRecentProjectIndex(), quarantined: 1 }; + } + return normalizeRecentProjects(candidate.projects); +} + +export function upsertRecentProject(index: RecentProjectIndex, record: RecentProjectRecord): RecentProjectIndex { + return normalizeRecentProjects([...index.projects, record]).index; +} + +export function removeRecentProject(index: RecentProjectIndex, projectId: string): RecentProjectIndex { + return normalizeRecentProjects(index.projects.filter((project) => project.projectId !== projectId)).index; +} + +export function classifyRecentProjectIdentity(expected: RecentProjectIdentity, actual: RecentProjectIdentity | undefined): RecentProjectIssueCode | undefined { + if (!actual) return "MISSING"; + if (expected.sha256 !== actual.sha256) return "HASH_MISMATCH"; + if (expected.revision !== actual.revision || expected.bytes !== actual.bytes) return "METADATA_MISMATCH"; + return undefined; +} diff --git a/web/protocol/render-assets.ts b/web/protocol/render-assets.ts index aa82dd51..4594a734 100644 --- a/web/protocol/render-assets.ts +++ b/web/protocol/render-assets.ts @@ -118,7 +118,7 @@ function materialUsages(materials: MaterialIR[]): Map tile.packed).map((tile) => ({ assetId: tile.assetId, diff --git a/web/protocol/render-budget.ts b/web/protocol/render-budget.ts new file mode 100644 index 00000000..27fc8310 --- /dev/null +++ b/web/protocol/render-budget.ts @@ -0,0 +1,245 @@ +import type { ErrorCode } from "./error"; +import { MAX_GPU_TEXTURE_ASSETS, MAX_GPU_TEXTURE_BYTES, MAX_GPU_TEXTURE_DIMENSION, type GPUTextureAsset } from "./render-assets"; +import type { SceneSnapshotIR } from "./scene-ir"; + +export const PBR_RENDER_BUDGET_SCHEMA = 1 as const; +export type PBRRenderBackend = "THREE_WEBGL2" | "THREE_WEBGPU"; + +export interface PBRRenderBudget { + schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA; + backend: PBRRenderBackend; + maxLights: number; + reservedLights: number; + maxShadowMaps: number; + reservedShadowMaps: number; + shadowMapDimension: number; + maxShadowMapTexels: number; + maxTextureAssets: number; + maxTextureDimension: number; + maxTexturePayloadBytes: number; + maxTextureGPUBytes: number; +} + +export interface PBRDeviceLimits { + maxLights?: number; + maxShadowMaps?: number; + maxShadowMapDimension?: number; + maxTextureAssets?: number; + maxTextureDimension2D?: number; + maxTexturePayloadBytes?: number; + maxTextureGPUBytes?: number; +} + +export interface PBRRenderBudgetIssue { + code: Extract; + message: string; + resource: "LIGHT" | "SHADOW_MAP" | "TEXTURE"; +} + +export interface PBRLightingBudgetReport { + schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA; + backend: PBRRenderBackend; + status: "READY" | "BLOCKED"; + budget: PBRRenderBudget; + requestedLights: number; + renderedLightNodeIds: string[]; + droppedLightNodeIds: string[]; + requestedShadowMaps: number; + shadowLightNodeIds: string[]; + shadowBlockedLightNodeIds: string[]; + shadowMapTexels: number; + issues: PBRRenderBudgetIssue[]; +} + +export type PBRTextureBudgetAsset = Pick< + GPUTextureAsset, + "assetId" | "imageId" | "usage" | "tileNumber" | "width" | "height" | "byteLength" +>; + +export interface PBRTextureBudgetReport { + schemaVersion: typeof PBR_RENDER_BUDGET_SCHEMA; + backend: PBRRenderBackend; + status: "READY" | "BLOCKED"; + budget: PBRRenderBudget; + requestedAssets: number; + payloadBytes: number; + decodedGPUBytes: number; + maxRequestedDimension: number; + issues: PBRRenderBudgetIssue[]; +} + +const mib = 1024 * 1024; + +export const PBR_RENDER_BUDGETS: Readonly> = Object.freeze({ + THREE_WEBGL2: Object.freeze({ + schemaVersion: PBR_RENDER_BUDGET_SCHEMA, + backend: "THREE_WEBGL2", + maxLights: 16, + reservedLights: 2, + maxShadowMaps: 4, + reservedShadowMaps: 1, + shadowMapDimension: 1024, + maxShadowMapTexels: 4 * 1024 * 1024, + maxTextureAssets: MAX_GPU_TEXTURE_ASSETS, + maxTextureDimension: MAX_GPU_TEXTURE_DIMENSION, + maxTexturePayloadBytes: 512 * mib, + maxTextureGPUBytes: 512 * mib, + }), + THREE_WEBGPU: Object.freeze({ + schemaVersion: PBR_RENDER_BUDGET_SCHEMA, + backend: "THREE_WEBGPU", + maxLights: 64, + reservedLights: 2, + maxShadowMaps: 8, + reservedShadowMaps: 1, + shadowMapDimension: 2048, + maxShadowMapTexels: 8 * 2048 * 2048, + maxTextureAssets: MAX_GPU_TEXTURE_ASSETS, + maxTextureDimension: MAX_GPU_TEXTURE_DIMENSION, + maxTexturePayloadBytes: 512 * mib, + maxTextureGPUBytes: 1024 * mib, + }), +}); + +function positiveLimit(value: number | undefined, field: string): number | undefined { + if (value === undefined) return undefined; + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: ${field} must be a positive safe integer`); + } + return value; +} + +function lower(product: number, device: number | undefined, field: string): number { + return Math.min(product, positiveLimit(device, field) ?? product); +} + +export function resolvePBRRenderBudget( + backend: PBRRenderBackend, + device: PBRDeviceLimits = {}, +): PBRRenderBudget { + const product = PBR_RENDER_BUDGETS[backend]; + if (!product) throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: unknown PBR render backend ${String(backend)}`); + const maxLights = Math.max(product.reservedLights, lower(product.maxLights, device.maxLights, "maxLights")); + const maxShadowMaps = Math.max(product.reservedShadowMaps, lower(product.maxShadowMaps, device.maxShadowMaps, "maxShadowMaps")); + const shadowMapDimension = lower(product.shadowMapDimension, device.maxShadowMapDimension, "maxShadowMapDimension"); + return { + ...product, + maxLights, + maxShadowMaps, + shadowMapDimension, + maxShadowMapTexels: Math.min(product.maxShadowMapTexels, maxShadowMaps * shadowMapDimension * shadowMapDimension), + maxTextureAssets: lower(product.maxTextureAssets, device.maxTextureAssets, "maxTextureAssets"), + maxTextureDimension: lower(product.maxTextureDimension, device.maxTextureDimension2D, "maxTextureDimension2D"), + maxTexturePayloadBytes: lower(product.maxTexturePayloadBytes, device.maxTexturePayloadBytes, "maxTexturePayloadBytes"), + maxTextureGPUBytes: lower(product.maxTextureGPUBytes, device.maxTextureGPUBytes, "maxTextureGPUBytes"), + }; +} + +function shadowCapable(lightType: number, castsShadow: boolean | undefined): boolean { + return castsShadow !== false && (lightType === 0 || lightType === 1 || lightType === 2); +} + +export function planPBRLightingBudget( + snapshot: Pick, + backend: PBRRenderBackend = "THREE_WEBGL2", + device: PBRDeviceLimits = {}, +): PBRLightingBudgetReport { + const budget = resolvePBRRenderBudget(backend, device); + const definitions = new Map(snapshot.lights.map((light) => [light.id, light])); + const requested = snapshot.nodes.filter((node) => node.type === "LIGHT" && node.visible && node.dataId && definitions.has(node.dataId)); + const lightSlots = Math.max(0, budget.maxLights - budget.reservedLights); + const rendered = requested.slice(0, lightSlots); + const dropped = requested.slice(lightSlots); + const shadowRequested = rendered.filter((node) => shadowCapable(definitions.get(node.dataId!)!.lightType, definitions.get(node.dataId!)!.castsShadow)); + const shadowSlots = Math.max(0, budget.maxShadowMaps - budget.reservedShadowMaps); + const shadowLights = shadowRequested.slice(0, shadowSlots); + const shadowBlocked = shadowRequested.slice(shadowSlots); + const issues: PBRRenderBudgetIssue[] = []; + if (dropped.length > 0) { + issues.push({ + code: "GPU_LIGHT_BUDGET_EXCEEDED", + resource: "LIGHT", + message: `${backend} requested ${requested.length + budget.reservedLights} total lights; limit is ${budget.maxLights}`, + }); + } + if (shadowBlocked.length > 0) { + issues.push({ + code: "GPU_SHADOW_BUDGET_EXCEEDED", + resource: "SHADOW_MAP", + message: `${backend} requested ${shadowRequested.length + budget.reservedShadowMaps} shadow maps; limit is ${budget.maxShadowMaps}`, + }); + } + return { + schemaVersion: PBR_RENDER_BUDGET_SCHEMA, + backend, + status: issues.length === 0 ? "READY" : "BLOCKED", + budget, + requestedLights: requested.length, + renderedLightNodeIds: rendered.map((node) => node.id), + droppedLightNodeIds: dropped.map((node) => node.id), + requestedShadowMaps: shadowRequested.length, + shadowLightNodeIds: shadowLights.map((node) => node.id), + shadowBlockedLightNodeIds: shadowBlocked.map((node) => node.id), + shadowMapTexels: (shadowLights.length + budget.reservedShadowMaps) * budget.shadowMapDimension * budget.shadowMapDimension, + issues, + }; +} + +function safeAdd(total: number, value: number, field: string): number { + const result = total + value; + if (!Number.isSafeInteger(result)) throw new Error(`GPU_TEXTURE_BUDGET_EXCEEDED: ${field} overflows a safe integer`); + return result; +} + +function textureKey(asset: PBRTextureBudgetAsset): string { + return `${asset.assetId}:${asset.imageId}:${asset.usage}:${asset.tileNumber ?? 0}`; +} + +export function planPBRTextureBudget( + assets: readonly PBRTextureBudgetAsset[], + backend: PBRRenderBackend = "THREE_WEBGL2", + device: PBRDeviceLimits = {}, +): PBRTextureBudgetReport { + const budget = resolvePBRRenderBudget(backend, device); + const unique = new Map(assets.map((asset) => [textureKey(asset), asset])); + let payloadBytes = 0; + let decodedGPUBytes = 0; + let maxRequestedDimension = 0; + let invalid = false; + for (const asset of unique.values()) { + if (!Number.isSafeInteger(asset.width) || !Number.isSafeInteger(asset.height) || + !Number.isSafeInteger(asset.byteLength) || asset.width < 1 || asset.height < 1 || + asset.byteLength < 1 || asset.byteLength > MAX_GPU_TEXTURE_BYTES) { + invalid = true; + continue; + } + maxRequestedDimension = Math.max(maxRequestedDimension, asset.width, asset.height); + payloadBytes = safeAdd(payloadBytes, asset.byteLength, "texture payload bytes"); + const pixels = asset.width * asset.height; + if (!Number.isSafeInteger(pixels) || !Number.isSafeInteger(pixels * 4)) { + invalid = true; + continue; + } + decodedGPUBytes = safeAdd(decodedGPUBytes, pixels * 4, "decoded texture bytes"); + } + const issues: PBRRenderBudgetIssue[] = []; + if (invalid || unique.size > budget.maxTextureAssets || maxRequestedDimension > budget.maxTextureDimension || + payloadBytes > budget.maxTexturePayloadBytes || decodedGPUBytes > budget.maxTextureGPUBytes) { + issues.push({ + code: "GPU_TEXTURE_BUDGET_EXCEEDED", + resource: "TEXTURE", + message: `${backend} texture request ${unique.size} assets/${payloadBytes} payload bytes/${decodedGPUBytes} decoded bytes/${maxRequestedDimension}px exceeds ${budget.maxTextureAssets}/${budget.maxTexturePayloadBytes}/${budget.maxTextureGPUBytes}/${budget.maxTextureDimension}`, + }); + } + return { + schemaVersion: PBR_RENDER_BUDGET_SCHEMA, + backend, + status: issues.length === 0 ? "READY" : "BLOCKED", + budget, + requestedAssets: unique.size, + payloadBytes, + decodedGPUBytes, + maxRequestedDimension, + issues, + }; +} diff --git a/web/protocol/render-capabilities.ts b/web/protocol/render-capabilities.ts index 6890936d..8e130023 100644 --- a/web/protocol/render-capabilities.ts +++ b/web/protocol/render-capabilities.ts @@ -5,7 +5,7 @@ export type RenderBackend = "WEBGL2" | "WEBGPU"; export type PostProcessPass = "FXAA" | "BLOOM" | "SSAO" | "SSR" | "TAA" | "DOF" | "MOTION_BLUR"; export type RenderCapabilityRequest = - | { kind: "ARBITRARY_SHADER"; nodeTypes: Array } + | { kind: "ARBITRARY_SHADER"; nodeTypes: string[] } | { kind: "VOLUME" } | { kind: "SUBSURFACE" } | { kind: "WEBGPU_BACKEND" } @@ -36,7 +36,7 @@ export function gateRenderCapability( ): CapabilityGateResult { if (request.kind === "ARBITRARY_SHADER") { const supported = context.supportedShaderNodes ?? boundedShaderNodes; - const unsupported = [...new Set(request.nodeTypes.filter((type) => type === "UNSUPPORTED" || !supported.has(type as ShaderNodeType)))]; + const unsupported = [...new Set(request.nodeTypes.filter((type) => type === "UNSUPPORTED" || !supported.has(type as ShaderNodeType)))].sort(); if (unsupported.length === 0) return readyGate("PBR-012", "BOUNDED_SHADER_GRAPH"); return blockedGate("PBR-012", "ARBITRARY_SHADER", [ capabilityIssue("SHADER_NODE_UNSUPPORTED", `Web shader compiler does not support: ${unsupported.join(", ")}`, "nodeTypes"), diff --git a/web/protocol/render-image-comparison.ts b/web/protocol/render-image-comparison.ts new file mode 100644 index 00000000..fb9ff463 --- /dev/null +++ b/web/protocol/render-image-comparison.ts @@ -0,0 +1,218 @@ +import type { ErrorCode } from "./error"; + +export const RENDER_IMAGE_COMPARISON_SCHEMA_VERSION = 1 as const; +export const RENDER_REFERENCE_MISMATCH_CODE = "RENDER_REFERENCE_MISMATCH" as const satisfies ErrorCode; +export const MAX_RENDER_COMPARISON_DIMENSION = 4_096; +export const MAX_RENDER_COMPARISON_PIXELS = 4_194_304; + +export interface RenderImageComparisonThresholdsIR { + maxMeanAbsoluteError: number; + maxRootMeanSquaredError: number; + maxP95ChannelError: number; + maxBadPixelRatio: number; + badPixelChannelError: number; + foregroundDeltaFromReferenceBackground: number; + minForegroundIntersectionOverUnion: number; + maxAlphaCoverageDeltaRatio: number; +} + +export interface RenderImageComparisonCheckIR { + metric: "MEAN_ABSOLUTE_ERROR" | "ROOT_MEAN_SQUARED_ERROR" | "P95_CHANNEL_ERROR" | + "BAD_PIXEL_RATIO" | "FOREGROUND_INTERSECTION_OVER_UNION" | "ALPHA_COVERAGE_DELTA_RATIO"; + actual: number; + threshold: number; + comparison: "LTE" | "GTE"; + passed: boolean; +} + +export interface RenderImageComparisonIR { + schemaVersion: typeof RENDER_IMAGE_COMPARISON_SCHEMA_VERSION; + colorSpace: "SRGB8"; + alphaMode: "STRAIGHT"; + status: "READY" | "BLOCKED"; + width: number; + height: number; + pixelCount: number; + comparedRGBChannels: number; + meanAbsoluteError: number; + rootMeanSquaredError: number; + p95ChannelError: number; + maxChannelError: number; + badPixelCount: number; + badPixelRatio: number; + referenceBackground: [number, number, number]; + referenceForegroundPixels: number; + actualForegroundPixels: number; + foregroundIntersectionPixels: number; + foregroundUnionPixels: number; + foregroundIntersectionOverUnion: number; + referenceAlphaPixels: number; + actualAlphaPixels: number; + alphaCoverageDeltaRatio: number; + thresholds: RenderImageComparisonThresholdsIR; + checks: RenderImageComparisonCheckIR[]; + errorCode: typeof RENDER_REFERENCE_MISMATCH_CODE | null; +} + +function finiteRange(value: number, minimum: number, maximum: number, label: string): number { + if (!Number.isFinite(value) || value < minimum || value > maximum) { + throw new Error(`INVALID_ARGUMENT: ${label} is outside ${minimum}..${maximum}`); + } + return value; +} + +function validateThresholds(value: RenderImageComparisonThresholdsIR): RenderImageComparisonThresholdsIR { + return { + maxMeanAbsoluteError: finiteRange(value.maxMeanAbsoluteError, 0, 255, "maxMeanAbsoluteError"), + maxRootMeanSquaredError: finiteRange(value.maxRootMeanSquaredError, 0, 255, "maxRootMeanSquaredError"), + maxP95ChannelError: finiteRange(value.maxP95ChannelError, 0, 255, "maxP95ChannelError"), + maxBadPixelRatio: finiteRange(value.maxBadPixelRatio, 0, 1, "maxBadPixelRatio"), + badPixelChannelError: finiteRange(value.badPixelChannelError, 0, 255, "badPixelChannelError"), + foregroundDeltaFromReferenceBackground: finiteRange( + value.foregroundDeltaFromReferenceBackground, + 1, + 255, + "foregroundDeltaFromReferenceBackground", + ), + minForegroundIntersectionOverUnion: finiteRange( + value.minForegroundIntersectionOverUnion, + 0, + 1, + "minForegroundIntersectionOverUnion", + ), + maxAlphaCoverageDeltaRatio: finiteRange(value.maxAlphaCoverageDeltaRatio, 0, 1, "maxAlphaCoverageDeltaRatio"), + }; +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)]; +} + +function referenceBackground(reference: Uint8Array, width: number, height: number): [number, number, number] { + const cornerPixels = [0, width - 1, (height - 1) * width, height * width - 1]; + return [0, 1, 2].map((channel) => median(cornerPixels.map((pixel) => reference[pixel * 4 + channel]))) as [number, number, number]; +} + +function isForeground(bytes: Uint8Array, offset: number, background: readonly number[], threshold: number): boolean { + return Math.max( + Math.abs(bytes[offset] - background[0]), + Math.abs(bytes[offset + 1] - background[1]), + Math.abs(bytes[offset + 2] - background[2]), + ) >= threshold; +} + +function check( + metric: RenderImageComparisonCheckIR["metric"], + actual: number, + threshold: number, + comparison: RenderImageComparisonCheckIR["comparison"], +): RenderImageComparisonCheckIR { + return { metric, actual, threshold, comparison, passed: comparison === "LTE" ? actual <= threshold : actual >= threshold }; +} + +/** Compares equal-size display-referred sRGB8 frames and reports every release-gate metric. */ +export function compareRenderImages( + reference: Uint8Array, + actual: Uint8Array, + width: number, + height: number, + thresholdValue: RenderImageComparisonThresholdsIR, +): RenderImageComparisonIR { + if ( + !(reference instanceof Uint8Array) || !(actual instanceof Uint8Array) || + !Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || + width > MAX_RENDER_COMPARISON_DIMENSION || height > MAX_RENDER_COMPARISON_DIMENSION || + width * height > MAX_RENDER_COMPARISON_PIXELS || + reference.byteLength !== width * height * 4 || actual.byteLength !== reference.byteLength + ) { + throw new Error("INVALID_ARGUMENT: render reference and actual must be equal, bounded RGBA8 frames"); + } + const thresholds = validateThresholds(thresholdValue); + const pixelCount = width * height; + const channelHistogram = new Uint32Array(256); + const background = referenceBackground(reference, width, height); + let absoluteTotal = 0; + let squaredTotal = 0; + let maxChannelError = 0; + let badPixelCount = 0; + let referenceForegroundPixels = 0; + let actualForegroundPixels = 0; + let foregroundIntersectionPixels = 0; + let foregroundUnionPixels = 0; + let referenceAlphaPixels = 0; + let actualAlphaPixels = 0; + + for (let pixel = 0; pixel < pixelCount; pixel++) { + const offset = pixel * 4; + let pixelMaximum = 0; + for (let channel = 0; channel < 3; channel++) { + const difference = Math.abs(reference[offset + channel] - actual[offset + channel]); + absoluteTotal += difference; + squaredTotal += difference * difference; + pixelMaximum = Math.max(pixelMaximum, difference); + maxChannelError = Math.max(maxChannelError, difference); + channelHistogram[difference]++; + } + if (pixelMaximum > thresholds.badPixelChannelError) badPixelCount++; + const referenceForeground = isForeground(reference, offset, background, thresholds.foregroundDeltaFromReferenceBackground); + const actualForeground = isForeground(actual, offset, background, thresholds.foregroundDeltaFromReferenceBackground); + if (referenceForeground) referenceForegroundPixels++; + if (actualForeground) actualForegroundPixels++; + if (referenceForeground && actualForeground) foregroundIntersectionPixels++; + if (referenceForeground || actualForeground) foregroundUnionPixels++; + if (reference[offset + 3] >= 128) referenceAlphaPixels++; + if (actual[offset + 3] >= 128) actualAlphaPixels++; + } + + const comparedRGBChannels = pixelCount * 3; + const meanAbsoluteError = absoluteTotal / comparedRGBChannels; + const rootMeanSquaredError = Math.sqrt(squaredTotal / comparedRGBChannels); + const percentileTarget = Math.ceil(comparedRGBChannels * 0.95); + let percentileCount = 0; + let p95ChannelError = 0; + for (; p95ChannelError < channelHistogram.length; p95ChannelError++) { + percentileCount += channelHistogram[p95ChannelError]; + if (percentileCount >= percentileTarget) break; + } + const badPixelRatio = badPixelCount / pixelCount; + const foregroundIntersectionOverUnion = foregroundUnionPixels === 0 ? 1 : foregroundIntersectionPixels / foregroundUnionPixels; + const alphaCoverageDeltaRatio = Math.abs(referenceAlphaPixels - actualAlphaPixels) / pixelCount; + const checks = [ + check("MEAN_ABSOLUTE_ERROR", meanAbsoluteError, thresholds.maxMeanAbsoluteError, "LTE"), + check("ROOT_MEAN_SQUARED_ERROR", rootMeanSquaredError, thresholds.maxRootMeanSquaredError, "LTE"), + check("P95_CHANNEL_ERROR", p95ChannelError, thresholds.maxP95ChannelError, "LTE"), + check("BAD_PIXEL_RATIO", badPixelRatio, thresholds.maxBadPixelRatio, "LTE"), + check("FOREGROUND_INTERSECTION_OVER_UNION", foregroundIntersectionOverUnion, thresholds.minForegroundIntersectionOverUnion, "GTE"), + check("ALPHA_COVERAGE_DELTA_RATIO", alphaCoverageDeltaRatio, thresholds.maxAlphaCoverageDeltaRatio, "LTE"), + ]; + const matches = checks.every((item) => item.passed); + return { + schemaVersion: RENDER_IMAGE_COMPARISON_SCHEMA_VERSION, + colorSpace: "SRGB8", + alphaMode: "STRAIGHT", + status: matches ? "READY" : "BLOCKED", + width, + height, + pixelCount, + comparedRGBChannels, + meanAbsoluteError, + rootMeanSquaredError, + p95ChannelError, + maxChannelError, + badPixelCount, + badPixelRatio, + referenceBackground: background, + referenceForegroundPixels, + actualForegroundPixels, + foregroundIntersectionPixels, + foregroundUnionPixels, + foregroundIntersectionOverUnion, + referenceAlphaPixels, + actualAlphaPixels, + alphaCoverageDeltaRatio, + thresholds, + checks, + errorCode: matches ? null : RENDER_REFERENCE_MISMATCH_CODE, + }; +} diff --git a/web/protocol/render-routing.ts b/web/protocol/render-routing.ts new file mode 100644 index 00000000..7a144680 --- /dev/null +++ b/web/protocol/render-routing.ts @@ -0,0 +1,105 @@ +import type { ErrorCode } from "./error"; + +export const RENDER_ROUTING_SCHEMA_VERSION = 1 as const; + +export type RenderRoutingBackend = + | "WEBGL2" + | "WEBGPU" + | "CYCLES" + | "EEVEE_COMPLEX" + | "CUDA" + | "OPTIX" + | "HIP" + | "METAL" + | "ONEAPI"; +export type RenderRoutingEngine = "BLENDER_EEVEE" | "BLENDER_EEVEE_NEXT" | "BLENDER_CYCLES" | "BLENDER_WORKBENCH"; +export type RenderRoutingTarget = "WEB_LOCAL_BOUNDED" | "SERVER_JOB"; + +export interface RenderRoutingRequestIR { + schemaVersion: typeof RENDER_ROUTING_SCHEMA_VERSION; + renderEngine: string; + backend: RenderRoutingBackend; + complexity: "BOUNDED" | "COMPLEX"; + hardwareBackend?: "NONE" | "CUDA" | "OPTIX" | "HIP" | "METAL" | "ONEAPI"; +} + +export interface RenderRoutingContextIR { + webgpuAvailable?: boolean; + webgpuRendererBundled?: boolean; + serverRenderAvailable?: boolean; +} + +export interface RenderRoutingResultIR { + schemaVersion: typeof RENDER_ROUTING_SCHEMA_VERSION; + target: RenderRoutingTarget; + status: "READY" | "BLOCKED"; + capability: "WEB_REALTIME_BOUNDED" | "CYCLES_SERVER_RENDER" | "COMPLEX_EEVEE_SERVER_RENDER" | + "HARDWARE_SERVER_RENDER" | "WORKBENCH_SERVER_RENDER" | "UNSUPPORTED_RENDER_ENGINE"; + reason: "BOUNDED_EEVEE" | "WEBGPU_UNAVAILABLE" | "CYCLES_REQUIRES_SERVER" | + "COMPLEX_EEVEE_REQUIRES_SERVER" | "HARDWARE_BACKEND_REQUIRES_SERVER" | + "WORKBENCH_REQUIRES_SERVER" | "SERVER_ENDPOINT_UNAVAILABLE" | "UNSUPPORTED_ENGINE"; + issues: Array<{ code: ErrorCode; message: string; recoverable: boolean }>; +} + +function blocked( + target: RenderRoutingTarget, + capability: RenderRoutingResultIR["capability"], + reason: RenderRoutingResultIR["reason"], + code: ErrorCode, + message: string, +): RenderRoutingResultIR { + return { + schemaVersion: RENDER_ROUTING_SCHEMA_VERSION, + target, + status: "BLOCKED", + capability, + reason, + issues: [{ code, message, recoverable: true }], + }; +} + +function ready(target: RenderRoutingTarget, capability: RenderRoutingResultIR["capability"], reason: RenderRoutingResultIR["reason"]): RenderRoutingResultIR { + return { schemaVersion: RENDER_ROUTING_SCHEMA_VERSION, target, status: "READY", capability, reason, issues: [] }; +} + +function validateRequest(request: RenderRoutingRequestIR): void { + if (!request || request.schemaVersion !== RENDER_ROUTING_SCHEMA_VERSION) throw new Error("INVALID_ARGUMENT: render routing schema is unsupported"); + if (!Object.hasOwn({ WEBGL2: true, WEBGPU: true, CYCLES: true, EEVEE_COMPLEX: true, CUDA: true, OPTIX: true, HIP: true, METAL: true, ONEAPI: true }, request.backend)) { + throw new Error("INVALID_ARGUMENT: render routing backend is unsupported"); + } + if (typeof request.renderEngine !== "string" || !/^[A-Z0-9_]{1,64}$/.test(request.renderEngine)) throw new Error("INVALID_ARGUMENT: render routing engine identity is invalid"); + if (request.complexity !== "BOUNDED" && request.complexity !== "COMPLEX") throw new Error("INVALID_ARGUMENT: render routing complexity is unsupported"); + if (request.hardwareBackend !== undefined && !Object.hasOwn({ NONE: true, CUDA: true, OPTIX: true, HIP: true, METAL: true, ONEAPI: true }, request.hardwareBackend)) { + throw new Error("INVALID_ARGUMENT: render routing hardware backend is unsupported"); + } +} + +/** Routes final-render-only capabilities without claiming a local approximation is equivalent. */ +export function routeRenderExecution(request: RenderRoutingRequestIR, context: RenderRoutingContextIR = {}): RenderRoutingResultIR { + validateRequest(request); + const hardware = request.hardwareBackend && request.hardwareBackend !== "NONE" ? request.hardwareBackend : undefined; + if (hardware || ["CUDA", "OPTIX", "HIP", "METAL", "ONEAPI"].includes(request.backend)) { + if (!context.serverRenderAvailable) return blocked("SERVER_JOB", "HARDWARE_SERVER_RENDER", "HARDWARE_BACKEND_REQUIRES_SERVER", "SERVER_JOB_UNAVAILABLE", `Hardware backend ${hardware ?? request.backend} requires a configured server render job`); + return ready("SERVER_JOB", "HARDWARE_SERVER_RENDER", "HARDWARE_BACKEND_REQUIRES_SERVER"); + } + if (request.renderEngine === "BLENDER_CYCLES" || request.backend === "CYCLES") { + if (!context.serverRenderAvailable) return blocked("SERVER_JOB", "CYCLES_SERVER_RENDER", "CYCLES_REQUIRES_SERVER", "SERVER_JOB_UNAVAILABLE", "Cycles final rendering requires a configured server render job"); + return ready("SERVER_JOB", "CYCLES_SERVER_RENDER", "CYCLES_REQUIRES_SERVER"); + } + if (request.renderEngine === "BLENDER_WORKBENCH") { + if (!context.serverRenderAvailable) return blocked("SERVER_JOB", "WORKBENCH_SERVER_RENDER", "WORKBENCH_REQUIRES_SERVER", "SERVER_JOB_UNAVAILABLE", "Workbench final rendering is not a Web realtime equivalent and requires a configured server render job"); + return ready("SERVER_JOB", "WORKBENCH_SERVER_RENDER", "WORKBENCH_REQUIRES_SERVER"); + } + if (request.renderEngine === "BLENDER_EEVEE" || request.renderEngine === "BLENDER_EEVEE_NEXT") { + if (request.complexity === "COMPLEX" || request.backend === "EEVEE_COMPLEX") { + if (!context.serverRenderAvailable) return blocked("SERVER_JOB", "COMPLEX_EEVEE_SERVER_RENDER", "COMPLEX_EEVEE_REQUIRES_SERVER", "SERVER_JOB_UNAVAILABLE", "Complex Eevee final rendering requires a configured server render job"); + return ready("SERVER_JOB", "COMPLEX_EEVEE_SERVER_RENDER", "COMPLEX_EEVEE_REQUIRES_SERVER"); + } + if (request.backend === "WEBGPU" && !(context.webgpuAvailable && context.webgpuRendererBundled)) { + return blocked("WEB_LOCAL_BOUNDED", "WEB_REALTIME_BOUNDED", "WEBGPU_UNAVAILABLE", "WEBGPU_RENDERER_UNAVAILABLE", "The requested WebGPU realtime renderer is unavailable"); + } + if (request.backend !== "WEBGL2" && request.backend !== "WEBGPU") throw new Error("INVALID_ARGUMENT: bounded Eevee requires WEBGL2 or WEBGPU"); + return ready("WEB_LOCAL_BOUNDED", "WEB_REALTIME_BOUNDED", "BOUNDED_EEVEE"); + } + return blocked("SERVER_JOB", "UNSUPPORTED_RENDER_ENGINE", "UNSUPPORTED_ENGINE", "PLATFORM_CAPABILITY_UNAVAILABLE", "The requested render engine has no declared Web or server route"); +} diff --git a/web/protocol/scene-ir.ts b/web/protocol/scene-ir.ts index 346528a5..95e027b0 100644 --- a/web/protocol/scene-ir.ts +++ b/web/protocol/scene-ir.ts @@ -7,6 +7,7 @@ import { normalizeProjectAssetPath } from "./asset-path"; import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow"; import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform"; import { parsePhysicsSimulationManifest, type PhysicsSimulationManifestIR } from "./physics-simulation"; +import { GEOMETRY_NODE_GRAPH_BUDGET, parseGeometryNodeGraph, type GeometryNodeGraphIR } from "./geometry-nodes"; export type SceneNodeType = | "EMPTY" @@ -193,6 +194,8 @@ export interface MaterialIR { normalImageId?: string | null; imageIds?: string[]; warnings?: string[]; + /** SHA-256 of the serialized bounded shader graph when a node tree was read. */ + shaderGraphHash?: string; nodes?: MaterialNodeIR[]; links?: MaterialLinkIR[]; } @@ -269,6 +272,7 @@ export interface ImageIR { width?: number; height?: number; sha256?: string; + colorSpace?: "SRGB" | "NON_COLOR" | "LINEAR"; sourcePath?: string; packed?: boolean; packedByteLength?: number; @@ -369,6 +373,8 @@ export interface VFontResourceIR { sourcePath: string; builtin: boolean; packed: boolean; + packedByteLength?: number; + sha256?: string; } export interface NonMeshVolumePropertiesIR { @@ -493,6 +499,7 @@ export interface SceneSnapshotIR { scriptSources?: ScriptSourceInventoryIR; scriptSourceStatus?: "AVAILABLE" | "BLOCKED"; physicsSimulation?: PhysicsSimulationManifestIR; + geometryNodeGraphs?: GeometryNodeGraphIR[]; libraries?: Array<{ id: string; name: string; @@ -738,6 +745,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { if (image.sourcePath !== undefined) requireString(image.sourcePath, `images[${index}].sourcePath`); if (image.packed !== undefined) requireBoolean(image.packed, `images[${index}].packed`); if (image.packedByteLength !== undefined) requireNumber(image.packedByteLength, `images[${index}].packedByteLength`); + if (image.colorSpace !== undefined && !["SRGB", "NON_COLOR", "LINEAR"].includes(image.colorSpace as string)) throw new Error(`images[${index}].colorSpace is invalid`); if (image.sourceKind !== undefined) requireString(image.sourceKind, `images[${index}].sourceKind`); if (image.assetStatus !== undefined) requireString(image.assetStatus, `images[${index}].assetStatus`); if (image.libraryLinked !== undefined) requireBoolean(image.libraryLinked, `images[${index}].libraryLinked`); @@ -757,6 +765,15 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { requireString(font.sourcePath, `vfonts[${index}].sourcePath`); requireBoolean(font.builtin, `vfonts[${index}].builtin`); requireBoolean(font.packed, `vfonts[${index}].packed`); + if (font.packed) { + if (!Number.isSafeInteger(font.packedByteLength) || (font.packedByteLength as number) <= 0 || + typeof font.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(font.sha256)) { + throw new Error(`SceneIR.vfonts[${index}] packed identity is invalid`); + } + } + else if (font.packedByteLength !== undefined || font.sha256 !== undefined) { + throw new Error(`SceneIR.vfonts[${index}] unpacked identity is invalid`); + } } } for (const [index, data] of ((value.nonMeshData ?? []) as unknown[]).entries()) { @@ -1144,5 +1161,14 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR { if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present"); } if (value.physicsSimulation !== undefined) parsePhysicsSimulationManifest(value.physicsSimulation); + if (value.geometryNodeGraphs !== undefined) { + const graphs = requireArray(value.geometryNodeGraphs, "geometryNodeGraphs"); + if (graphs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxGraphs) throw new Error("SceneIR.geometryNodeGraphs exceeds the graph budget"); + const graphIds = new Set(); + for (const [index, graph] of graphs.entries()) { + const parsed = parseGeometryNodeGraph(graph); + if (!graphIds.add(parsed.id)) throw new Error(`SceneIR.geometryNodeGraphs[${index}] has a duplicate graph ID`); + } + } return value as unknown as SceneSnapshotIR; } diff --git a/web/protocol/sequencer-audio-session.ts b/web/protocol/sequencer-audio-session.ts new file mode 100644 index 00000000..7e2389eb --- /dev/null +++ b/web/protocol/sequencer-audio-session.ts @@ -0,0 +1,86 @@ +import type { ErrorCode } from "./error"; + +export const SEQUENCER_AUDIO_SESSION_SCHEMA = 1 as const; +export type SequencerAudioContextState = "UNAVAILABLE" | "SUSPENDED" | "RUNNING" | "CLOSED"; +export type SequencerAudioOutputState = "BLOCKED" | "SILENT" | "ENABLED"; +export type SequencerAudioSessionIssueCode = Extract< + ErrorCode, + | "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE" + | "SEQUENCER_AUDIO_RESUME_FAILED" + | "SEQUENCER_AUDIO_SUSPEND_FAILED" +>; + +export interface SequencerAudioSessionReportIR { + schemaVersion: typeof SEQUENCER_AUDIO_SESSION_SCHEMA; + revision: number; + contextState: SequencerAudioContextState; + outputState: SequencerAudioOutputState; + muted: boolean; + outputGain: number; + issueCode: SequencerAudioSessionIssueCode | null; +} + +export class SequencerAudioSessionValidationError extends Error { + readonly code: ErrorCode; + + constructor(message: string) { + super(`SEQUENCER_AUDIO_CONTEXT_INVALID: ${message}`); + this.name = "SequencerAudioSessionValidationError"; + this.code = "SEQUENCER_AUDIO_CONTEXT_INVALID"; + } +} + +const REPORT_KEYS = new Set([ + "schemaVersion", "revision", "contextState", "outputState", "muted", "outputGain", "issueCode", +]); +const CONTEXT_STATES = new Set(["UNAVAILABLE", "SUSPENDED", "RUNNING", "CLOSED"]); +const OUTPUT_STATES = new Set(["BLOCKED", "SILENT", "ENABLED"]); +const ISSUE_CODES = new Set([ + "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE", + "SEQUENCER_AUDIO_RESUME_FAILED", + "SEQUENCER_AUDIO_SUSPEND_FAILED", +]); + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseSequencerAudioSessionReport(value: unknown): SequencerAudioSessionReportIR { + if (!record(value) || value.schemaVersion !== SEQUENCER_AUDIO_SESSION_SCHEMA || + Object.keys(value).length !== REPORT_KEYS.size || Object.keys(value).some((key) => !REPORT_KEYS.has(key))) { + throw new SequencerAudioSessionValidationError("audio session report fields are invalid"); + } + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || + !CONTEXT_STATES.has(value.contextState as SequencerAudioContextState) || + !OUTPUT_STATES.has(value.outputState as SequencerAudioOutputState) || + typeof value.muted !== "boolean" || typeof value.outputGain !== "number" || + !Number.isFinite(value.outputGain) || value.outputGain < 0 || value.outputGain > 1 || + (value.issueCode !== null && !ISSUE_CODES.has(value.issueCode as SequencerAudioSessionIssueCode))) { + throw new SequencerAudioSessionValidationError("audio session report values are invalid"); + } + + const report = value as unknown as SequencerAudioSessionReportIR; + if (report.contextState === "UNAVAILABLE" && + (report.outputState !== "BLOCKED" || report.outputGain !== 0 || + report.issueCode !== "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE")) { + throw new SequencerAudioSessionValidationError("unavailable audio context must be blocked"); + } + if (report.contextState === "CLOSED" && + (report.outputState !== "SILENT" || report.outputGain !== 0 || report.issueCode !== null)) { + throw new SequencerAudioSessionValidationError("closed audio context must release output"); + } + if ((report.contextState === "RUNNING" || report.contextState === "SUSPENDED") && report.outputState === "BLOCKED") { + throw new SequencerAudioSessionValidationError("an allocated audio context cannot report blocked output"); + } + if (report.contextState === "SUSPENDED" && report.outputState !== "SILENT") { + throw new SequencerAudioSessionValidationError("a suspended audio context must be silent"); + } + if (report.outputState === "ENABLED" && + (report.contextState !== "RUNNING" || report.muted || report.outputGain <= 0 || report.issueCode !== null)) { + throw new SequencerAudioSessionValidationError("enabled audio output invariants are invalid"); + } + if (report.muted && report.outputGain !== 0) { + throw new SequencerAudioSessionValidationError("muted audio output gain must be zero"); + } + return { ...report }; +} diff --git a/web/protocol/sequencer-export.ts b/web/protocol/sequencer-export.ts new file mode 100644 index 00000000..6276acc0 --- /dev/null +++ b/web/protocol/sequencer-export.ts @@ -0,0 +1,184 @@ +import type { ErrorCode } from "./error"; + +export const SEQUENCER_FINAL_EXPORT_SCHEMA = 1 as const; +export type SequencerFinalContainer = "MPEG4" | "WEBM" | "QUICKTIME"; +export type SequencerFinalVideoCodec = "H264" | "VP9" | "PRORES"; +export type SequencerFinalAudioCodec = "AAC" | "OPUS" | "PCM" | "NONE"; + +export interface SequencerFinalExportRequestIR { + schemaVersion: typeof SEQUENCER_FINAL_EXPORT_SCHEMA; + timelineId: string; + timelineRevision: number; + sourceBlendSha256: string; + frameStart: number; + frameEnd: number; + fpsNumerator: number; + fpsDenominator: number; + width: number; + height: number; + container: SequencerFinalContainer; + videoCodec: SequencerFinalVideoCodec; + audioCodec: SequencerFinalAudioCodec; +} + +export interface SequencerFinalExportEnvironmentIR { + serverExportAvailable: boolean; + browserVideoEncoderAvailable: boolean; +} + +export interface SequencerFinalExportRouteIR { + schemaVersion: typeof SEQUENCER_FINAL_EXPORT_SCHEMA; + requestSha256: string; + settingsSha256: string; + route: "SERVER_EXPORT"; + status: "SERVER_EXPORT_REQUIRED" | "BLOCKED"; + code: ErrorCode | null; + localEncoding: "BLOCKED"; + browserVideoEncoderDetected: boolean; +} + +export class SequencerFinalExportValidationError extends Error { + readonly code: ErrorCode; + + constructor(code: ErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "SequencerFinalExportValidationError"; + this.code = code; + } +} + +const SHA256 = /^[a-f0-9]{64}$/; +const ID = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$/; +const REQUEST_KEYS = new Set([ + "schemaVersion", "timelineId", "timelineRevision", "sourceBlendSha256", "frameStart", "frameEnd", + "fpsNumerator", "fpsDenominator", "width", "height", "container", "videoCodec", "audioCodec", +]); +const ENVIRONMENT_KEYS = new Set(["serverExportAvailable", "browserVideoEncoderAvailable"]); +const CODEC_COMBINATIONS = new Set(["MPEG4:H264:AAC", "MPEG4:H264:NONE", "WEBM:VP9:OPUS", "WEBM:VP9:NONE", "QUICKTIME:PRORES:PCM", "QUICKTIME:PRORES:NONE"]); + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} must be an object`); + } + return value as Record; +} + +function exactKeys(value: Record, fields: ReadonlySet, label: string): void { + if (Object.keys(value).length !== fields.size || Object.keys(value).some((field) => !fields.has(field))) { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} fields are invalid`); + } +} + +function integer(value: unknown, label: string, minimum: number, maximum: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} is outside the bounded range`); + } + return value; +} + +function enumValue(value: unknown, values: readonly T[], label: string): T { + if (typeof value !== "string" || !values.includes(value as T)) { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} is unsupported`); + } + return value as T; +} + +async function sha256(value: string): Promise { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export function parseSequencerFinalExportRequest(value: unknown): SequencerFinalExportRequestIR { + const input = record(value, "Sequencer final export request"); + exactKeys(input, REQUEST_KEYS, "Sequencer final export request"); + if (input.schemaVersion !== SEQUENCER_FINAL_EXPORT_SCHEMA) { + throw new SequencerFinalExportValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer final export schema"); + } + if (typeof input.timelineId !== "string" || !ID.test(input.timelineId) || + typeof input.sourceBlendSha256 !== "string" || !SHA256.test(input.sourceBlendSha256)) { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export source identity is invalid"); + } + const frameStart = integer(input.frameStart, "frameStart", -1_000_000, 1_000_000); + const frameEnd = integer(input.frameEnd, "frameEnd", -1_000_000, 1_000_000); + if (frameEnd < frameStart || frameEnd - frameStart + 1 > 1_000_000) { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export frame range is invalid"); + } + const width = integer(input.width, "width", 1, 16_384); + const height = integer(input.height, "height", 1, 16_384); + if (width * height > 67_108_864) { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export pixel dimensions exceed the budget"); + } + const container = enumValue(input.container, ["MPEG4", "WEBM", "QUICKTIME"] as const, "container"); + const videoCodec = enumValue(input.videoCodec, ["H264", "VP9", "PRORES"] as const, "videoCodec"); + const audioCodec = enumValue(input.audioCodec, ["AAC", "OPUS", "PCM", "NONE"] as const, "audioCodec"); + if (!CODEC_COMBINATIONS.has(`${container}:${videoCodec}:${audioCodec}`)) { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export codec combination is unsupported"); + } + return { + schemaVersion: SEQUENCER_FINAL_EXPORT_SCHEMA, + timelineId: input.timelineId, + timelineRevision: integer(input.timelineRevision, "timelineRevision", 0, Number.MAX_SAFE_INTEGER), + sourceBlendSha256: input.sourceBlendSha256, + frameStart, + frameEnd, + fpsNumerator: integer(input.fpsNumerator, "fpsNumerator", 1, 1_000_000), + fpsDenominator: integer(input.fpsDenominator, "fpsDenominator", 1, 1_000_000), + width, + height, + container, + videoCodec, + audioCodec, + }; +} + +export function parseSequencerFinalExportEnvironment(value: unknown): SequencerFinalExportEnvironmentIR { + const input = record(value, "Sequencer final export environment"); + exactKeys(input, ENVIRONMENT_KEYS, "Sequencer final export environment"); + if (typeof input.serverExportAvailable !== "boolean" || typeof input.browserVideoEncoderAvailable !== "boolean") { + throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export environment is invalid"); + } + return { + serverExportAvailable: input.serverExportAvailable, + browserVideoEncoderAvailable: input.browserVideoEncoderAvailable, + }; +} + +function canonicalSettings(request: SequencerFinalExportRequestIR): Record { + return { + frameStart: request.frameStart, + frameEnd: request.frameEnd, + fpsNumerator: request.fpsNumerator, + fpsDenominator: request.fpsDenominator, + width: request.width, + height: request.height, + container: request.container, + videoCodec: request.videoCodec, + audioCodec: request.audioCodec, + }; +} + +export async function routeSequencerFinalExport( + requestValue: unknown, + environmentValue: unknown, +): Promise { + const request = parseSequencerFinalExportRequest(requestValue); + const environment = parseSequencerFinalExportEnvironment(environmentValue); + const settingsSha256 = await sha256(JSON.stringify(canonicalSettings(request))); + const requestSha256 = await sha256(JSON.stringify({ + schemaVersion: request.schemaVersion, + timelineId: request.timelineId, + timelineRevision: request.timelineRevision, + sourceBlendSha256: request.sourceBlendSha256, + settingsSha256, + })); + return { + schemaVersion: SEQUENCER_FINAL_EXPORT_SCHEMA, + requestSha256, + settingsSha256, + route: "SERVER_EXPORT", + status: environment.serverExportAvailable ? "SERVER_EXPORT_REQUIRED" : "BLOCKED", + code: environment.serverExportAvailable ? null : "SEQUENCER_EXPORT_SERVER_UNAVAILABLE", + localEncoding: "BLOCKED", + browserVideoEncoderDetected: environment.browserVideoEncoderAvailable, + }; +} diff --git a/web/protocol/sequencer-media-cache.ts b/web/protocol/sequencer-media-cache.ts new file mode 100644 index 00000000..5f265457 --- /dev/null +++ b/web/protocol/sequencer-media-cache.ts @@ -0,0 +1,270 @@ +import type { ErrorCode } from "./error"; +import { + gateSequencerCodec, + parseSequencerCodecProbeRequest, + parseSequencerCodecProbeResult, + type SequencerCodecProbeRequestIR, + type SequencerCodecProbeResultIR, +} from "./sequencer"; + +export const SEQUENCER_MEDIA_CACHE_SCHEMA = 1 as const; +export const SEQUENCER_MEDIA_PROXY_MAX_BYTES = 64 * 1024 * 1024; + +export interface SequencerMediaProxyProfileIR { + kind: "MOVIE_RGBA8_FRAME"; + width: number; + height: number; + colorSpace: "SRGB8"; + alphaMode: "STRAIGHT"; +} + +export interface SequencerMediaCacheManifestIR { + schemaVersion: typeof SEQUENCER_MEDIA_CACHE_SCHEMA; + source: SequencerCodecProbeRequestIR; + decodeCapability: SequencerCodecProbeResultIR; + profile: SequencerMediaProxyProfileIR; + sourceFrame: number; + identitySha256: string; + payloadByteLength: number; + payloadSha256: string; +} + +export class SequencerMediaCacheValidationError extends Error { + readonly code: ErrorCode; + + constructor(code: ErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "SequencerMediaCacheValidationError"; + this.code = code; + } +} + +const SHA256 = /^[a-f0-9]{64}$/; +const MANIFEST_KEYS = new Set([ + "schemaVersion", "source", "decodeCapability", "profile", "sourceFrame", + "identitySha256", "payloadByteLength", "payloadSha256", +]); +const PROFILE_KEYS = new Set(["kind", "width", "height", "colorSpace", "alphaMode"]); + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be an object`); + } + return value as Record; +} + +function exactKeys(value: Record, allowed: ReadonlySet, label: string): void { + const unexpected = Object.keys(value).filter((key) => !allowed.has(key)); + if (unexpected.length > 0) { + throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} contains undeclared fields: ${unexpected.join(", ")}`); + } +} + +function integer(value: unknown, label: string, minimum: number, maximum: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is outside the bounded range`); + } + return value; +} + +function digest(value: unknown, label: string): string { + if (typeof value !== "string" || !SHA256.test(value)) { + throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be a lowercase SHA-256 digest`); + } + return value; +} + +async function sha256(data: ArrayBuffer): Promise { + const result = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join(""); +} + +function canonicalSource(source: SequencerCodecProbeRequestIR): Record { + return { + schemaVersion: source.schemaVersion, + stripType: source.stripType, + mimeType: source.mimeType, + byteLength: source.byteLength, + sourceSha256: source.sourceSha256, + }; +} + +function canonicalCapability(capability: SequencerCodecProbeResultIR): Record { + const decoded = capability.decoded === null ? null : capability.stripType === "IMAGE" ? { + width: capability.decoded.width, + height: capability.decoded.height, + } : capability.stripType === "SOUND" ? { + sampleRate: capability.decoded.sampleRate, + channels: capability.decoded.channels, + durationFrames: capability.decoded.durationFrames, + } : { + width: capability.decoded.width, + height: capability.decoded.height, + durationMicros: capability.decoded.durationMicros, + }; + return { + ...canonicalSource(capability), + status: capability.status, + backend: capability.backend, + reason: capability.reason, + decoded, + }; +} + +function canonicalProfile(profile: SequencerMediaProxyProfileIR): Record { + return { + kind: profile.kind, + width: profile.width, + height: profile.height, + colorSpace: profile.colorSpace, + alphaMode: profile.alphaMode, + }; +} + +function sameJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function parseReadyMovieCapability( + sourceValue: unknown, + capabilityValue: unknown, +): { source: SequencerCodecProbeRequestIR; capability: SequencerCodecProbeResultIR } { + const source = parseSequencerCodecProbeRequest(sourceValue); + const capability = parseSequencerCodecProbeResult(capabilityValue); + if (source.stripType !== "MOVIE" || gateSequencerCodec(source, capability).status !== "READY") { + throw new SequencerMediaCacheValidationError( + "SEQUENCER_CODEC_UNSUPPORTED", + "Movie proxy cache requires a source-bound READY runtime decode receipt", + ); + } + return { source, capability }; +} + +export function parseSequencerMediaProxyProfile(value: unknown): SequencerMediaProxyProfileIR { + const profile = record(value, "Sequencer media proxy profile"); + exactKeys(profile, PROFILE_KEYS, "Sequencer media proxy profile"); + if (profile.kind !== "MOVIE_RGBA8_FRAME" || profile.colorSpace !== "SRGB8" || profile.alphaMode !== "STRAIGHT") { + throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media proxy profile is unsupported"); + } + const width = integer(profile.width, "profile.width", 1, 16_384); + const height = integer(profile.height, "profile.height", 1, 16_384); + if (width * height * 4 > SEQUENCER_MEDIA_PROXY_MAX_BYTES) { + throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer media proxy frame exceeds the RGBA8 budget"); + } + return { kind: "MOVIE_RGBA8_FRAME", width, height, colorSpace: "SRGB8", alphaMode: "STRAIGHT" }; +} + +export async function computeSequencerMediaCacheIdentity( + sourceValue: unknown, + capabilityValue: unknown, + profileValue: unknown, + sourceFrameValue: unknown, +): Promise { + const { source, capability } = parseReadyMovieCapability(sourceValue, capabilityValue); + const profile = parseSequencerMediaProxyProfile(profileValue); + const sourceFrame = integer(sourceFrameValue, "sourceFrame", 0, 1_000_000); + const identity = JSON.stringify({ + schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA, + source: canonicalSource(source), + decodeCapability: canonicalCapability(capability), + profile: canonicalProfile(profile), + sourceFrame, + }); + return sha256(new TextEncoder().encode(identity).buffer as ArrayBuffer); +} + +export function parseSequencerMediaCacheManifest(value: unknown): SequencerMediaCacheManifestIR { + const manifest = record(value, "Sequencer media cache manifest"); + exactKeys(manifest, MANIFEST_KEYS, "Sequencer media cache manifest"); + if (manifest.schemaVersion !== SEQUENCER_MEDIA_CACHE_SCHEMA) { + throw new SequencerMediaCacheValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media cache schema"); + } + const { source, capability } = parseReadyMovieCapability(manifest.source, manifest.decodeCapability); + const profile = parseSequencerMediaProxyProfile(manifest.profile); + if (capability.decoded === null || capability.decoded.width === undefined || capability.decoded.height === undefined || + profile.width > capability.decoded.width || profile.height > capability.decoded.height) { + throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy profile exceeds the runtime decoded movie dimensions"); + } + const sourceFrame = integer(manifest.sourceFrame, "sourceFrame", 0, 1_000_000); + const payloadByteLength = integer(manifest.payloadByteLength, "payloadByteLength", 1, SEQUENCER_MEDIA_PROXY_MAX_BYTES); + if (payloadByteLength !== profile.width * profile.height * 4) { + throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy payload length does not match its RGBA8 profile"); + } + return { + schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA, + source, + decodeCapability: capability, + profile, + sourceFrame, + identitySha256: digest(manifest.identitySha256, "identitySha256"), + payloadByteLength, + payloadSha256: digest(manifest.payloadSha256, "payloadSha256"), + }; +} + +export async function createSequencerMediaCacheManifest( + sourceValue: unknown, + capabilityValue: unknown, + profileValue: unknown, + sourceFrameValue: unknown, + payload: ArrayBuffer, +): Promise { + if (!(payload instanceof ArrayBuffer) || payload.byteLength === 0 || payload.byteLength > SEQUENCER_MEDIA_PROXY_MAX_BYTES) { + throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer media proxy payload exceeds the byte budget"); + } + const { source, capability } = parseReadyMovieCapability(sourceValue, capabilityValue); + const profile = parseSequencerMediaProxyProfile(profileValue); + if (capability.decoded === null || capability.decoded.width === undefined || capability.decoded.height === undefined || + profile.width > capability.decoded.width || profile.height > capability.decoded.height) { + throw new SequencerMediaCacheValidationError("SEQUENCER_SCHEMA_INVALID", "Proxy profile exceeds the runtime decoded movie dimensions"); + } + const sourceFrame = integer(sourceFrameValue, "sourceFrame", 0, 1_000_000); + const manifest = { + schemaVersion: SEQUENCER_MEDIA_CACHE_SCHEMA, + source, + decodeCapability: capability, + profile, + sourceFrame, + identitySha256: await computeSequencerMediaCacheIdentity(source, capability, profile, sourceFrame), + payloadByteLength: payload.byteLength, + payloadSha256: await sha256(payload), + } satisfies SequencerMediaCacheManifestIR; + return parseSequencerMediaCacheManifest(manifest); +} + +export async function verifySequencerMediaCacheEntry( + manifestValue: unknown, + payload: ArrayBuffer, + currentSourceValue: unknown, + currentCapabilityValue: unknown, +): Promise { + const manifest = parseSequencerMediaCacheManifest(manifestValue); + const { source: currentSource, capability: currentCapability } = parseReadyMovieCapability( + currentSourceValue, + currentCapabilityValue, + ); + if (!sameJson(canonicalSource(manifest.source), canonicalSource(currentSource))) { + throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Proxy cache source identity is stale"); + } + if (!sameJson(canonicalCapability(manifest.decodeCapability), canonicalCapability(currentCapability))) { + throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_CAPABILITY_MISMATCH", "Proxy cache decode capability is stale"); + } + const identitySha256 = await computeSequencerMediaCacheIdentity( + manifest.source, + manifest.decodeCapability, + manifest.profile, + manifest.sourceFrame, + ); + if (identitySha256 !== manifest.identitySha256) { + throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_IDENTITY_MISMATCH", "Proxy cache identity hash is invalid"); + } + if (!(payload instanceof ArrayBuffer) || payload.byteLength !== manifest.payloadByteLength || await sha256(payload) !== manifest.payloadSha256) { + throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_HASH_MISMATCH", "Proxy cache payload failed SHA-256 verification"); + } + return manifest; +} + +export function sequencerMediaCacheKey(manifestValue: unknown): string { + const manifest = parseSequencerMediaCacheManifest(manifestValue); + return `sequencer-media-cache:v${manifest.schemaVersion}:${manifest.identitySha256}`; +} diff --git a/web/protocol/sequencer-media-revision.ts b/web/protocol/sequencer-media-revision.ts new file mode 100644 index 00000000..aa64791f --- /dev/null +++ b/web/protocol/sequencer-media-revision.ts @@ -0,0 +1,162 @@ +import type { ErrorCode } from "./error"; + +export const SEQUENCER_MEDIA_REVISION_SCHEMA = 1 as const; +export type SequencerMediaOperation = "SEEK" | "SCRUB" | "DECODE"; + +export interface SequencerMediaRevisionStateIR { + schemaVersion: typeof SEQUENCER_MEDIA_REVISION_SCHEMA; + timelineId: string; + timelineRevision: number; + latestRequestRevision: number; +} + +export interface SequencerMediaRevisionRequestIR { + schemaVersion: typeof SEQUENCER_MEDIA_REVISION_SCHEMA; + requestId: string; + timelineId: string; + timelineRevision: number; + requestRevision: number; + operation: SequencerMediaOperation; + frame: number; +} + +export interface SequencerMediaRevisionResultIR extends SequencerMediaRevisionRequestIR { + status: "COMPLETED"; + sourceFrame: number; + payloadSha256: string; +} + +export interface SequencerMediaRevisionDecisionIR { + status: "PUBLISH" | "STALE"; + code: ErrorCode | null; + operation: SequencerMediaOperation; + requestRevision: number; +} + +export class SequencerMediaRevisionValidationError extends Error { + readonly code: ErrorCode; + + constructor(code: ErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "SequencerMediaRevisionValidationError"; + this.code = code; + } +} + +const SHA256 = /^[a-f0-9]{64}$/; +const ID = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$/; +const OPERATIONS = new Set(["SEEK", "SCRUB", "DECODE"]); +const STATE_KEYS = new Set(["schemaVersion", "timelineId", "timelineRevision", "latestRequestRevision"]); +const REQUEST_KEYS = new Set([ + "schemaVersion", "requestId", "timelineId", "timelineRevision", "requestRevision", "operation", "frame", +]); +const RESULT_KEYS = new Set([...REQUEST_KEYS, "status", "sourceFrame", "payloadSha256"]); + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} must be an object`); + } + return value as Record; +} + +function exactKeys(value: Record, keys: ReadonlySet, label: string): void { + const actual = Object.keys(value); + if (actual.length !== keys.size || actual.some((key) => !keys.has(key))) { + throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} fields are invalid`); + } +} + +function identity(value: unknown, label: string): string { + if (typeof value !== "string" || !ID.test(value)) { + throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is invalid`); + } + return value; +} + +function integer(value: unknown, label: string, maximum = Number.MAX_SAFE_INTEGER): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) { + throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", `${label} is outside the bounded range`); + } + return value; +} + +export function parseSequencerMediaRevisionState(value: unknown): SequencerMediaRevisionStateIR { + const state = record(value, "Sequencer media revision state"); + exactKeys(state, STATE_KEYS, "Sequencer media revision state"); + if (state.schemaVersion !== SEQUENCER_MEDIA_REVISION_SCHEMA) { + throw new SequencerMediaRevisionValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media revision schema"); + } + return { + schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA, + timelineId: identity(state.timelineId, "timelineId"), + timelineRevision: integer(state.timelineRevision, "timelineRevision"), + latestRequestRevision: integer(state.latestRequestRevision, "latestRequestRevision"), + }; +} + +export function parseSequencerMediaRevisionRequest(value: unknown): SequencerMediaRevisionRequestIR { + const request = record(value, "Sequencer media revision request"); + exactKeys(request, REQUEST_KEYS, "Sequencer media revision request"); + if (request.schemaVersion !== SEQUENCER_MEDIA_REVISION_SCHEMA) { + throw new SequencerMediaRevisionValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer media revision schema"); + } + if (!OPERATIONS.has(request.operation as SequencerMediaOperation)) { + throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media operation is invalid"); + } + return { + schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA, + requestId: identity(request.requestId, "requestId"), + timelineId: identity(request.timelineId, "timelineId"), + timelineRevision: integer(request.timelineRevision, "timelineRevision"), + requestRevision: integer(request.requestRevision, "requestRevision"), + operation: request.operation as SequencerMediaOperation, + frame: integer(request.frame, "frame", 1_000_000), + }; +} + +export function parseSequencerMediaRevisionResult(value: unknown): SequencerMediaRevisionResultIR { + const result = record(value, "Sequencer media revision result"); + exactKeys(result, RESULT_KEYS, "Sequencer media revision result"); + const request = parseSequencerMediaRevisionRequest({ + schemaVersion: result.schemaVersion, + requestId: result.requestId, + timelineId: result.timelineId, + timelineRevision: result.timelineRevision, + requestRevision: result.requestRevision, + operation: result.operation, + frame: result.frame, + }); + if (result.status !== "COMPLETED" || typeof result.payloadSha256 !== "string" || !SHA256.test(result.payloadSha256)) { + throw new SequencerMediaRevisionValidationError("SEQUENCER_SCHEMA_INVALID", "Sequencer media completed result is invalid"); + } + return { + ...request, + status: "COMPLETED", + sourceFrame: integer(result.sourceFrame, "sourceFrame", 1_000_000), + payloadSha256: result.payloadSha256, + }; +} + +function sameRequest(request: SequencerMediaRevisionRequestIR, result: SequencerMediaRevisionResultIR): boolean { + return request.requestId === result.requestId && request.timelineId === result.timelineId && + request.timelineRevision === result.timelineRevision && request.requestRevision === result.requestRevision && + request.operation === result.operation && request.frame === result.frame; +} + +export function gateSequencerMediaRevision( + requestValue: unknown, + stateValue: unknown, + resultValue: unknown, +): SequencerMediaRevisionDecisionIR { + const request = parseSequencerMediaRevisionRequest(requestValue); + const state = parseSequencerMediaRevisionState(stateValue); + const result = parseSequencerMediaRevisionResult(resultValue); + const stale = !sameRequest(request, result) || request.timelineId !== state.timelineId || + request.timelineRevision !== state.timelineRevision || request.requestRevision !== state.latestRequestRevision; + return { + status: stale ? "STALE" : "PUBLISH", + code: stale ? "REVISION_CONFLICT" : null, + operation: request.operation, + requestRevision: request.requestRevision, + }; +} diff --git a/web/protocol/sequencer.ts b/web/protocol/sequencer.ts index 25683788..73e86f0a 100644 --- a/web/protocol/sequencer.ts +++ b/web/protocol/sequencer.ts @@ -67,6 +67,38 @@ export interface SequencerRuntimeCapabilityIR { localEncoding: "BLOCKED"; } +export const SEQUENCER_CODEC_PROBE_SCHEMA = 1 as const; +export const SEQUENCER_CODEC_PROBE_MAX_BYTES = 512 * 1024 * 1024; +export type SequencerCodecStripType = "IMAGE" | "SOUND" | "MOVIE"; +export type SequencerCodecProbeBackend = "IMAGE_BITMAP" | "WEB_AUDIO" | "HTML_MEDIA"; +export type SequencerCodecProbeBlockReason = + | "RUNTIME_UNAVAILABLE" + | "MIME_UNSUPPORTED" + | "SOURCE_IDENTITY_MISMATCH" + | "DECODE_FAILED"; + +export interface SequencerCodecProbeRequestIR { + schemaVersion: typeof SEQUENCER_CODEC_PROBE_SCHEMA; + stripType: SequencerCodecStripType; + mimeType: string; + byteLength: number; + sourceSha256: string; +} + +export interface SequencerCodecProbeResultIR extends SequencerCodecProbeRequestIR { + status: "READY" | "BLOCKED"; + backend: SequencerCodecProbeBackend | null; + reason: SequencerCodecProbeBlockReason | null; + decoded: { + width?: number; + height?: number; + sampleRate?: number; + channels?: number; + durationFrames?: number; + durationMicros?: number; + } | null; +} + export interface SequencerFrameStripIR { stripId: string; channel: number; @@ -94,6 +126,20 @@ export class SequencerValidationError extends Error { const SHA256 = /^[a-f0-9]{64}$/; const STRIP_TYPES = new Set(["SCENE", "MOVIE", "IMAGE", "SOUND", "EFFECT", "META"]); +const CODEC_STRIP_TYPES = new Set(["IMAGE", "SOUND", "MOVIE"]); +const CODEC_BACKENDS: Readonly> = { + IMAGE: "IMAGE_BITMAP", + SOUND: "WEB_AUDIO", + MOVIE: "HTML_MEDIA", +}; +const CODEC_MIME_PREFIX: Readonly> = { + IMAGE: "image/", + SOUND: "audio/", + MOVIE: "video/", +}; +const CODEC_BLOCK_REASONS = new Set([ + "RUNTIME_UNAVAILABLE", "MIME_UNSUPPORTED", "SOURCE_IDENTITY_MISMATCH", "DECODE_FAILED", +]); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -308,7 +354,73 @@ export function sequencerRuntimeCapabilities(scope: typeof globalThis = globalTh }; } -export function gateSequencerCodec(mimeType: string, verifiedMimeTypes: ReadonlySet): CapabilityGateResult { - if (verifiedMimeTypes.has(mimeType)) return readyGate("N-021", `CODEC_${mimeType}`); - return blockedGate("N-021", `CODEC_${mimeType}`, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", `Codec ${mimeType} has not passed an exact seek/decode probe`)]); +function exactCodecProbeKeys(value: Record, names: readonly string[], label: string): void { + const allowed = new Set(names); + const unexpected = Object.keys(value).filter((key) => !allowed.has(key)); + if (unexpected.length > 0) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${label} contains undeclared fields: ${unexpected.join(", ")}`); +} + +export function parseSequencerCodecProbeRequest(value: unknown): SequencerCodecProbeRequestIR { + if (!record(value) || value.schemaVersion !== SEQUENCER_CODEC_PROBE_SCHEMA || !CODEC_STRIP_TYPES.has(value.stripType as SequencerCodecStripType)) { + throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer codec probe request"); + } + exactCodecProbeKeys(value, ["schemaVersion", "stripType", "mimeType", "byteLength", "sourceSha256"], "Codec probe request"); + const stripType = value.stripType as SequencerCodecStripType; + const mimeType = text(value.mimeType, "mimeType", 128); + if (mimeType !== mimeType.toLowerCase() || !mimeType.startsWith(CODEC_MIME_PREFIX[stripType]) || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mimeType)) { + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Codec MIME ${mimeType} does not match ${stripType}`); + } + const byteLength = integer(value.byteLength, "byteLength", 1, SEQUENCER_CODEC_PROBE_MAX_BYTES); + if (typeof value.sourceSha256 !== "string" || !SHA256.test(value.sourceSha256)) { + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Codec probe sourceSha256 is invalid"); + } + return { schemaVersion: SEQUENCER_CODEC_PROBE_SCHEMA, stripType, mimeType, byteLength, sourceSha256: value.sourceSha256 }; +} + +export function parseSequencerCodecProbeResult(value: unknown): SequencerCodecProbeResultIR { + if (!record(value)) throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer codec probe result"); + exactCodecProbeKeys(value, ["schemaVersion", "stripType", "mimeType", "byteLength", "sourceSha256", "status", "backend", "reason", "decoded"], "Codec probe result"); + const request = parseSequencerCodecProbeRequest({ + schemaVersion: value.schemaVersion, + stripType: value.stripType, + mimeType: value.mimeType, + byteLength: value.byteLength, + sourceSha256: value.sourceSha256, + }); + if (value.status !== "READY" && value.status !== "BLOCKED") throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Codec probe status is invalid"); + if (value.status === "BLOCKED") { + if ((value.backend !== null && value.backend !== CODEC_BACKENDS[request.stripType]) || + !CODEC_BLOCK_REASONS.has(value.reason as SequencerCodecProbeBlockReason) || value.decoded !== null) { + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Blocked codec probe result is invalid"); + } + } + else { + if (value.backend !== CODEC_BACKENDS[request.stripType] || value.reason !== null || !record(value.decoded)) { + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Ready codec probe result is invalid"); + } + const decodedKeys = request.stripType === "IMAGE" ? ["width", "height"] : + request.stripType === "SOUND" ? ["sampleRate", "channels", "durationFrames"] : + ["width", "height", "durationMicros"]; + exactCodecProbeKeys(value.decoded, decodedKeys, "Codec probe decoded result"); + for (const key of decodedKeys) integer(value.decoded[key], `decoded.${key}`, 1, Number.MAX_SAFE_INTEGER); + } + return value as unknown as SequencerCodecProbeResultIR; +} + +export function gateSequencerCodec(requestValue: unknown, resultValue: unknown): CapabilityGateResult { + let capability = "CODEC_RUNTIME_PROBE"; + try { + const request = parseSequencerCodecProbeRequest(requestValue); + capability = `CODEC_${request.stripType}_${request.mimeType}`; + const result = parseSequencerCodecProbeResult(resultValue); + if (result.stripType !== request.stripType || result.mimeType !== request.mimeType || + result.byteLength !== request.byteLength || result.sourceSha256 !== request.sourceSha256) { + return blockedGate("N-021", capability, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", "Codec probe result does not match the source identity")]); + } + if (result.status === "READY") return readyGate("N-021", capability); + return blockedGate("N-021", capability, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", `Codec runtime probe blocked: ${result.reason}`)]); + } + catch (error) { + return blockedGate("N-021", capability, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", error instanceof Error ? error.message : "Codec probe is invalid")]); + } } diff --git a/web/protocol/server-render-job.ts b/web/protocol/server-render-job.ts new file mode 100644 index 00000000..180aa2f6 --- /dev/null +++ b/web/protocol/server-render-job.ts @@ -0,0 +1,313 @@ +import type { ErrorCode } from "./error"; + +/** Server render jobs are deliberately a separate contract from realtime routing. */ +export const SERVER_RENDER_JOB_SCHEMA = 1 as const; +export const SERVER_RENDER_JOB_BUDGET = { + maxSourceBytes: 512 * 1024 * 1024, + maxOutputBytes: 512 * 1024 * 1024, + maxSettingsBytes: 256 * 1024, + maxSettingsNodes: 10_000, + maxJobIdBytes: 128, + maxBuildVersionBytes: 64, +} as const; +export const SERVER_RENDER_JOB_STATUSES = ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const; +export type ServerRenderJobStatus = typeof SERVER_RENDER_JOB_STATUSES[number]; + +export interface BlenderBuildIdentityIR { + version: string; + buildSha256: string; +} + +export interface ServerRenderSettingsIR { + renderEngine: "BLENDER_EEVEE" | "BLENDER_EEVEE_NEXT" | "BLENDER_CYCLES" | "BLENDER_WORKBENCH"; + frameStart: number; + frameEnd: number; + resolutionX: number; + resolutionY: number; + resolutionPercentage: number; + samples: number; + outputMime: "image/png" | "image/openexr"; + transparent: boolean; +} + +export interface ServerRenderJobRequestIR { + schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA; + jobId: string; + sourceBlendSha256: string; + sourceBlendByteLength: number; + sourceRevision: number; + blenderBuild: BlenderBuildIdentityIR; + settings: ServerRenderSettingsIR; + settingsSha256: string; + requestSha256: string; +} + +export interface ServerRenderJobResultIR { + schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA; + jobId: string; + status: ServerRenderJobStatus; + sourceBlendSha256: string; + sourceBlendByteLength: number; + sourceRevision: number; + blenderBuild: BlenderBuildIdentityIR; + settingsSha256: string; + outputMime?: ServerRenderSettingsIR["outputMime"]; + outputSha256?: string; + outputByteLength?: number; + requestSha256: string; + resultSha256: string; + errorCode?: ErrorCode; +} + +export class ServerRenderJobValidationError extends Error { + readonly code: ErrorCode; + + constructor(code: ErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "ServerRenderJobValidationError"; + this.code = code; + } +} + +const SHA256 = /^[a-f0-9]{64}$/; +const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; +const BUILD_VERSION = /^5\.2\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/; +const OUTPUT_MIMES = ["image/png", "image/openexr"] as const; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value: Record, allowed: readonly string[], name: string, code: ErrorCode = "SERVER_RENDER_REQUEST_INVALID"): void { + const allowedSet = new Set(allowed); + if (Object.keys(value).some((key) => !allowedSet.has(key))) { + throw new ServerRenderJobValidationError(code, `${name} contains undeclared fields`); + } +} + +function digest(value: unknown, name: string): string { + if (typeof value !== "string" || !SHA256.test(value)) { + throw new ServerRenderJobValidationError("SERVER_RENDER_HASH_INVALID", `${name} must be a lowercase SHA-256 digest`); + } + return value; +} + +function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `${name} is outside the render budget`); + } + return value; +} + +function stableJSON(value: unknown, state = { nodes: 0, depth: 0 }): string { + state.nodes += 1; + if (state.nodes > SERVER_RENDER_JOB_BUDGET.maxSettingsNodes) { + throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the node budget"); + } + if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value); + if (Array.isArray(value)) { + if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested"); + const result = `[${value.map((item) => stableJSON(item, state)).join(",")}]`; + state.depth -= 1; + return result; + } + if (record(value)) { + if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested"); + const result = `{${Object.keys(value).sort().map((key) => { + if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(key) || value[key] === undefined) { + throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `render setting key ${key} is invalid`); + } + return `${JSON.stringify(key)}:${stableJSON(value[key], state)}`; + }).join(",")}}`; + state.depth -= 1; + return result; + } + throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings contain a non-JSON value"); +} + +async function sha256(data: ArrayBuffer | string): Promise { + const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); + const hash = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join(""); +} + +function parseBuild(value: unknown): BlenderBuildIdentityIR { + if (!record(value) || typeof value.version !== "string" || !BUILD_VERSION.test(value.version) || + new TextEncoder().encode(value.version).byteLength > SERVER_RENDER_JOB_BUDGET.maxBuildVersionBytes) { + throw new ServerRenderJobValidationError("SERVER_RENDER_BUILD_INVALID", "Blender build version is invalid or outside the 5.2 contract"); + } + exactKeys(value, ["version", "buildSha256"], "blenderBuild", "SERVER_RENDER_BUILD_INVALID"); + return { version: value.version, buildSha256: digest(value.buildSha256, "blenderBuild.buildSha256") }; +} + +function parseSettings(value: unknown): ServerRenderSettingsIR { + if (!record(value)) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings must be an object"); + exactKeys(value, ["renderEngine", "frameStart", "frameEnd", "resolutionX", "resolutionY", "resolutionPercentage", "samples", "outputMime", "transparent"], "settings", "SERVER_RENDER_SETTINGS_INVALID"); + const renderEngine = value.renderEngine; + if (!(["BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "BLENDER_CYCLES", "BLENDER_WORKBENCH"] as string[]).includes(renderEngine as string)) { + throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render engine is not declared"); + } + const outputMime = value.outputMime; + if (!OUTPUT_MIMES.includes(outputMime as typeof OUTPUT_MIMES[number])) { + throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "output MIME is not declared"); + } + const settings = { + ...value, + renderEngine, + frameStart: boundedInteger(value.frameStart, "frameStart", -1_000_000, 1_000_000), + frameEnd: boundedInteger(value.frameEnd, "frameEnd", -1_000_000, 1_000_000), + resolutionX: boundedInteger(value.resolutionX, "resolutionX", 1, 16_384), + resolutionY: boundedInteger(value.resolutionY, "resolutionY", 1, 16_384), + resolutionPercentage: boundedInteger(value.resolutionPercentage, "resolutionPercentage", 1, 100), + samples: boundedInteger(value.samples, "samples", 1, 65_536), + outputMime, + transparent: value.transparent, + } as ServerRenderSettingsIR; + if (typeof settings.transparent !== "boolean") throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "transparent must be boolean"); + if (settings.frameEnd !== settings.frameStart) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "schema 1 binds one still frame per output hash"); + const encoded = new TextEncoder().encode(stableJSON(settings)); + if (encoded.byteLength > SERVER_RENDER_JOB_BUDGET.maxSettingsBytes) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the byte budget"); + return settings; +} + +function canonicalRequest(request: Omit): string { + return stableJSON({ + schemaVersion: request.schemaVersion, + jobId: request.jobId, + sourceBlendSha256: request.sourceBlendSha256, + sourceBlendByteLength: request.sourceBlendByteLength, + sourceRevision: request.sourceRevision, + blenderBuild: request.blenderBuild, + settingsSha256: request.settingsSha256, + }); +} + +function canonicalResult(result: Omit): string { + return stableJSON(result); +} + +export async function createServerRenderJobRequest( + sourceBlend: ArrayBuffer, + blenderBuildValue: unknown, + settingsValue: unknown, + options: { jobId?: string; sourceRevision?: number } = {}, +): Promise { + if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength < 1 || sourceBlend.byteLength > SERVER_RENDER_JOB_BUDGET.maxSourceBytes) { + throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_INVALID", "source .blend bytes are empty or exceed the budget"); + } + const jobId = options.jobId ?? "render-request"; + if (!JOB_ID.test(jobId) || new TextEncoder().encode(jobId).byteLength > SERVER_RENDER_JOB_BUDGET.maxJobIdBytes) { + throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid"); + } + const sourceRevision = options.sourceRevision ?? 0; + boundedInteger(sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER); + const blenderBuild = parseBuild(blenderBuildValue); + const settings = parseSettings(settingsValue); + const settingsSha256 = await sha256(stableJSON(settings)); + const unsigned = { + schemaVersion: SERVER_RENDER_JOB_SCHEMA, + jobId, + sourceBlendSha256: await sha256(sourceBlend), + sourceBlendByteLength: sourceBlend.byteLength, + sourceRevision, + blenderBuild, + settings, + settingsSha256, + } as Omit; + return { ...unsigned, requestSha256: await sha256(canonicalRequest(unsigned)) }; +} + +export function parseServerRenderJobRequest(value: unknown): ServerRenderJobRequestIR { + if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job request schema"); + exactKeys(value, ["schemaVersion", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settings", "settingsSha256", "requestSha256"], "request"); + const jobId = value.jobId; + if (typeof jobId !== "string" || !JOB_ID.test(jobId)) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid"); + const sourceBlendByteLength = boundedInteger(value.sourceBlendByteLength, "sourceBlendByteLength", 1, SERVER_RENDER_JOB_BUDGET.maxSourceBytes); + const sourceRevision = boundedInteger(value.sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER); + const blenderBuild = parseBuild(value.blenderBuild); + const settings = parseSettings(value.settings); + const settingsSha256 = digest(value.settingsSha256, "settingsSha256"); + const requestSha256 = digest(value.requestSha256, "requestSha256"); + const request = { schemaVersion: SERVER_RENDER_JOB_SCHEMA, jobId, sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), sourceBlendByteLength, sourceRevision, blenderBuild, settings, settingsSha256, requestSha256 }; + return request; +} + +export async function verifyServerRenderJobRequest(value: unknown, sourceBlend?: ArrayBuffer): Promise { + const request = parseServerRenderJobRequest(value); + if (await sha256(stableJSON(request.settings)) !== request.settingsSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_HASH_MISMATCH", "settings do not match settingsSha256"); + const { requestSha256, ...unsigned } = request; + if (await sha256(canonicalRequest(unsigned)) !== requestSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_HASH_MISMATCH", "request binding hash does not match canonical metadata"); + if (sourceBlend !== undefined) { + if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength !== request.sourceBlendByteLength || await sha256(sourceBlend) !== request.sourceBlendSha256) { + throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_HASH_MISMATCH", "source .blend bytes do not match the submitted request"); + } + } + return request; +} + +export async function createServerRenderJobResult( + requestValue: unknown, + output: ArrayBuffer, + options: { status?: "SUCCEEDED"; errorCode?: never } = {}, +): Promise { + const request = await verifyServerRenderJobRequest(requestValue); + if (!(output instanceof ArrayBuffer) || output.byteLength < 1 || output.byteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) { + throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "render output is empty or exceeds the budget"); + } + const unsigned = { + schemaVersion: SERVER_RENDER_JOB_SCHEMA, + jobId: request.jobId, + status: options.status ?? "SUCCEEDED", + sourceBlendSha256: request.sourceBlendSha256, + sourceBlendByteLength: request.sourceBlendByteLength, + sourceRevision: request.sourceRevision, + blenderBuild: request.blenderBuild, + settingsSha256: request.settingsSha256, + outputMime: request.settings.outputMime, + outputSha256: await sha256(output), + outputByteLength: output.byteLength, + requestSha256: request.requestSha256, + } as Omit; + return { ...unsigned, resultSha256: await sha256(canonicalResult(unsigned)) }; +} + +export async function verifyServerRenderJobResult(value: unknown, requestValue: unknown, output?: ArrayBuffer): Promise { + const request = await verifyServerRenderJobRequest(requestValue); + if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job result schema"); + exactKeys(value, ["schemaVersion", "jobId", "status", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settingsSha256", "outputMime", "outputSha256", "outputByteLength", "requestSha256", "resultSha256", "errorCode"], "result"); + const result = { ...value } as unknown as ServerRenderJobResultIR; + if (result.jobId !== request.jobId || result.requestSha256 !== request.requestSha256 || result.sourceBlendSha256 !== request.sourceBlendSha256 || + result.sourceBlendByteLength !== request.sourceBlendByteLength || result.sourceRevision !== request.sourceRevision || + result.settingsSha256 !== request.settingsSha256 || JSON.stringify(result.blenderBuild) !== JSON.stringify(request.blenderBuild)) { + throw new ServerRenderJobValidationError("SERVER_RENDER_BINDING_MISMATCH", "server result is bound to different source, build, settings or request"); + } + if (!SERVER_RENDER_JOB_STATUSES.includes(result.status)) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status is invalid"); + if (result.status === "SUCCEEDED") { + if (result.errorCode !== undefined || result.outputMime !== request.settings.outputMime || !OUTPUT_MIMES.includes(result.outputMime) || typeof result.outputByteLength !== "number" || + !Number.isSafeInteger(result.outputByteLength) || result.outputByteLength < 1 || result.outputByteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) { + throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "successful result has inconsistent output metadata"); + } + digest(result.outputSha256, "outputSha256"); + if (output !== undefined && (output.byteLength !== result.outputByteLength || await sha256(output) !== result.outputSha256)) { + throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_HASH_MISMATCH", "render output bytes do not match outputSha256"); + } + } else { + if (result.outputSha256 !== undefined || result.outputByteLength !== undefined || result.outputMime !== undefined) { + throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "non-successful result must not publish output bytes"); + } + const expectedErrorCode = result.status === "FAILED" ? "SERVER_RENDER_FAILED" : result.status === "CANCELLED" ? "SERVER_RENDER_CANCELLED" : undefined; + if (result.errorCode !== expectedErrorCode) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status and errorCode disagree"); + } + const { resultSha256, ...unsigned } = result; + if (typeof resultSha256 !== "string" || !SHA256.test(resultSha256) || await sha256(canonicalResult(unsigned)) !== resultSha256) { + throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_HASH_MISMATCH", "result binding hash does not match canonical metadata"); + } + return result; +} + +export async function createServerRenderJobKey(requestValue: unknown): Promise { + const request = await verifyServerRenderJobRequest(requestValue); + const key = await sha256(stableJSON({ schemaVersion: SERVER_RENDER_JOB_SCHEMA, sourceBlendSha256: request.sourceBlendSha256, sourceRevision: request.sourceRevision, blenderBuild: request.blenderBuild, settingsSha256: request.settingsSha256, outputMime: request.settings.outputMime })); + return `render-${key}`; +} diff --git a/web/protocol/shader-compiler.ts b/web/protocol/shader-compiler.ts new file mode 100644 index 00000000..ebaf3670 --- /dev/null +++ b/web/protocol/shader-compiler.ts @@ -0,0 +1,502 @@ +import type { ErrorCode } from "./error"; +import type { MaterialIR, MaterialLinkIR, MaterialNodeIR } from "./scene-ir"; +import type { ShaderGraphIR } from "./shader-graph"; + +export const SHADER_COMPILE_SCHEMA = 1 as const; +export const SHADER_COMPILE_TASK = "M10-07" as const; +export const SHADER_COMPILE_BACKEND = "WEBGL2_THREE_PHYSICAL" as const; +export type ShaderCompileBackend = typeof SHADER_COMPILE_BACKEND; +export type ShaderTextureColorSpace = "SRGB" | "NON_COLOR" | "LINEAR"; + +/** + * This is deliberately smaller than the Main writer allowlist. RGB and Value are + * constants used to feed the declared Math/Principled closure; no arbitrary GLSL + * or unknown Blender node can enter the browser material path. + */ +export const SHADER_COMPILE_ALLOWLIST = [ + "RGB", + "VALUE", + "MATH", + "IMAGE_TEXTURE", + "NORMAL_MAP", + "PRINCIPLED", + "OUTPUT", +] as const; + +export const SHADER_COMPILE_BUDGET = { + maxNodes: 128, + maxLinks: 512, + maxDepth: 64, + maxTextures: 16, + maxIdentifierBytes: 256, + maxNameBytes: 1_024, +} as const; + +type CompileNodeType = typeof SHADER_COMPILE_ALLOWLIST[number]; +type Issue = { code: ErrorCode; message: string; path?: string }; + +export interface ShaderCompiledMaterial { + baseColor: [number, number, number, number]; + roughness: number; + metallic: number; + alpha: number; + ior: number; + specularIORLevel?: number; + transmissionWeight?: number; + coatWeight?: number; + coatRoughness?: number; + emissionStrength?: number; + emissionColor?: [number, number, number, number]; + baseColorImageId?: string; + normalImageId?: string; +} + +export interface ShaderCompileReport { + schemaVersion: typeof SHADER_COMPILE_SCHEMA; + taskId: typeof SHADER_COMPILE_TASK; + backend: typeof SHADER_COMPILE_BACKEND; + status: "COMPILED" | "BLOCKED"; + materialId: string; + graphHash: string | null; + compileKey: string | null; + nodeOrder: string[]; + compiledNodeTypes: CompileNodeType[]; + textureBindings: Array<{ imageId: string; usage: "BASE_COLOR" | "NORMAL" }>; + instructions: Array<{ nodeId: string; type: CompileNodeType; operation?: string }>; + material?: ShaderCompiledMaterial; + issues: Issue[]; +} + +export interface ShaderCompileContext { + imageIds?: ReadonlySet; + blockedImageIds?: ReadonlySet; + rendererBackend?: string; + textureIdentities?: ReadonlyMap; +} + +export interface ShaderCompileKeyInput { + graphHash: string | null; + rendererBackend: string; + textures: ReadonlyArray<{ + imageId: string; + usage: "BASE_COLOR" | "NORMAL"; + assetId: string | null; + sha256: string | null; + colorSpace: ShaderTextureColorSpace; + }>; +} + +export function compileShaderGraph( + graph: ShaderGraphIR, + baseMaterial?: MaterialIR, + context: ShaderCompileContext = {}, +): ShaderCompileReport { + const socketName = new Map(); + for (const node of graph.nodes) { + for (const socket of node.sockets) socketName.set(`${node.id}:${socket.id}`, socket.name); + } + const nodes: MaterialNodeIR[] = graph.nodes.map((node) => { + const outputDefault = node.sockets.find((socket) => socket.direction === "OUTPUT")?.defaultValue; + const defaultValue = typeof outputDefault === "number" ? [outputDefault] + : Array.isArray(outputDefault) && outputDefault.every(finite) ? [...outputDefault] : undefined; + return { + id: node.id, + type: node.type === "MATERIAL_OUTPUT" ? "OUTPUT" : node.type as MaterialNodeIR["type"], + name: node.name, + imageId: node.imageId, + defaultValue, + properties: node.type === "MATH" ? { + operation: node.properties?.operation as "ADD" | "SUBTRACT" | "MULTIPLY" | "DIVIDE" | "MINIMUM" | "MAXIMUM" | undefined, + } : undefined, + }; + }); + const material: MaterialIR = { + id: graph.materialId, + name: baseMaterial?.name ?? graph.materialId, + baseColor: baseMaterial?.baseColor ?? [0.8, 0.8, 0.8, 1], + roughness: baseMaterial?.roughness ?? 0.5, + metallic: baseMaterial?.metallic ?? 0, + emissionColor: baseMaterial?.emissionColor ?? [0, 0, 0, 1], + alpha: baseMaterial?.alpha ?? 1, + ior: baseMaterial?.ior ?? 1.45, + specularIORLevel: baseMaterial?.specularIORLevel, + transmissionWeight: baseMaterial?.transmissionWeight, + coatWeight: baseMaterial?.coatWeight, + coatRoughness: baseMaterial?.coatRoughness, + emissionStrength: baseMaterial?.emissionStrength, + nodes, + links: graph.links.map((link) => ({ + fromNodeId: link.fromNodeId, + fromSocket: socketName.get(`${link.fromNodeId}:${link.fromSocketId}`) ?? link.fromSocketId, + toNodeId: link.toNodeId, + toSocket: socketName.get(`${link.toNodeId}:${link.toSocketId}`) ?? link.toSocketId, + })), + }; + return compileMaterialGraph(material, context); +} + +function finite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function finiteColor(value: unknown): value is number[] { + return Array.isArray(value) && (value.length === 3 || value.length === 4) && value.every((item) => finite(item) && item >= 0 && item <= 1); +} + +function normalizeSocket(value: string): string { + return value.toLowerCase().replace(/[ _-]/g, ""); +} + +function scalarSocket(value: string): boolean { + const normalized = normalizeSocket(value); + return normalized === "value" || normalized === "value001" || normalized === "a" || normalized === "b"; +} + +function canonicalGraph(material: MaterialIR): string { + return JSON.stringify({ + schemaVersion: 1, + materialId: material.id, + nodes: (material.nodes ?? []).map((node) => ({ + id: node.id, + type: node.type, + name: node.name, + imageId: node.imageId ?? null, + defaultValue: node.defaultValue ?? null, + properties: node.properties ?? null, + })), + links: (material.links ?? []).map((link) => ({ + fromNodeId: link.fromNodeId, + fromSocket: link.fromSocket, + toNodeId: link.toNodeId, + toSocket: link.toSocket, + })), + }); +} + +// A synchronous SHA-256 keeps viewport material creation deterministic without +// making every Three.js material allocation asynchronous. Native snapshots carry +// their own SHA-256; this is the fallback for protocol/unit fixtures. +function fallbackGraphHash(value: string): string { + const bytes = new TextEncoder().encode(value); + const words = new Uint32Array(64); + const constants = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]; + const state = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const length = ((bytes.length + 9 + 63) >> 6) << 6; + const padded = new Uint8Array(length); + padded.set(bytes); + padded[bytes.length] = 0x80; + const bitLength = bytes.length * 8; + new DataView(padded.buffer).setUint32(length - 4, bitLength >>> 0, false); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let index = 0; index < 16; index++) words[index] = new DataView(padded.buffer, offset + index * 4, 4).getUint32(0, false); + for (let index = 16; index < 64; index++) { + const a = words[index - 15]; + const b = words[index - 2]; + const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3); + const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10); + words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, h] = state; + for (let index = 0; index < 64; index++) { + const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); + const choose = (e & f) ^ (~e & g); + const temp1 = (h + S1 + choose + constants[index] + words[index]) >>> 0; + const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); + const majority = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + majority) >>> 0; + h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0; + } + state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0; state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0; + state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0; state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0; + } + return Array.from(state, (word) => word.toString(16).padStart(8, "0")).join(""); +} + +function issue(code: ErrorCode, message: string, path?: string): Issue { + return { code, message, path }; +} + +export function createShaderCompileKey(input: ShaderCompileKeyInput): string { + const textures = [...input.textures].sort((left, right) => + `${left.usage}:${left.imageId}`.localeCompare(`${right.usage}:${right.imageId}`)); + return fallbackGraphHash(JSON.stringify({ + schemaVersion: SHADER_COMPILE_SCHEMA, + graphHash: input.graphHash, + rendererBackend: input.rendererBackend, + textures, + })); +} + +function linkKey(nodeId: string, socket: string): string { + return `${nodeId}:${normalizeSocket(socket)}`; +} + +function sourceLink(incoming: ReadonlyMap, nodeId: string, socket: string): MaterialLinkIR | undefined { + return incoming.get(linkKey(nodeId, socket)); +} + +function isScalarSource(node: MaterialNodeIR | undefined): boolean { + return node?.type === "VALUE" || node?.type === "MATH"; +} + +function targetScalarSocket(socket: string): boolean { + return new Set(["roughness", "metallic", "alpha", "ior", "speculariorlevel", "transmissionweight", "coatweight", "coatroughness", "emissionstrength"]).has(normalizeSocket(socket)); +} + +function boundedMaterialValue(name: string, value: number): boolean { + if (!finite(value)) return false; + if (["roughness", "metallic", "alpha", "speculariorlevel", "transmissionweight", "coatweight", "coatroughness"].includes(name)) return value >= 0 && value <= 1; + if (name === "ior") return value >= 1 && value <= 2.333; + return value >= 0 && value <= 1_000_000; +} + +export function compileMaterialGraph(material: MaterialIR, context: ShaderCompileContext = {}): ShaderCompileReport { + const nodes = material.nodes ?? []; + const links = material.links ?? []; + const graphHash = material.shaderGraphHash ?? (nodes.length > 0 ? fallbackGraphHash(canonicalGraph(material)) : null); + const base: ShaderCompileReport = { + schemaVersion: SHADER_COMPILE_SCHEMA, + taskId: SHADER_COMPILE_TASK, + backend: SHADER_COMPILE_BACKEND, + status: "BLOCKED", + materialId: material.id, + graphHash, + nodeOrder: [], + compileKey: null, + compiledNodeTypes: [], + textureBindings: [], + instructions: [], + issues: [], + }; + if (nodes.length === 0) return { + ...base, + status: "COMPILED", + issues: [], + material: { + baseColor: material.baseColor, + roughness: material.roughness, + metallic: material.metallic, + alpha: material.alpha, + ior: material.ior, + specularIORLevel: material.specularIORLevel, + transmissionWeight: material.transmissionWeight, + coatWeight: material.coatWeight, + coatRoughness: material.coatRoughness, + emissionStrength: material.emissionStrength, + emissionColor: material.emissionColor, + }, + }; + if (nodes.length > SHADER_COMPILE_BUDGET.maxNodes) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compiler node budget", "nodes")); + if (links.length > SHADER_COMPILE_BUDGET.maxLinks) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compiler link budget", "links")); + if (base.issues.length > 0) return base; + const rendererBackend = context.rendererBackend ?? SHADER_COMPILE_BACKEND; + if (rendererBackend !== SHADER_COMPILE_BACKEND) { + base.issues.push(issue("CAPABILITY_MISSING", `Shader backend is not available: ${rendererBackend}`, "rendererBackend")); + return base; + } + if (material.shaderGraphHash !== undefined && !/^[0-9a-f]{64}$/.test(material.shaderGraphHash)) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader graph hash is not a lowercase SHA-256 digest", "shaderGraphHash")); + if (!finiteColor(material.baseColor) || !finiteColor(material.emissionColor)) base.issues.push(issue("SHADER_INVALID_GRAPH", "Material color defaults are invalid", "material")); + for (const [field, value] of [["roughness", material.roughness], ["metallic", material.metallic], ["alpha", material.alpha], ["ior", material.ior]] as const) { + if (!boundedMaterialValue(normalizeSocket(field), value)) base.issues.push(issue("SHADER_INVALID_GRAPH", `Material ${field} default is outside the compiler range`, field)); + } + const byId = new Map(); + for (const [index, node] of nodes.entries()) { + if (new TextEncoder().encode(node.id).byteLength === 0 || new TextEncoder().encode(node.id).byteLength > SHADER_COMPILE_BUDGET.maxIdentifierBytes) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader node ID is empty or oversized", `nodes.${index}.id`)); + if (new TextEncoder().encode(node.name).byteLength === 0 || new TextEncoder().encode(node.name).byteLength > SHADER_COMPILE_BUDGET.maxNameBytes) base.issues.push(issue("SHADER_INVALID_GRAPH", "Shader node name is empty or oversized", `nodes.${index}.name`)); + if (byId.has(node.id)) base.issues.push(issue("SHADER_INVALID_GRAPH", `duplicate shader node ID: ${node.id}`, `nodes.${index}.id`)); + byId.set(node.id, node); + if (!(SHADER_COMPILE_ALLOWLIST as readonly string[]).includes(node.type)) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `shader node ${node.type} is outside the M10-07 compiler allowlist`, `nodes.${index}.type`)); + if (node.type === "MATH" && !["ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "MINIMUM", "MAXIMUM"].includes(node.properties?.operation ?? "")) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Math operation is outside the M10-07 compiler allowlist", `nodes.${index}.properties.operation`)); + if (node.type !== "MATH" && node.properties && Object.keys(node.properties).length > 0) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `properties are not compiled for ${node.type}`, `nodes.${index}.properties`)); + } + const incoming = new Map(); + const edges = new Map(); + for (const [index, link] of links.entries()) { + const from = byId.get(link.fromNodeId); + const to = byId.get(link.toNodeId); + if (!from || !to) { + base.issues.push(issue("SHADER_INVALID_GRAPH", "shader link references an unknown node", `links.${index}`)); + continue; + } + const targetKey = linkKey(to.id, link.toSocket); + if (incoming.has(targetKey)) base.issues.push(issue("SHADER_INVALID_GRAPH", `shader input has more than one link: ${targetKey}`, `links.${index}`)); + incoming.set(targetKey, link); + edges.set(from.id, [...(edges.get(from.id) ?? []), to.id]); + } + const state = new Map(); + const order: string[] = []; + const visit = (id: string, depth: number): void => { + if (depth > SHADER_COMPILE_BUDGET.maxDepth) { + base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded compile depth", "links")); + return; + } + const current = state.get(id) ?? 0; + if (current === 2) return; + if (current === 1) { + base.issues.push(issue("SHADER_GRAPH_CYCLE", "Shader graph contains a cycle", "links")); + return; + } + state.set(id, 1); + for (const next of edges.get(id) ?? []) visit(next, depth + 1); + state.set(id, 2); + order.push(id); + }; + for (const node of nodes) visit(node.id, 0); + base.nodeOrder = [...new Set(order.reverse())]; + const outputs = nodes.filter((node) => node.type === "OUTPUT"); + const principled = nodes.filter((node) => node.type === "PRINCIPLED"); + if (outputs.length !== 1 || principled.length !== 1) base.issues.push(issue("SHADER_INVALID_GRAPH", "Compiled Shader graph requires exactly one Principled and one Output node", "nodes")); + const output = outputs[0]; + const shader = principled[0]; + if (output && shader && !sourceLink(incoming, output.id, "Surface")) base.issues.push(issue("SHADER_INVALID_GRAPH", "Material Output Surface is not connected", "links")); + const textures = new Map(); + const compiled: ShaderCompiledMaterial = { + baseColor: material.baseColor, + roughness: material.roughness, + metallic: material.metallic, + alpha: material.alpha, + ior: material.ior, + specularIORLevel: material.specularIORLevel, + transmissionWeight: material.transmissionWeight, + coatWeight: material.coatWeight, + coatRoughness: material.coatRoughness, + emissionStrength: material.emissionStrength, + emissionColor: material.emissionColor, + }; + const evaluated = new Map(); + const evaluating = new Set(); + const evalScalar = (nodeId: string, depth: number): number | undefined => { + if (depth > SHADER_COMPILE_BUDGET.maxDepth) return undefined; + const cached = evaluated.get(nodeId); + if (cached !== undefined) return cached; + if (evaluating.has(nodeId)) return undefined; + const node = byId.get(nodeId); + if (!node) return undefined; + evaluating.add(nodeId); + let value: number | undefined; + if (node.type === "VALUE") { + value = node.defaultValue?.length === 1 && finite(node.defaultValue[0]) ? node.defaultValue[0] : undefined; + } + else if (node.type === "MATH") { + const first = sourceLink(incoming, node.id, "Value") ?? sourceLink(incoming, node.id, "A"); + const second = sourceLink(incoming, node.id, "Value_001") ?? sourceLink(incoming, node.id, "B"); + const left = first ? evalScalar(first.fromNodeId, depth + 1) : undefined; + const right = second ? evalScalar(second.fromNodeId, depth + 1) : undefined; + if (left !== undefined && right !== undefined) { + switch (node.properties?.operation) { + case "ADD": value = left + right; break; + case "SUBTRACT": value = left - right; break; + case "MULTIPLY": value = left * right; break; + case "DIVIDE": value = Math.abs(right) > 1e-12 ? left / right : undefined; break; + case "MINIMUM": value = Math.min(left, right); break; + case "MAXIMUM": value = Math.max(left, right); break; + } + } + } + evaluating.delete(nodeId); + if (value !== undefined && finite(value)) evaluated.set(nodeId, value); + return value; + }; + const evalColor = (link: MaterialLinkIR): [number, number, number, number] | undefined => { + const node = byId.get(link.fromNodeId); + if (node?.type === "RGB" && finiteColor(node.defaultValue)) { + return [node.defaultValue[0], node.defaultValue[1], node.defaultValue[2], node.defaultValue[3] ?? 1]; + } + if (node?.type === "IMAGE_TEXTURE" && node.imageId) { + if (context.imageIds && !context.imageIds.has(node.imageId)) { + base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${node.imageId}`, `nodes.${node.id}.imageId`)); + } + if (context.blockedImageIds?.has(node.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${node.imageId}`, `nodes.${node.id}.imageId`)); + textures.set(`BASE_COLOR:${node.imageId}`, { imageId: node.imageId, usage: "BASE_COLOR" }); + compiled.baseColorImageId = node.imageId; + return undefined; + } + return undefined; + }; + if (shader) { + for (const [socket, field] of [["Roughness", "roughness"], ["Metallic", "metallic"], ["Alpha", "alpha"], ["IOR", "ior"], ["Specular IOR Level", "specularIORLevel"], ["Transmission Weight", "transmissionWeight"], ["Coat Weight", "coatWeight"], ["Coat Roughness", "coatRoughness"], ["Emission Strength", "emissionStrength"]] as const) { + const link = sourceLink(incoming, shader.id, socket); + if (!link) continue; + const value = evalScalar(link.fromNodeId, 0); + const normalized = normalizeSocket(field); + if (value === undefined || !boundedMaterialValue(normalized, value)) base.issues.push(issue("SHADER_INVALID_GRAPH", `${socket} input is not a finite bounded constant`, `links.${socket}`)); + else (compiled as unknown as Record)[field] = value; + } + const colorLink = sourceLink(incoming, shader.id, "Base Color"); + if (colorLink) { + const color = evalColor(colorLink); + if (color) compiled.baseColor = color; + else if (byId.get(colorLink.fromNodeId)?.type !== "IMAGE_TEXTURE") base.issues.push(issue("SHADER_INVALID_GRAPH", "Base Color must be an RGB constant or Image Texture", "links.BaseColor")); + } + const normalLink = sourceLink(incoming, shader.id, "Normal"); + if (normalLink) { + const normalNode = byId.get(normalLink.fromNodeId); + if (normalNode?.type !== "NORMAL_MAP") base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Principled Normal must be driven by the declared Normal Map node", "links.Normal")); + else { + const imageLink = sourceLink(incoming, normalNode.id, "Color"); + const imageNode = imageLink ? byId.get(imageLink.fromNodeId) : undefined; + if (!imageLink || imageNode?.type !== "IMAGE_TEXTURE" || !imageNode.imageId) base.issues.push(issue("SHADER_INVALID_GRAPH", "Normal Map requires an Image Texture Color input", `nodes.${normalNode.id}`)); + else { + if (context.imageIds && !context.imageIds.has(imageNode.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`)); + if (context.blockedImageIds?.has(imageNode.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`)); + textures.set(`NORMAL:${imageNode.imageId}`, { imageId: imageNode.imageId, usage: "NORMAL" }); + compiled.normalImageId = imageNode.imageId; + } + } + } + } + const supportedLink = (link: MaterialLinkIR): boolean => { + const from = byId.get(link.fromNodeId); + const to = byId.get(link.toNodeId); + if (!from || !to) return false; + const fromSocket = normalizeSocket(link.fromSocket); + const toSocket = normalizeSocket(link.toSocket); + if (to.type === "OUTPUT") return from.type === "PRINCIPLED" && fromSocket === "bsdf" && toSocket === "surface"; + if (to.type === "PRINCIPLED") { + if (toSocket === "basecolor") return (from.type === "RGB" && fromSocket === "color") || (from.type === "IMAGE_TEXTURE" && fromSocket === "color"); + if (toSocket === "normal") return from.type === "NORMAL_MAP" && fromSocket === "normal"; + return targetScalarSocket(toSocket) && isScalarSource(from) && (from.type !== "MATH" || fromSocket === "value"); + } + if (to.type === "NORMAL_MAP") return from.type === "IMAGE_TEXTURE" && fromSocket === "color" && toSocket === "color"; + if (to.type === "MATH") return isScalarSource(from) && scalarSocket(toSocket) && fromSocket === "value"; + return false; + }; + for (const [index, link] of links.entries()) if (!supportedLink(link)) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `Shader link is outside the M10-07 compiled closure: ${link.fromNodeId}.${link.fromSocket} -> ${link.toNodeId}.${link.toSocket}`, `links.${index}`)); + base.textureBindings = [...textures.values()]; + const textureKeys = base.textureBindings.map((binding) => { + const identity = context.textureIdentities?.get(binding.imageId); + const sha256 = identity?.sha256 ?? null; + if (sha256 !== null && !/^[0-9a-f]{64}$/.test(sha256)) { + base.issues.push(issue("SHADER_INVALID_GRAPH", `texture SHA-256 is invalid: ${binding.imageId}`, `textures.${binding.imageId}.sha256`)); + } + return { + imageId: binding.imageId, + usage: binding.usage, + assetId: identity?.assetId ?? null, + sha256, + colorSpace: identity?.colorSpace ?? (binding.usage === "BASE_COLOR" ? "SRGB" : "NON_COLOR"), + }; + }); + base.compileKey = createShaderCompileKey({ graphHash, rendererBackend, textures: textureKeys }); + if (base.textureBindings.length > SHADER_COMPILE_BUDGET.maxTextures) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Shader graph exceeds the bounded texture binding budget", "nodes")); + base.compiledNodeTypes = [...new Set(nodes.map((node) => node.type).filter((type): type is CompileNodeType => (SHADER_COMPILE_ALLOWLIST as readonly string[]).includes(type)))]; + base.instructions = base.nodeOrder.map((nodeId) => { + const node = byId.get(nodeId) as MaterialNodeIR; + return { nodeId, type: node.type as CompileNodeType, ...(node.type === "MATH" ? { operation: node.properties?.operation } : {}) }; + }); + if (base.issues.length > 0) return base; + return { ...base, status: "COMPILED", material: compiled }; +} diff --git a/web/protocol/simulation-cache.ts b/web/protocol/simulation-cache.ts index d6faecde..33e5a67d 100644 --- a/web/protocol/simulation-cache.ts +++ b/web/protocol/simulation-cache.ts @@ -1,13 +1,31 @@ import type { ErrorCode } from "./error"; -export const SIMULATION_CACHE_SCHEMA = 1 as const; +export const SIMULATION_CACHE_SCHEMA = 2 as const; export const SIMULATION_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const; export const SIMULATION_CACHE_BUDGET = { maxCacheBytes: 16 * 1024 * 1024 * 1024, + maxProjectCacheBytes: 16 * 1024 * 1024 * 1024, maxFrameBytes: 512 * 1024 * 1024, maxFrames: 100_000, } as const; +export interface SimulationCacheLRUCandidateIR { + cacheKey: string; + byteLength: number; + createdAt: string; + lastAccessAt: string; +} + +export interface SimulationCacheLRUPlanIR { + maxBytes: number; + beforeBytes: number; + remainingBytes: number; + removedBytes: number; + cacheKeys: string[]; + protectedCacheKeys: string[]; + budgetSatisfied: boolean; +} + export interface SimulationCacheFrameIR { frame: number; byteOffset: number; @@ -20,7 +38,9 @@ export interface SimulationCacheManifestIR { graphId: string; graphHash: string; sourceBlendSha256: string; + sourceRevision: number; inputHash: string; + revisionHash: string; cacheSha256: string; blenderVersion: string; frameStart: number; @@ -29,6 +49,17 @@ export interface SimulationCacheManifestIR { frames: SimulationCacheFrameIR[]; } +export interface SimulationCacheRevisionBindingIR { + graphId: string; + graphHash: string; + sourceBlendSha256: string; + sourceRevision: number; + inputHash: string; + blenderVersion: string; + frameStart: number; + frameEnd: number; +} + export class SimulationCacheValidationError extends Error { readonly code: ErrorCode; @@ -40,14 +71,15 @@ export class SimulationCacheValidationError extends Error { } const SHA256 = /^[a-f0-9]{64}$/; +const CACHE_KEY = /^sim2-[a-f0-9]{64}$/; function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function integer(value: unknown, name: string, minimum = 0): number { - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be an integer >= ${minimum}`); +function integer(value: unknown, name: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be an integer from ${minimum} to ${maximum}`); } return value; } @@ -59,38 +91,80 @@ function digest(value: unknown, name: string): string { return value; } +function exactKeys(value: Record, allowed: readonly string[], name: string): void { + const allowedSet = new Set(allowed); + if (Object.keys(value).some((key) => !allowedSet.has(key))) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} contains undeclared fields`); + } +} + +function boundedText(value: unknown, name: string, maximumBytes: number): string { + if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maximumBytes) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} is outside its text budget`); + } + return value; +} + +function revisionBindingText(binding: SimulationCacheRevisionBindingIR): string { + return JSON.stringify([ + "blender-web-simulation-cache-revision-v2", + binding.graphId, + binding.graphHash, + binding.sourceBlendSha256, + String(binding.sourceRevision), + binding.inputHash, + binding.blenderVersion, + String(binding.frameStart), + String(binding.frameEnd), + ]); +} + +export async function computeSimulationCacheRevisionHash( + binding: SimulationCacheRevisionBindingIR, +): Promise { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(revisionBindingText(binding))); + return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join(""); +} + export function parseSimulationCacheManifest(value: unknown): SimulationCacheManifestIR { if (!record(value) || value.schemaVersion !== SIMULATION_CACHE_SCHEMA) { throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", "Unsupported SimulationCache manifest schema"); } - for (const name of ["graphId", "blenderVersion"] as const) { - if (typeof value[name] !== "string" || value[name].length === 0) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} is required`); - } - } - if (!(value.blenderVersion as string).startsWith(SIMULATION_CACHE_BLENDER_VERSION_PREFIX)) { + exactKeys(value, [ + "schemaVersion", "graphId", "graphHash", "sourceBlendSha256", "sourceRevision", + "inputHash", "revisionHash", "cacheSha256", "blenderVersion", "frameStart", + "frameEnd", "byteLength", "frames", + ], "manifest"); + const graphId = boundedText(value.graphId, "graphId", 256); + const blenderVersion = boundedText(value.blenderVersion, "blenderVersion", 64); + if (!blenderVersion.startsWith(SIMULATION_CACHE_BLENDER_VERSION_PREFIX)) { throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", `Simulation cache requires Blender ${SIMULATION_CACHE_BLENDER_VERSION_PREFIX}x`); } - const frameStart = integer(value.frameStart, "frameStart", -1_000_000); - const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000); + const sourceRevision = integer(value.sourceRevision, "sourceRevision"); + const frameStart = integer(value.frameStart, "frameStart", -1_000_000, 1_000_000); + const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000, 1_000_000); const byteLength = integer(value.byteLength, "byteLength", 1); if (byteLength > SIMULATION_CACHE_BUDGET.maxCacheBytes) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache exceeds the byte budget"); + throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation cache exceeds the byte budget"); } - if (frameEnd < frameStart || frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames || !Array.isArray(value.frames)) { + if (frameEnd < frameStart || !Array.isArray(value.frames)) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame range is invalid"); } + if (frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation frame range exceeds the budget"); + } if (value.frames.length !== frameEnd - frameStart + 1) { throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache must contain every declared frame"); } let nextOffset = 0; const frames = value.frames.map((item, index): SimulationCacheFrameIR => { if (!record(item)) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is invalid`); - const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000); + exactKeys(item, ["frame", "byteOffset", "byteLength", "sha256"], `frames[${index}]`); + const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000, 1_000_000); const byteOffset = integer(item.byteOffset, `frames[${index}].byteOffset`); const frameByteLength = integer(item.byteLength, `frames[${index}].byteLength`, 1); if (frameByteLength > SIMULATION_CACHE_BUDGET.maxFrameBytes) { - throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] exceeds the byte budget`); + throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", `frames[${index}] exceeds the byte budget`); } if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) { throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is not contiguous or ordered`); @@ -101,12 +175,14 @@ export function parseSimulationCacheManifest(value: unknown): SimulationCacheMan if (nextOffset !== byteLength) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame ranges do not cover the cache payload"); return { schemaVersion: SIMULATION_CACHE_SCHEMA, - graphId: value.graphId as string, + graphId, graphHash: digest(value.graphHash, "graphHash"), sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), + sourceRevision, inputHash: digest(value.inputHash, "inputHash"), + revisionHash: digest(value.revisionHash, "revisionHash"), cacheSha256: digest(value.cacheSha256, "cacheSha256"), - blenderVersion: value.blenderVersion as string, + blenderVersion, frameStart, frameEnd, byteLength, @@ -114,21 +190,47 @@ export function parseSimulationCacheManifest(value: unknown): SimulationCacheMan }; } +export async function verifySimulationCacheRevisionBinding( + manifestValue: unknown, +): Promise { + const manifest = parseSimulationCacheManifest(manifestValue); + const computed = await computeSimulationCacheRevisionHash(manifest); + if (computed !== manifest.revisionHash) { + throw new SimulationCacheValidationError( + "SIMULATION_CACHE_REVISION_MISMATCH", + "Simulation cache revision hash does not match its graph, source, revision, inputs, and frame range", + ); + } + return manifest; +} + async function sha256(data: ArrayBuffer): Promise { const hash = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join(""); } export async function verifySimulationCache(manifestValue: unknown, data: ArrayBuffer): Promise { - const manifest = parseSimulationCacheManifest(manifestValue); + return verifySimulationCacheCancellable(manifestValue, data); +} + +export async function verifySimulationCacheCancellable( + manifestValue: unknown, + data: ArrayBuffer, + checkCancelled: () => void = () => undefined, +): Promise { + checkCancelled(); + const manifest = await verifySimulationCacheRevisionBinding(manifestValue); + checkCancelled(); if (data.byteLength !== manifest.byteLength || await sha256(data) !== manifest.cacheSha256) { throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload does not match its manifest"); } + checkCancelled(); for (const frame of manifest.frames) { const bytes = data.slice(frame.byteOffset, frame.byteOffset + frame.byteLength); if (await sha256(bytes) !== frame.sha256) { throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame.frame} failed SHA-256 verification`); } + checkCancelled(); } return manifest; } @@ -150,7 +252,8 @@ export async function verifySimulationCacheFrame( frame: number, data: ArrayBuffer, ): Promise { - const selected = selectSimulationCacheFrame(manifestValue, frame); + const manifest = await verifySimulationCacheRevisionBinding(manifestValue); + const selected = selectSimulationCacheFrame(manifest, frame); if (data.byteLength !== selected.byteLength || await sha256(data) !== selected.sha256) { throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame} failed SHA-256 verification`); } @@ -158,5 +261,59 @@ export async function verifySimulationCacheFrame( } export function simulationCacheKey(manifest: SimulationCacheManifestIR): string { - return `${manifest.graphHash.slice(0, 16)}-${manifest.sourceBlendSha256.slice(0, 16)}-${manifest.inputHash.slice(0, 16)}-${manifest.frameStart}-${manifest.frameEnd}`; + return `sim2-${manifest.revisionHash}`; +} + +export function planSimulationCacheLRU( + candidatesValue: readonly SimulationCacheLRUCandidateIR[], + maxBytesValue: number, + protectedCacheKeysValue: readonly string[] = [], +): SimulationCacheLRUPlanIR { + const maxBytes = integer(maxBytesValue, "maxBytes", 0, SIMULATION_CACHE_BUDGET.maxProjectCacheBytes); + const seen = new Set(); + const candidates = candidatesValue.map((candidate, index) => { + if (!record(candidate) || !CACHE_KEY.test(candidate.cacheKey) || seen.has(candidate.cacheKey)) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `LRU candidate ${index} has an invalid or duplicate cache key`); + } + seen.add(candidate.cacheKey); + const byteLength = integer(candidate.byteLength, `LRU candidate ${index} byteLength`, 1, SIMULATION_CACHE_BUDGET.maxCacheBytes); + for (const field of ["createdAt", "lastAccessAt"] as const) { + if (typeof candidate[field] !== "string" || !Number.isFinite(Date.parse(candidate[field]))) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `LRU candidate ${index} ${field} is invalid`); + } + } + return { ...candidate, byteLength }; + }); + const protectedCacheKeys = [...new Set(protectedCacheKeysValue)].sort(); + if (protectedCacheKeys.some((cacheKey) => !CACHE_KEY.test(cacheKey))) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Protected Simulation cache key is invalid"); + } + const protectedSet = new Set(protectedCacheKeys); + const beforeBytes = candidates.reduce((total, candidate) => { + const next = total + candidate.byteLength; + if (!Number.isSafeInteger(next)) { + throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation cache LRU byte total exceeds the safe integer range"); + } + return next; + }, 0); + let remainingBytes = beforeBytes; + const cacheKeys: string[] = []; + const removable = candidates.filter((candidate) => !protectedSet.has(candidate.cacheKey)).sort((left, right) => + left.lastAccessAt.localeCompare(right.lastAccessAt) || + left.createdAt.localeCompare(right.createdAt) || + left.cacheKey.localeCompare(right.cacheKey)); + for (const candidate of removable) { + if (remainingBytes <= maxBytes) break; + cacheKeys.push(candidate.cacheKey); + remainingBytes -= candidate.byteLength; + } + return { + maxBytes, + beforeBytes, + remainingBytes, + removedBytes: beforeBytes - remainingBytes, + cacheKeys, + protectedCacheKeys, + budgetSatisfied: remainingBytes <= maxBytes, + }; } diff --git a/web/protocol/storage-budget.ts b/web/protocol/storage-budget.ts new file mode 100644 index 00000000..835f401b --- /dev/null +++ b/web/protocol/storage-budget.ts @@ -0,0 +1,39 @@ +export const STORAGE_BUDGET_SCHEMA_VERSION = 1 as const; + +export interface StorageBudgetBreakdown { + schemaVersion: typeof STORAGE_BUDGET_SCHEMA_VERSION; + projectId: string; + projectBytes: number; + snapshotBytes: number; + lodBytes: number; + mediaBytes: number; + vdbBytes: number; + totalBytes: number; +} + +export function createStorageBudget(projectId: string, values: Partial> = {}): StorageBudgetBreakdown { + const fields = ["projectBytes", "snapshotBytes", "lodBytes", "mediaBytes", "vdbBytes"] as const; + const normalized = Object.fromEntries(fields.map((field) => { + const value = values[field] ?? 0; + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`STORAGE_BUDGET_INVALID: ${field}`); + return [field, value]; + })) as Pick; + const totalBytes = fields.reduce((total, field) => { + const next = total + normalized[field]; + if (!Number.isSafeInteger(next)) throw new Error("STORAGE_BUDGET_INVALID: totalBytes"); + return next; + }, 0); + return { schemaVersion: STORAGE_BUDGET_SCHEMA_VERSION, projectId, ...normalized, totalBytes }; +} + +export function formatStorageBytes(bytes: number): string { + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new Error("STORAGE_BUDGET_INVALID: bytes"); + if (bytes < 1024) return `${bytes} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let value = bytes; + for (const unit of units) { + value /= 1024; + if (value < 1024 || unit === units[units.length - 1]) return `${value.toFixed(value >= 10 ? 0 : 1)} ${unit}`; + } + return `${bytes} B`; +} diff --git a/web/protocol/storage.ts b/web/protocol/storage.ts index b6551e9d..772d94fa 100644 --- a/web/protocol/storage.ts +++ b/web/protocol/storage.ts @@ -1,6 +1,9 @@ import type { LODCacheRecord } from "./lod"; import type { SimulationCacheManifestIR } from "./simulation-cache"; +import type { RecentProjectIssue, RecentProjectRecord } from "./recent-projects"; +import type { StorageBudgetBreakdown } from "./storage-budget"; import type { ErrorCode } from "./error"; +import type { TexturePaintTileBindingRequestIR, TexturePaintTileBindingResultIR, TexturePaintTileCommitIR, TexturePaintTileCommitResultIR } from "./texture-paint-asset"; export interface StorageSmokeResult { backend: "indexeddb"; @@ -18,12 +21,27 @@ export interface StorageInfoResult { stores: string[]; } +export interface StorageBudgetResult extends StorageBudgetBreakdown {} + +export interface StorageRecentProjectsResult { + projects: RecentProjectRecord[]; + quarantined: number; + issues: RecentProjectIssue[]; +} + export interface StorageProjectResult { projectId: string; scenePath: string; directories: string[]; } +export interface StorageProjectCleanupResult { + projectId: string; + removed: number; + bytes: number; + paths: string[]; +} + export interface StorageSaveResult { projectId: string; bytes: number; @@ -176,6 +194,35 @@ export interface StorageSimulationCacheFrameReadResult extends StorageSimulation data: ArrayBuffer; } +export interface StorageSimulationCachePlaybackReadyResult extends StorageSimulationCacheResult { + verifiedAt: string; +} + +export interface StorageSimulationCachePlaybackReleaseResult { + projectId: string; + cacheKey: string; + released: true; +} + +export interface StorageSimulationCachePruneResult { + projectId: string; + maxBytes: number; + beforeBytes: number; + remainingBytes: number; + removedBytes: number; + removed: number; + cacheKeys: string[]; + protectedCacheKeys: string[]; + budgetSatisfied: boolean; +} + +export interface StorageSimulationCacheQuarantineIssue { + cacheKey: string; + code: ErrorCode; + reason: string; + quarantinedAt: string; +} + export interface StorageSimulationCacheListResult { projectId: string; caches: Array<{ @@ -183,14 +230,23 @@ export interface StorageSimulationCacheListResult { manifest: SimulationCacheManifestIR; path: string; createdAt: string; + lastAccessAt: string; }>; + quarantined: number; + issues: StorageSimulationCacheQuarantineIssue[]; } export interface StorageRequest { requestId: string; command: | { type: "smoke" } + | { type: "crashForTest" } | { type: "info" } + | { type: "getBudget"; projectId: string } + | { type: "cleanupProject"; projectId: string } + | { type: "listRecentProjects" } + | { type: "touchRecentProject"; project: RecentProjectRecord } + | { type: "removeRecentProject"; projectId: string } | { type: "ensureProject"; projectId: string } | { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota" } | { type: "recoverProject"; projectId: string } @@ -204,6 +260,8 @@ export interface StorageRequest { | { type: "putAsset"; projectId: string; data: ArrayBuffer; mimeType: string; sourcePath?: string } | { type: "readAsset"; projectId: string; sha256: string } | { type: "listAssets"; projectId: string } + | { type: "commitTexturePaintTile"; commit: TexturePaintTileCommitIR } + | { type: "readTexturePaintTileBinding"; request: TexturePaintTileBindingRequestIR } | { type: "saveLOD"; projectId: string; cacheKey: string; data: ArrayBuffer } | { type: "putLODManifest"; projectId: string; manifest: LODCacheRecord } | { type: "getLODManifest"; projectId: string; cacheKey: string } @@ -212,15 +270,19 @@ export interface StorageRequest { | { type: "deleteLOD"; projectId: string; cacheKey: string } | { type: "pruneLOD"; projectId: string; maxBytes: number } | { type: "putSimulationCache"; projectId: string; manifest: SimulationCacheManifestIR; data: ArrayBuffer } + | { type: "prepareSimulationCachePlayback"; projectId: string; cacheKey: string } + | { type: "releaseSimulationCachePlayback"; projectId: string; cacheKey: string } | { type: "readSimulationCache"; projectId: string; cacheKey: string } | { type: "readSimulationCacheFrame"; projectId: string; cacheKey: string; frame: number } - | { type: "listSimulationCaches"; projectId: string }; + | { type: "listSimulationCaches"; projectId: string } + | { type: "pruneSimulationCaches"; projectId: string; maxBytes: number; protectedCacheKeys?: string[] } + | { type: "cancelRequest"; targetRequestId: string }; } export interface StorageResponse { requestId: string; ok: boolean; - result?: StorageSmokeResult | StorageInfoResult | StorageProjectResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheFrameReadResult | StorageSimulationCacheListResult; + result?: StorageSmokeResult | StorageInfoResult | StorageBudgetResult | StorageRecentProjectsResult | StorageProjectResult | StorageProjectCleanupResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | TexturePaintTileCommitResultIR | TexturePaintTileBindingResultIR | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheFrameReadResult | StorageSimulationCachePlaybackReadyResult | StorageSimulationCachePlaybackReleaseResult | StorageSimulationCachePruneResult | StorageSimulationCacheListResult; error?: string; errorCode?: ErrorCode; } diff --git a/web/protocol/texture-paint-asset.ts b/web/protocol/texture-paint-asset.ts new file mode 100644 index 00000000..c590ba31 --- /dev/null +++ b/web/protocol/texture-paint-asset.ts @@ -0,0 +1,177 @@ +import { parseUdimTilePatch, type UdimTilePatchIR } from "./paint"; + +export const TEXTURE_PAINT_ASSET_SCHEMA_VERSION = 1 as const; + +export type TexturePaintTileKind = "PACKED" | "UDIM"; +export type TexturePaintTileFault = "before-asset-write" | "after-asset-write" | "before-binding-commit"; + +export interface TexturePaintTileTargetIR { + schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION; + projectId: string; + imageId: string; + textureAssetId: string; + kind: TexturePaintTileKind; + tile: number; + revision: number; + width: number; + height: number; + mimeType: "image/png"; + colorSpace: "SRGB" | "LINEAR"; + sourcePath: string; + baseAssetSha256: string; +} + +export interface TexturePaintTileCommitIR { + schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION; + target: TexturePaintTileTargetIR; + patch: UdimTilePatchIR; + faultAt?: TexturePaintTileFault; +} + +export interface TexturePaintTileBindingRequestIR { + schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION; + projectId: string; + textureAssetId: string; + tile: number; +} + +export interface TexturePaintTileBindingIR { + schemaVersion: typeof TEXTURE_PAINT_ASSET_SCHEMA_VERSION; + projectId: string; + imageId: string; + textureAssetId: string; + kind: TexturePaintTileKind; + tile: number; + revision: number; + generation: number; + width: number; + height: number; + mimeType: "image/png"; + colorSpace: "SRGB" | "LINEAR"; + sourcePath: string; + assetId: string; + assetSha256: string; + pixelSha256: string; + bytes: number; + path: string; + updatedAt: string; +} + +export interface TexturePaintTileCommitResultIR { + projectId: string; + persisted: true; + binding: TexturePaintTileBindingIR; + previousAssetSha256: string; + orphanedAssetPossible: boolean; +} + +export interface TexturePaintTileBindingResultIR { + projectId: string; + binding?: TexturePaintTileBindingIR; +} + +const SHA256 = /^[a-f0-9]{64}$/; +const TARGET_FIELDS = new Set(["schemaVersion", "projectId", "imageId", "textureAssetId", "kind", "tile", "revision", "width", "height", "mimeType", "colorSpace", "sourcePath", "baseAssetSha256"]); +const COMMIT_FIELDS = new Set(["schemaVersion", "target", "patch", "faultAt"]); +const BINDING_REQUEST_FIELDS = new Set(["schemaVersion", "projectId", "textureAssetId", "tile"]); +const encoder = new TextEncoder(); + +function fail(message: string): never { + throw new Error(`PAINT_SCHEMA_INVALID: ${message}`); +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${label} must be an object`); + return value as Record; +} + +function exact(value: Record, fields: ReadonlySet, label: string): void { + if (Object.keys(value).some((field) => !fields.has(field))) fail(`${label} contains undeclared fields`); +} + +function boundedString(value: unknown, label: string, prefix?: string, maxBytes = 512): string { + if (typeof value !== "string" || value.length === 0 || (prefix !== undefined && !value.startsWith(prefix)) || encoder.encode(value).byteLength > maxBytes) { + fail(`${label} is outside the bounded identity range`); + } + return value; +} + +function integer(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail(`${label} must be a non-negative safe integer`); + return value; +} + +function tileNumber(value: unknown): number { + const tile = integer(value, "tile"); + if (tile < 1001 || tile > 1999) fail("tile must be in [1001,1999]"); + return tile; +} + +function digest(value: unknown, label: string): string { + if (typeof value !== "string" || !SHA256.test(value)) fail(`${label} must be a lowercase SHA-256 digest`); + return value; +} + +export function validateTexturePaintTileTarget(value: unknown): TexturePaintTileTargetIR { + const target = record(value, "Texture paint target"); + exact(target, TARGET_FIELDS, "Texture paint target"); + if (target.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported"); + if (target.kind !== "PACKED" && target.kind !== "UDIM") fail("Texture paint target kind is invalid"); + if (target.mimeType !== "image/png") fail("Texture paint atomic tile currently requires packed PNG bytes"); + if (target.colorSpace !== "SRGB" && target.colorSpace !== "LINEAR") fail("Texture paint color space is invalid"); + const width = integer(target.width, "width"); + const height = integer(target.height, "height"); + if (width < 1 || height < 1 || width > 16_384 || height > 16_384 || width * height * 4 > 256 * 1024 * 1024) { + throw new Error("PAINT_BUDGET_EXCEEDED: Texture paint tile dimensions exceed the RGBA8 budget"); + } + const sourcePath = boundedString(target.sourcePath, "sourcePath", undefined, 1024); + if (sourcePath.includes("\\") || sourcePath.split("/").includes("..")) fail("Texture paint source path escapes the project"); + return { + schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION, + projectId: boundedString(target.projectId, "projectId", undefined, 128), + imageId: boundedString(target.imageId, "imageId", "image:", 256), + textureAssetId: boundedString(target.textureAssetId, "textureAssetId", undefined, 256), + kind: target.kind, + tile: tileNumber(target.tile), + revision: integer(target.revision, "revision"), + width, + height, + mimeType: "image/png", + colorSpace: target.colorSpace, + sourcePath, + baseAssetSha256: digest(target.baseAssetSha256, "baseAssetSha256"), + }; +} + +export function validateTexturePaintTileCommit(value: unknown): TexturePaintTileCommitIR { + const input = record(value, "Texture paint commit"); + exact(input, COMMIT_FIELDS, "Texture paint commit"); + if (input.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported"); + const target = validateTexturePaintTileTarget(input.target); + const patch = parseUdimTilePatch(input.patch); + if (patch.textureAssetId !== target.textureAssetId || patch.tile !== target.tile || patch.revision !== target.revision || + patch.width !== target.width || patch.height !== target.height || patch.colorSpace !== target.colorSpace) { + fail("Texture paint patch does not match its packed tile target"); + } + const faultAt = input.faultAt; + if (faultAt !== undefined && faultAt !== "before-asset-write" && faultAt !== "after-asset-write" && faultAt !== "before-binding-commit") { + fail("Texture paint fault injection point is invalid"); + } + return { schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION, target, patch, faultAt }; +} + +export function validateTexturePaintTileBindingRequest(value: unknown): TexturePaintTileBindingRequestIR { + const input = record(value, "Texture paint binding request"); + exact(input, BINDING_REQUEST_FIELDS, "Texture paint binding request"); + if (input.schemaVersion !== TEXTURE_PAINT_ASSET_SCHEMA_VERSION) fail("Texture paint asset schema is unsupported"); + return { + schemaVersion: TEXTURE_PAINT_ASSET_SCHEMA_VERSION, + projectId: boundedString(input.projectId, "projectId", undefined, 128), + textureAssetId: boundedString(input.textureAssetId, "textureAssetId", undefined, 256), + tile: tileNumber(input.tile), + }; +} + +export function texturePaintTileBindingKey(request: TexturePaintTileBindingRequestIR): string { + return `texture-paint:v1:${request.projectId}:${encodeURIComponent(request.textureAssetId)}:${request.tile}`; +} diff --git a/web/protocol/ui-schema.ts b/web/protocol/ui-schema.ts index ee537daa..71eb1c44 100644 --- a/web/protocol/ui-schema.ts +++ b/web/protocol/ui-schema.ts @@ -40,6 +40,7 @@ export interface WebWorkspaceState { workspaces: Record; context: UIContextIR; operatorSearchOpen: boolean; + openMenu: string | null; sidebarVisible: boolean; } @@ -48,6 +49,7 @@ export type UICommand = | { type: "setMode"; mode: BlenderMode } | { type: "setActiveArea"; areaId: string } | { type: "toggleOperatorSearch"; open?: boolean } + | { type: "toggleMenu"; menu?: string } | { type: "toggleSidebar"; visible?: boolean }; function regions(): RegionIR[] { @@ -92,6 +94,7 @@ export function createDefaultWebWorkspaceState(): WebWorkspaceState { revision: 0, }, operatorSearchOpen: false, + openMenu: null, sidebarVisible: true, }; } @@ -122,7 +125,9 @@ export function reduceUICommand(state: WebWorkspaceState, command: UICommand): W }; } case "toggleOperatorSearch": - return { ...state, operatorSearchOpen: command.open ?? !state.operatorSearchOpen }; + return { ...state, operatorSearchOpen: command.open ?? !state.operatorSearchOpen, openMenu: command.open === false ? state.openMenu : null }; + case "toggleMenu": + return { ...state, openMenu: state.openMenu === command.menu ? null : command.menu ?? null, operatorSearchOpen: null === command.menu ? state.operatorSearchOpen : false }; case "toggleSidebar": return { ...state, sidebarVisible: command.visible ?? !state.sidebarVisible }; } diff --git a/web/protocol/viewport-camera.ts b/web/protocol/viewport-camera.ts new file mode 100644 index 00000000..aa0a00e7 --- /dev/null +++ b/web/protocol/viewport-camera.ts @@ -0,0 +1,58 @@ +export interface ViewportOrbitState { + yaw: number; + pitch: number; + distance: number; + target: [number, number, number]; +} + +export interface ViewportCameraState extends ViewportOrbitState { + position: [number, number, number]; +} + +export const VIEWPORT_DEFAULT_ORBIT: Readonly = Object.freeze({ + yaw: -Math.PI / 4, + pitch: 0.55, + distance: 7, + target: [0, 0, 0] as [number, number, number], +}); + +export const VIEWPORT_ORBIT_ROTATE_SENSITIVITY = 0.008; +export const VIEWPORT_ORBIT_ZOOM_SENSITIVITY = 0.001; +export const VIEWPORT_ORBIT_MIN_DISTANCE = 0.2; +export const VIEWPORT_ORBIT_MAX_DISTANCE = 500; + +export function orbitPosition(state: Pick): [number, number, number] { + const horizontal = state.distance * Math.cos(state.pitch); + return [ + state.target[0] + horizontal * Math.cos(state.yaw), + state.target[1] + horizontal * Math.sin(state.yaw), + state.target[2] + state.distance * Math.sin(state.pitch), + ]; +} + +export function cameraState(state: ViewportOrbitState): ViewportCameraState { + return { ...state, target: [...state.target] as [number, number, number], position: orbitPosition(state) }; +} + +export function orbitStateFromPosition(position: readonly number[], target: readonly number[] = [0, 0, 0]): ViewportOrbitState { + const dx = position[0] - target[0]; + const dy = position[1] - target[1]; + const dz = position[2] - target[2]; + const distance = Math.max(VIEWPORT_ORBIT_MIN_DISTANCE, Math.hypot(dx, dy, dz)); + return { + yaw: Math.atan2(dy, dx), + pitch: Math.asin(Math.max(-1, Math.min(1, dz / distance))), + distance, + target: [target[0], target[1], target[2]], + }; +} + +export function applyOrbitDelta(state: ViewportOrbitState, deltaX: number, deltaY: number, zoom: number): ViewportOrbitState { + return { + ...state, + yaw: state.yaw - deltaX * VIEWPORT_ORBIT_ROTATE_SENSITIVITY, + pitch: Math.max(-1.45, Math.min(1.45, state.pitch + deltaY * VIEWPORT_ORBIT_ROTATE_SENSITIVITY)), + distance: Math.max(VIEWPORT_ORBIT_MIN_DISTANCE, Math.min(VIEWPORT_ORBIT_MAX_DISTANCE, state.distance * Math.exp(zoom * VIEWPORT_ORBIT_ZOOM_SENSITIVITY))), + target: [...state.target] as [number, number, number], + }; +} diff --git a/web/protocol/web-engine.ts b/web/protocol/web-engine.ts index 710540ba..d70bdbd0 100644 --- a/web/protocol/web-engine.ts +++ b/web/protocol/web-engine.ts @@ -10,10 +10,19 @@ import type { DepsgraphEvaluationIR } from "./depsgraph"; import type { SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt"; import type { GeometryNodeGraphIR } from "./geometry-nodes"; import type { ShaderGraphIR } from "./shader-graph"; -import type { NlaTrackIR } from "./nla"; +import type { ShaderCompileReport } from "./shader-compiler"; +import type { NlaMoveStripCommand, NlaTrackIR } from "./nla"; import type { RenderCapabilityRequest } from "./render-capabilities"; import type { CapabilityGateResult } from "./capability-gates"; import type { NonMeshGeometryChunk } from "./nonmesh-binary"; +import type { + PaintStrokeSessionBeginIR, + PaintStrokeSessionCancelIR, + PaintStrokeSessionChunkIR, + PaintStrokeSessionCommitIR, + PaintStrokeSessionReceiptIR, +} from "./paint-stroke-session"; +import type { PaintPBVHCapabilityRequest } from "./paint-pbvh-capability"; export interface MeshGeometryBuffer { schemaVersion: 1; @@ -112,16 +121,18 @@ export type WebEngineEditCommand = | { type: "setFontBody"; dataId: string; body: string } | { type: "setFontProperties"; dataId: string; properties: Partial } | { type: "setFontAdvanced"; dataId: string; characters: NonMeshFontCharacterIR[]; textBoxes: NonMeshFontTextBoxIR[]; activeTextBox: number } + | { type: "importVFont"; schemaVersion: 1; projectId: string; assetId: string; assetPath: string; sourcePath: string; name: string; format: "TTF" | "OTF" | "PFB"; mimeType: string; byteLength: number; sha256: string; base64: string } | { type: "setFontLinks"; dataId: string; links: NonMeshFontLinksIR } | { type: "setVolumeProperties"; dataId: string; sourcePath: string; displayDensity: number; interpolation: "NEAREST" | "LINEAR"; stepSize: number; velocityGrid?: string; velocityScale?: number } | { type: "createGreasePencilLayer"; dataId: string; name: string } | { type: "removeGreasePencilLayer"; dataId: string; layerId: string } - | { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM" } + | { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM"; baseRevision?: number } + | { type: "moveGreasePencilFrame"; dataId: string; layerId: string; frame: number; targetFrame: number; drawingId: string; baseRevision: number } | { type: "insertGreasePencilFrame"; dataId: string; layerId: string; frame: number; duration?: number } | { type: "removeGreasePencilFrame"; dataId: string; layerId: string; frame: number } | { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; baseRevision?: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> } | { type: "setVertexColors"; meshId: string; attributeName: string; domain: "POINT" | "CORNER"; indices: number[]; colors: number[] } - | { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean } + | { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; limit?: number; mirror?: boolean; mirrorAxis?: 0 | 1 | 2; mirrorTolerance?: number } | { type: "setLightProperties"; dataId: string; properties: { color?: [number, number, number]; energy?: number; exposure?: number; temperature?: number; useTemperature?: boolean; castsShadow?: boolean; radius?: number; spotAngle?: number; spotBlend?: number; areaSize?: number; areaSizeY?: number; areaSpread?: number; sunAngle?: number } } | { type: "setCameraProperties"; dataId: string; properties: { projection?: "PERSPECTIVE" | "ORTHOGRAPHIC"; lensMm?: number; sensorWidthMm?: number; sensorHeightMm?: number; sensorFit?: 0 | 1 | 2; shift?: [number, number]; near?: number; far?: number; orthoScale?: number; depthOfField?: { enabled?: boolean; focusDistance?: number; apertureFStop?: number; apertureBlades?: number; apertureRotation?: number; apertureRatio?: number } } } | { type: "setWorldProperties"; dataId: string; properties: { color?: [number, number, number]; exposure?: number; mist?: { enabled?: boolean; type?: "QUADRATIC" | "LINEAR" | "INVERSE_QUADRATIC"; start?: number; depth?: number; intensity?: number; height?: number } } } @@ -130,17 +141,24 @@ export type WebEngineEditCommand = | { type: "setSculptMeshAttributes"; attributes: SculptMeshAttributesIR } | { type: "setGeometryNodeGraph"; meshId: string; graph: GeometryNodeGraphIR } | { type: "setShaderGraph"; materialId: string; graph: ShaderGraphIR } - | { type: "setNLAStack"; objectId: string; tracks: NlaTrackIR[] } + | { type: "setNLAStack"; objectId: string; tracks: NlaTrackIR[]; baseRevision?: number } + | NlaMoveStripCommand | { type: "undo" } | { type: "redo" }; export type WebEngineRequest = | { requestId: string; command: { type: "init" } } + | { requestId: string; command: { type: "crashForTest" } } | { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; } | { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } } | { requestId: string; command: { type: "openResourceStatus" } } | { requestId: string; command: { type: "snapshot" } } | { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } } + | { requestId: string; command: { type: "beginPaintStroke"; session: PaintStrokeSessionBeginIR } } + | { requestId: string; command: { type: "appendPaintStrokeChunk"; chunk: PaintStrokeSessionChunkIR } } + | { requestId: string; command: { type: "commitPaintStroke"; session: PaintStrokeSessionCommitIR } } + | { requestId: string; command: { type: "cancelPaintStroke"; session: PaintStrokeSessionCancelIR } } + | { requestId: string; command: { type: "queryPaintPBVHCapability"; request: PaintPBVHCapabilityRequest } } | { requestId: string; command: { type: "generateLOD"; payload: LODGenerationRequest } } | { requestId: string; command: { type: "delta" } } | { requestId: string; command: { type: "requestAsset"; assetId: string } } @@ -209,9 +227,11 @@ export interface WebEngineResult { lod?: WebEngineLODResult; asset?: AssetRequestResult; capabilityGate?: CapabilityGateResult; + shaderCompile?: ShaderCompileReport; depsgraph?: DepsgraphEvaluationIR; blend?: ArrayBuffer; openResources?: WebEngineOpenResourceStatus; + paintStrokeSession?: PaintStrokeSessionReceiptIR; } export type WebEngineResponse = diff --git a/web/protocol/weight-paint.ts b/web/protocol/weight-paint.ts new file mode 100644 index 00000000..fac0c4d1 --- /dev/null +++ b/web/protocol/weight-paint.ts @@ -0,0 +1,195 @@ +/** + * Bounded weight-paint operation contract shared by validation and tests. + * Native Blender remains authoritative for Main writes; these helpers make + * the ordering and symmetry rules explicit before a command crosses the + * Worker boundary. + */ + +export const WEIGHT_PAINT_SCHEMA_VERSION = 1 as const; +export const WEIGHT_PAINT_BUDGET = { + maxVertices: 1_000_000, + maxInfluencesPerVertex: 32, + maxMirrorTolerance: 1, +} as const; + +export interface WeightPaintOptionsIR { + schemaVersion: typeof WEIGHT_PAINT_SCHEMA_VERSION; + normalize: boolean; + limit?: number; + mirror: boolean; + mirrorAxis: 0 | 1 | 2; + mirrorTolerance: number; +} + +export interface WeightPaintVertexIR { + index: number; + position: [number, number, number]; + influences: Array<{ group: string; weight: number }>; +} + +export interface WeightPaintPatchIR { + vertexGroup: string; + indices: number[]; + values: number[]; + options: WeightPaintOptionsIR; +} + +function fail(message: string): never { + throw new Error(`PAINT_SCHEMA_INVALID: ${message}`); +} + +function finite(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be finite`); + return value; +} + +function integer(value: unknown, label: string): number { + const result = finite(value, label); + if (!Number.isSafeInteger(result) || result < 0) fail(`${label} must be a non-negative safe integer`); + return result; +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0 || value.length > 63) fail(`${label} is outside the bounded range`); + return value; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") fail(`${label} must be boolean`); + return value; +} + +export function parseWeightPaintOptions(value: unknown = {}): WeightPaintOptionsIR { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail("options must be an object"); + const source = value as Record; + const allowed = new Set(["schemaVersion", "normalize", "limit", "mirror", "mirrorAxis", "mirrorTolerance"]); + if (Object.keys(source).some((key) => !allowed.has(key))) fail("options contains undeclared fields"); + if (source.schemaVersion !== undefined && source.schemaVersion !== WEIGHT_PAINT_SCHEMA_VERSION) fail("options.schemaVersion is unsupported"); + const normalize = source.normalize === undefined ? false : boolean(source.normalize, "options.normalize"); + const mirror = source.mirror === undefined ? false : boolean(source.mirror, "options.mirror"); + const mirrorAxisValue = source.mirrorAxis === undefined ? 0 : integer(source.mirrorAxis, "options.mirrorAxis"); + if (mirrorAxisValue > 2) fail("options.mirrorAxis must be 0, 1 or 2"); + const mirrorTolerance = source.mirrorTolerance === undefined ? 1e-4 : finite(source.mirrorTolerance, "options.mirrorTolerance"); + if (mirrorTolerance <= 0 || mirrorTolerance > WEIGHT_PAINT_BUDGET.maxMirrorTolerance) fail("options.mirrorTolerance is outside the bounded range"); + let limit: number | undefined; + if (source.limit !== undefined) { + limit = integer(source.limit, "options.limit"); + if (limit < 1 || limit > WEIGHT_PAINT_BUDGET.maxInfluencesPerVertex) fail("options.limit is outside the bounded range"); + } + if (!mirror && (source.mirrorAxis !== undefined || source.mirrorTolerance !== undefined)) fail("mirrorAxis/mirrorTolerance require mirror=true"); + return { + schemaVersion: WEIGHT_PAINT_SCHEMA_VERSION, + normalize, + ...(limit === undefined ? {} : { limit }), + mirror, + mirrorAxis: mirrorAxisValue as 0 | 1 | 2, + mirrorTolerance, + }; +} + +export function parseWeightPaintPatch(value: unknown): WeightPaintPatchIR { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail("patch must be an object"); + const source = value as Record; + const vertexGroup = string(source.vertexGroup, "patch.vertexGroup"); + if (!Array.isArray(source.indices) || !Array.isArray(source.values) || source.indices.length === 0 || source.indices.length !== source.values.length) fail("patch indices and values must have equal non-empty lengths"); + if (source.indices.length > WEIGHT_PAINT_BUDGET.maxVertices) throw new Error("PAINT_BUDGET_EXCEEDED: patch exceeds the vertex budget"); + const seen = new Set(); + const indices = source.indices.map((value, offset) => { + const index = integer(value, `patch.indices[${offset}]`); + if (seen.has(index)) fail(`patch.indices[${offset}] contains a duplicate vertex`); + seen.add(index); + return index; + }); + const values = source.values.map((value, offset) => { + const weight = finite(value, `patch.values[${offset}]`); + if (weight < 0 || weight > 1) fail(`patch.values[${offset}] must be in [0,1]`); + return weight; + }); + return { + vertexGroup, + indices, + values, + options: parseWeightPaintOptions({ + schemaVersion: source.schemaVersion, + normalize: source.normalize, + limit: source.limit, + mirror: source.mirror, + mirrorAxis: source.mirrorAxis, + mirrorTolerance: source.mirrorTolerance, + }), + }; +} + +function mirrorMap(vertices: readonly WeightPaintVertexIR[], axis: 0 | 1 | 2, tolerance: number): Map { + const result = new Map(); + for (const vertex of vertices) { + let best: WeightPaintVertexIR | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + for (const candidate of vertices) { + const reflected = [...vertex.position] as [number, number, number]; + reflected[axis] = -reflected[axis]; + const distance = Math.hypot(...reflected.map((value, component) => value - candidate.position[component])); + if (distance < bestDistance || (distance === bestDistance && (best === undefined || candidate.index < best.index))) { + best = candidate; + bestDistance = distance; + } + } + if (!best || bestDistance > tolerance) throw new Error("CAPABILITY_MISSING: WEIGHT_MIRROR_SYMMETRY_UNVERIFIED"); + result.set(vertex.index, best.index); + } + for (const [source, target] of result) if (result.get(target) !== source) throw new Error("CAPABILITY_MISSING: WEIGHT_MIRROR_SYMMETRY_UNVERIFIED"); + return result; +} + +function normalize(influences: Array<{ group: string; weight: number }>): void { + const total = influences.reduce((sum, influence) => sum + influence.weight, 0); + if (total > 0) for (const influence of influences) influence.weight /= total; +} + +function limit(influences: Array<{ group: string; weight: number }>, count: number): void { + influences.sort((left, right) => right.weight - left.weight || left.group.localeCompare(right.group)); + influences.splice(count); +} + +/** Apply the deterministic bounded operation used by the desktop comparison. */ +export function applyWeightPaintPatch(verticesValue: unknown, patchValue: unknown): WeightPaintVertexIR[] { + if (!Array.isArray(verticesValue) || verticesValue.length === 0 || verticesValue.length > WEIGHT_PAINT_BUDGET.maxVertices) fail("vertices exceeds the weight paint budget"); + const vertices = verticesValue.map((value, offset) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`vertices[${offset}] must be an object`); + const source = value as Record; + const index = integer(source.index, `vertices[${offset}].index`); + const positionValue = source.position; + if (!Array.isArray(positionValue) || positionValue.length !== 3) fail(`vertices[${offset}].position must contain three numbers`); + const position = positionValue.map((component, axis) => finite(component, `vertices[${offset}].position[${axis}]`)) as [number, number, number]; + if (!Array.isArray(source.influences)) fail(`vertices[${offset}].influences must be an array`); + const influences = source.influences.map((item, influenceIndex) => { + if (typeof item !== "object" || item === null || Array.isArray(item)) fail(`vertices[${offset}].influences[${influenceIndex}] must be an object`); + const entry = item as Record; + const weight = finite(entry.weight, `vertices[${offset}].influences[${influenceIndex}].weight`); + if (weight < 0 || weight > 1) fail("influence weight must be in [0,1]"); + return { group: string(entry.group, `vertices[${offset}].influences[${influenceIndex}].group`), weight }; + }); + return { index, position, influences }; + }); + const patch = parseWeightPaintPatch(patchValue); + const byIndex = new Map(vertices.map((vertex) => [vertex.index, vertex])); + const targets = new Map(); + patch.indices.forEach((index, offset) => { + if (!byIndex.has(index)) fail(`patch.indices[${offset}] references an unknown vertex`); + targets.set(index, patch.values[offset]); + }); + if (patch.options.mirror) { + const mirrored = mirrorMap(vertices, patch.options.mirrorAxis, patch.options.mirrorTolerance); + for (const [index, value] of [...targets]) targets.set(mirrored.get(index)!, value); + } + for (const [index, value] of targets) { + const vertex = byIndex.get(index)!; + const influence = vertex.influences.find((item) => item.group === patch.vertexGroup); + if (value === 0) vertex.influences.splice(vertex.influences.indexOf(influence!), influence ? 1 : 0); + else if (influence) influence.weight = value; + else vertex.influences.push({ group: patch.vertexGroup, weight: value }); + if (patch.options.limit !== undefined) limit(vertex.influences, patch.options.limit); + if (patch.options.normalize) normalize(vertex.influences); + } + return vertices.map((vertex) => ({ ...vertex, position: [...vertex.position] as [number, number, number], influences: vertex.influences.map((influence) => ({ ...influence })) })); +} diff --git a/web/protocol/worker-fault.ts b/web/protocol/worker-fault.ts new file mode 100644 index 00000000..fa024f80 --- /dev/null +++ b/web/protocol/worker-fault.ts @@ -0,0 +1,21 @@ +import type { ErrorReport } from "./error"; + +export type WorkerFaultSource = "engine" | "storage"; + +export interface WorkerFault { + source: WorkerFaultSource; + error: ErrorReport; +} + +export function createWorkerFault(source: WorkerFaultSource, message: string): WorkerFault { + return { + source, + error: { + code: "WORKER_TERMINATED", + severity: "error", + message, + recoverable: true, + cause: `${source}-worker-fault`, + }, + }; +} diff --git a/web/tests/e2e/compositor-node-golden.spec.ts b/web/tests/e2e/compositor-node-golden.spec.ts new file mode 100644 index 00000000..00ecd815 --- /dev/null +++ b/web/tests/e2e/compositor-node-golden.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-07/compositor-node-golden.json"), "utf8")) as { + width: number; + height: number; + maxAbsoluteError: number; + allowlist: string[]; + fixture: { path: string }; + cases: Array<{ scene: string; newNode: string; nodeTypes: string[]; pixel: number[]; float32Sha256: string }>; +}; +const fixture = fs.readFileSync(path.join(root, golden.fixture.path)); + +test("M11-07 gives every allowlisted compositor node an independent CPU/WebGPU golden", async ({ page }) => { + test.setTimeout(60_000); + await page.goto("/"); + const result = await page.evaluate(async (input) => { + const [{ WebEngineClient }, cpu, gpu] = await Promise.all([ + import("/src/engine-client/WebEngineClient.ts"), + import("/src/compositor/CompositorExecutor.ts"), + import("/src/compositor/CompositorWebGPU.ts"), + ]); + const hash = async (data: Float32Array): Promise => Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength))), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + const client = new WebEngineClient({ timeoutMs: 30_000 }); + const device = await gpu.requestCompositorWebGPUDevice(); + try { + const opened = await client.openBlend(Uint8Array.from(input.bytes).buffer); + const cases = []; + for (const expected of input.cases) { + const scene = opened.snapshot.scenes.find((candidate) => candidate.name === expected.scene); + if (!scene?.compositorGraph) throw new Error(`Missing compositor graph ${expected.scene}`); + const plan = cpu.compileCompositorWebGPUPlan(scene.compositorGraph); + const cpuResult = cpu.executeCompositorGraph(scene.compositorGraph, new Map(), { width: input.width, height: input.height }).composite; + const gpuResult = await gpu.executeCompositorGraphWebGPU(device, scene.compositorGraph, input.width, input.height); + let maxAbsoluteError = 0; + for (let index = 0; index < cpuResult.data.length; index++) maxAbsoluteError = Math.max(maxAbsoluteError, Math.abs(cpuResult.data[index] - gpuResult.data[index])); + cases.push({ + scene: expected.scene, + newNode: expected.newNode, + instructionTypes: plan.instructions.map((instruction) => instruction.type), + cpuPixel: Array.from(cpuResult.data.slice(0, 4)), + gpuPixel: Array.from(gpuResult.data.slice(0, 4)), + cpuSha256: await hash(cpuResult.data), + gpuSha256: await hash(gpuResult.data), + maxAbsoluteError, + }); + } + const base = opened.snapshot.scenes.find((candidate) => candidate.name === input.cases[0].scene)?.compositorGraph; + if (!base) throw new Error("Missing base compositor graph"); + let blockedCode = ""; + try { + cpu.compileCompositorWebGPUPlan({ ...base, nodes: [...base.nodes, { id: "blocked:viewer", type: "VIEWER", name: "Blocked Viewer", properties: {} }] }); + } + catch (error) { blockedCode = error instanceof cpu.CompositorValidationError ? error.code : String(error); } + return { allowlist: cpu.COMPOSITOR_WEBGPU_NODE_ALLOWLIST, cases, blockedCode }; + } + finally { + device.destroy(); + client.terminate(); + } + }, { bytes: Array.from(fixture), cases: golden.cases, width: golden.width, height: golden.height }); + + expect(result.allowlist).toEqual(golden.allowlist); + expect(result.blockedCode).toBe("COMPOSITOR_NODE_UNSUPPORTED"); + for (const [index, candidate] of result.cases.entries()) { + const expected = golden.cases[index]; + expect(candidate.scene).toBe(expected.scene); + expect(candidate.newNode).toBe(expected.newNode); + expect(candidate.instructionTypes).toEqual(expected.nodeTypes); + expect(candidate.cpuPixel).toEqual(expected.pixel); + expect(candidate.gpuPixel).toEqual(expected.pixel); + expect(candidate.cpuSha256).toBe(expected.float32Sha256); + expect(candidate.gpuSha256).toBe(expected.float32Sha256); + expect(candidate.maxAbsoluteError).toBeLessThanOrEqual(golden.maxAbsoluteError); + } +}); diff --git a/web/tests/e2e/compositor-unsupported-gate.spec.ts b/web/tests/e2e/compositor-unsupported-gate.spec.ts new file mode 100644 index 00000000..42f16e4d --- /dev/null +++ b/web/tests/e2e/compositor-unsupported-gate.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-08/compositor-unsupported-gate.json"), "utf8")) as { + fixture: { path: string; sha256: string }; + scene: string; + unsupportedNode: { name: string; type: string; blenderType: string }; + expectedErrorCode: string; + expectedRevisionDelta: number; +}; +const fixture = fs.readFileSync(path.join(root, golden.fixture.path)); + +test("M11-08 keeps the Blender graph intact while blocking unsupported compositor execution", async ({ page }) => { + expect(crypto.createHash("sha256").update(fixture).digest("hex")).toBe(golden.fixture.sha256); + await page.goto("/"); + const result = await page.evaluate(async (input) => { + const [{ WebEngineClient }, compositor] = await Promise.all([ + import("/src/engine-client/WebEngineClient.ts"), + import("/src/compositor/CompositorExecutor.ts"), + ]); + const client = new WebEngineClient({ timeoutMs: 30_000 }); + await client.init(); + const opened = await client.openBlend(input.bytes.buffer); + const scene = opened.snapshot.scenes.find((candidate) => candidate.name === input.scene); + if (!scene?.compositorGraph) throw new Error(`Missing compositor graph ${input.scene}`); + const beforeGraph = JSON.stringify(scene.compositorGraph); + const beforeRevision = opened.snapshot.revision; + const unsupported = scene.compositorGraph.nodes.find((node) => node.type === input.unsupportedNode.type); + let gateCode = ""; + let executeCode = ""; + let cachedCode = ""; + try { gateCode = compositor.gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code ?? ""; } catch (error) { gateCode = String(error); } + try { compositor.executeCompositorGraph(scene.compositorGraph, new Map(), { width: 2, height: 2 }); } + catch (error) { executeCode = error instanceof compositor.CompositorValidationError ? error.code : String(error); } + try { + await compositor.executeCompositorGraphCached(scene.compositorGraph, new Map(), new compositor.CompositorFrameCache(512), { frame: 1, width: 2, height: 2 }); + } + catch (error) { cachedCode = error instanceof compositor.CompositorValidationError ? error.code : String(error); } + const after = await client.snapshot(); + client.terminate(); + const afterScene = after.snapshot.scenes.find((candidate) => candidate.name === input.scene); + return { + beforeGraph, + afterGraph: JSON.stringify(afterScene?.compositorGraph), + beforeRevision, + afterRevision: after.snapshot.revision, + gateCode, + executeCode, + cachedCode, + unsupported: unsupported && { name: unsupported.name, type: unsupported.type, blenderType: unsupported.blenderType }, + }; + }, { bytes: new Uint8Array(fixture), scene: golden.scene, unsupportedNode: golden.unsupportedNode }); + + expect(result.gateCode).toBe(golden.expectedErrorCode); + expect(result.executeCode).toBe(golden.expectedErrorCode); + expect(result.cachedCode).toBe(golden.expectedErrorCode); + expect(result.unsupported).toEqual(golden.unsupportedNode); + expect(result.afterGraph).toBe(result.beforeGraph); + expect(result.afterRevision - result.beforeRevision).toBe(golden.expectedRevisionDelta); +}); diff --git a/web/tests/e2e/curve-topology-contract.spec.ts b/web/tests/e2e/curve-topology-contract.spec.ts new file mode 100644 index 00000000..05993986 --- /dev/null +++ b/web/tests/e2e/curve-topology-contract.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test"; + +test("M9-04 freezes Curve topology operators and budgets before Main or UI exposure", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/curve-topology-contract-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage(null); + })); + expect(result).toMatchObject({ + operatorCount: 14, + blockedCount: 13, + readyOperators: ["TOGGLE_CYCLIC"], + sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc", + atomicMainTransaction: true, + unknownCode: "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", + staleCode: "REVISION_CONFLICT", + budgetCode: "NON_MESH_DATA_BUDGET_EXCEEDED", + stage: "ONE_VERIFIED_OPERATOR", + }); + expect(result.accepted).toMatchObject({ operator: "ADD_SPLINE", outputSplineCount: 2, outputPointCount: 4 }); +}); diff --git a/web/tests/e2e/curve-topology-operator.spec.ts b/web/tests/e2e/curve-topology-operator.spec.ts new file mode 100644 index 00000000..b42f4644 --- /dev/null +++ b/web/tests/e2e/curve-topology-operator.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"); +const golden = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M9-05/curve-toggle-cyclic.json"), "utf8")); + +test("M9-05 exposes TOGGLE_CYCLIC only after Main, undo, save and Blender golden verification", async ({ page }) => { + test.setTimeout(120_000); + const fixtureBytes = fs.readFileSync(fixture); + expect(crypto.createHash("sha256").update(fixtureBytes).digest("hex")).toBe(golden.fixtureSha256); + + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await page.getByTestId("blend-file-input").setInputFiles(fixture); + const curveRow = page.locator('[data-data-id="curve:WebCurveData"]'); + await expect(curveRow).toBeVisible(); + await curveRow.click(); + + const app = page.locator(".blender-app"); + const editor = page.getByTestId("curve-topology-editor"); + const cyclic = page.getByRole("checkbox", { name: "Curve cyclic U" }); + await expect(editor).toHaveAttribute("data-ready-operators", "TOGGLE_CYCLIC"); + await expect(editor).toHaveAttribute("data-spline-types", golden.splineTypes.join(",")); + await expect(editor).toHaveAttribute("data-point-count", String(golden.splinePointCounts.reduce((sum: number, count: number) => sum + count, 0))); + await expect(editor).toHaveAttribute("data-cyclic-u", golden.beforeCyclicU.join(",")); + await expect(cyclic).not.toBeChecked(); + const selectedRevision = Number(await app.getAttribute("data-current-main-revision")); + + await cyclic.click(); + await expect(editor).toHaveAttribute("data-cyclic-u", golden.afterCyclicU.join(",")); + await expect(cyclic).toBeChecked(); + await expect(app).toHaveAttribute("data-dirty", "true"); + const toggledRevision = Number(await app.getAttribute("data-current-main-revision")); + expect(toggledRevision).toBe(selectedRevision + 1); + + await page.getByRole("button", { name: "撤销" }).click(); + await expect(editor).toHaveAttribute("data-cyclic-u", golden.beforeCyclicU.join(",")); + const undoRevision = Number(await app.getAttribute("data-current-main-revision")); + expect(undoRevision).toBe(toggledRevision + 1); + + await page.getByRole("button", { name: "重做" }).click(); + await expect(editor).toHaveAttribute("data-cyclic-u", golden.afterCyclicU.join(",")); + const redoRevision = Number(await app.getAttribute("data-current-main-revision")); + expect(redoRevision).toBe(undoRevision + 1); + + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await download; + await expect(app).toHaveAttribute("data-dirty", "false"); + await page.getByRole("button", { name: "关闭项目" }).click(); + await expect(editor).toHaveCount(0); + await page.getByRole("button", { name: "恢复项目" }).click(); + await expect(curveRow).toBeVisible(); + await curveRow.click(); + await expect(editor).toHaveAttribute("data-cyclic-u", golden.reopenedCyclicU.join(",")); + await expect(cyclic).toBeChecked(); +}); diff --git a/web/tests/e2e/diagnostic-report.spec.ts b/web/tests/e2e/diagnostic-report.spec.ts new file mode 100644 index 00000000..8ab4415d --- /dev/null +++ b/web/tests/e2e/diagnostic-report.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); +const materialBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend"); + +test("M7-17 keeps Worker detail out of the UI and exports it in a diagnostics report", async ({ page }) => { + await page.goto("/?worker-fault=engine"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const app = page.locator("main.blender-app"); + await page.getByTestId("blend-file-input").setInputFiles(basicBlend); + await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); + const revision = Number(await app.getAttribute("data-current-main-revision")); + + await page.getByTestId("inject-worker-crash").click(); + await expect(app).toHaveAttribute("data-worker-fault-code", "WORKER_TERMINATED"); + await expect(app).toHaveAttribute("data-last-diagnostic-code", "ENGINE_WORKER_TERMINATED"); + await expect(page.getByTestId("engine-status")).toHaveText("Engine: Worker stopped; project remains available"); + await expect(page.locator("body")).not.toContainText("WORKER_CRASH_INJECTED"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("export-diagnostics").click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("blender-web-diagnostics.json"); + const reportPath = await download.path(); + expect(reportPath).not.toBeNull(); + const report = JSON.parse(await fs.readFile(reportPath!, "utf8")); + + expect(report).toMatchObject({ + schemaVersion: 1, + product: "Web Blender Modeler V1", + runtime: { + url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/$/), + userAgent: expect.any(String), + language: expect.any(String), + crossOriginIsolated: true, + }, + project: { projectId: "basic_scene", revision }, + }); + expect(report.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(report.entries).toHaveLength(1); + expect(report.entries[0]).toMatchObject({ + schemaVersion: 1, + sequence: 1, + area: "ENGINE", + code: "ENGINE_WORKER_TERMINATED", + summary: "Engine: Worker stopped; project remains available", + sourceCode: "WORKER_TERMINATED", + context: { projectId: "basic_scene", revision }, + }); + expect(report.entries[0].detail).toContain("WORKER_CRASH_INJECTED"); +}); + +test("M7-17 records open and import details behind stable user messages", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const app = page.locator("main.blender-app"); + await page.getByTestId("blend-file-input").setInputFiles(materialBlend); + await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible(); + + await page.getByTestId("blend-file-input").setInputFiles({ + name: "invalid.blend", + mimeType: "application/octet-stream", + buffer: Buffer.from([0x42, 0x41, 0x44]), + }); + await expect(page.getByTestId("engine-status")).toHaveText("Engine: .blend open failed"); + await expect(app).toHaveAttribute("data-last-diagnostic-code", "BLEND_OPEN_FAILED"); + + await page.locator("label.file-button input[type=file]").setInputFiles({ + name: "invalid.png", + mimeType: "image/png", + buffer: Buffer.from("not-a-png"), + }); + await expect(page.getByTestId("engine-status")).toHaveText("Image import failed"); + await expect(app).toHaveAttribute("data-last-diagnostic-code", "IMAGE_IMPORT_FAILED"); + await expect(app).toHaveAttribute("data-diagnostic-count", "2"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("export-diagnostics").click(); + const reportPath = await (await downloadPromise).path(); + const report = JSON.parse(await fs.readFile(reportPath!, "utf8")); + expect(report.entries.map((entry: { code: string }) => entry.code)).toEqual(["BLEND_OPEN_FAILED", "IMAGE_IMPORT_FAILED"]); + expect(report.entries.every((entry: { detail: string }) => entry.detail.length > 0)).toBe(true); + expect(report.entries[0].summary).toBe("Engine: .blend open failed"); + expect(report.entries[1].summary).toBe("Image import failed"); +}); diff --git a/web/tests/e2e/editing-domain-recovery.spec.ts b/web/tests/e2e/editing-domain-recovery.spec.ts new file mode 100644 index 00000000..376a47ee --- /dev/null +++ b/web/tests/e2e/editing-domain-recovery.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const fixtures = { + CURVE: path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"), + GREASE_PENCIL: path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend"), + PAINT: path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend"), +}; + +test("M9-14 recovers Curve, Grease Pencil and Paint after Worker, OOM and GPU release faults", async ({ page }) => { + test.setTimeout(180_000); + await page.goto("/"); + const input = Object.fromEntries(Object.entries(fixtures).map(([domain, file]) => [domain, Array.from(fs.readFileSync(file))])); + const reports = await page.evaluate(async (bytes) => { + const { runEditingDomainRecoverySuite } = await import("/src/testing/editing-domain-recovery.ts"); + const buffers = Object.fromEntries(Object.entries(bytes).map(([domain, value]) => [domain, Uint8Array.from(value as number[]).buffer])); + return runEditingDomainRecoverySuite(buffers); + }, input); + + expect(reports.map((report) => report.domain)).toEqual(["CURVE", "GREASE_PENCIL", "PAINT"]); + for (const report of reports) { + expect(report.schemaVersion).toBe(1); + expect(report.workerRestart).toMatchObject({ status: "RECOVERED", workerGeneration: 2, temporaryResourcesAfter: 0 }); + expect(report.workerRestart.hashAfter).toBe(report.baseline.identityHash); + expect(report.oom).toMatchObject({ status: "RECOVERED", faultPoint: "GPU_GEOMETRY_UPLOAD", code: "GPU_GEOMETRY_BUDGET_EXCEEDED", temporaryResourcesAfter: 0 }); + expect(report.oom.hashAfter).toBe(report.baseline.identityHash); + expect(report.gpuRelease).toMatchObject({ status: "RECOVERED", backend: "WEBGL2", releaseCount: 1, reinitCount: 1 }); + expect(report.gpuRelease.disposedResources).toBeGreaterThan(0); + expect(report.gpuRelease.visiblePixels).toBeGreaterThan(0); + expect(report.smallScene).toMatchObject({ status: "RECOVERED", identityHash: report.baseline.identityHash, revision: report.baseline.revision, objectCount: report.baseline.objectCount }); + expect(report.smallScene.visiblePixels).toBeGreaterThan(0); + } +}); diff --git a/web/tests/e2e/editing-soak.spec.ts b/web/tests/e2e/editing-soak.spec.ts new file mode 100644 index 00000000..4011c637 --- /dev/null +++ b/web/tests/e2e/editing-soak.spec.ts @@ -0,0 +1,239 @@ +import { expect, test, type Page } from "@playwright/test"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const REQUIRED_DURATION_MS = 30 * 60 * 1_000; +const configuredDurationMs = Number.parseInt(process.env.EDITING_SOAK_DURATION_MS ?? String(REQUIRED_DURATION_MS), 10); +const allowShort = process.env.EDITING_SOAK_ALLOW_SHORT === "1"; +const reportPath = process.env.EDITING_SOAK_REPORT ? path.resolve(process.cwd(), process.env.EDITING_SOAK_REPORT) : null; +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); + +interface StoredProjectSample { + revision: number; + sha256: string; + bytes: number; + snapshotCount: number; +} + +interface ResourceSample { + cycle: number; + label: string; + elapsedMs: number; + jsHeapBytes: number; + storageUsageBytes: number; + storageQuotaBytes: number; +} + +async function readStoredProject(page: Page): Promise { + return page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const storage = new StorageClient(); + try { + const [project, snapshots] = await Promise.all([ + storage.readProject("basic_scene"), + storage.listSnapshots("basic_scene"), + ]); + return { revision: project.revision, sha256: project.sha256, bytes: project.bytes, snapshotCount: snapshots.snapshots.length }; + } + finally { + storage.terminate(); + } + }); +} + +test("M7-18 keeps high-frequency retained snapshots within a bounded origin budget", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const storage = new StorageClient(); + const projectId = `snapshot-soak-${Date.now()}`; + const payload = new Uint8Array(256 * 1024); + payload.fill(0x5a); + const baseline = (await navigator.storage.estimate()).usage ?? 0; + for (let revision = 1; revision <= 128; revision++) { + payload[0] = revision & 0xff; + await storage.saveSnapshot(projectId, revision, payload.slice().buffer, 5, 2 * 1024 * 1024); + } + const snapshots = await storage.listSnapshots(projectId); + const latest = await storage.readSnapshot(projectId, 128); + const final = (await navigator.storage.estimate()).usage ?? 0; + storage.terminate(); + return { + growthBytes: final - baseline, + revisions: snapshots.snapshots.map((snapshot) => snapshot.revision), + latestByte: new Uint8Array(latest.buffer)[0], + }; + }); + expect(result.revisions).toEqual([128, 127, 126, 125, 124]); + expect(result.latestByte).toBe(128); + expect(result.growthBytes).toBeLessThanOrEqual(4 * 1024 * 1024); +}); + +test("M7-18 sustains editing, autosave and OPFS reopen for 30 minutes", async ({ page }, testInfo) => { + if (!Number.isSafeInteger(configuredDurationMs) || configuredDurationMs <= 0) throw new Error("EDITING_SOAK_DURATION_INVALID"); + if (!allowShort && configuredDurationMs < REQUIRED_DURATION_MS) throw new Error("EDITING_SOAK_DURATION_BELOW_30_MINUTES"); + test.setTimeout(configuredDurationMs + 5 * 60 * 1_000); + + const pageErrors: string[] = []; + const resourceSamples: ResourceSample[] = []; + const revisionSamples: number[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + let downloadCount = 0; + page.on("download", () => { downloadCount += 1; }); + + const startedAt = new Date().toISOString(); + let soakStarted = 0; + let actualDurationMs = 0; + let cycles = 0; + let autosaves = 0; + let reopens = 0; + let initialRevision = 0; + let finalStored: StoredProjectSample | null = null; + let failure: string | null = null; + + const app = page.locator("main.blender-app"); + const sampleResources = async (label: string): Promise => { + const cdp = await page.context().newCDPSession(page); + let jsHeapBytes = 0; + try { + await cdp.send("HeapProfiler.collectGarbage"); + await cdp.send("Performance.enable"); + const metrics = await cdp.send("Performance.getMetrics") as { metrics: Array<{ name: string; value: number }> }; + jsHeapBytes = metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value ?? 0; + } + finally { + await cdp.detach(); + } + const storage = await page.evaluate(async () => { + const estimate = await navigator.storage.estimate(); + return { usage: estimate.usage ?? 0, quota: estimate.quota ?? 0 }; + }); + resourceSamples.push({ cycle: cycles, label, elapsedMs: soakStarted ? Date.now() - soakStarted : 0, jsHeapBytes, storageUsageBytes: storage.usage, storageQuotaBytes: storage.quota }); + }; + + const reopen = async (expected: StoredProjectSample): Promise => { + await page.reload(); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const recent = page.getByTestId("recent-projects"); + await expect(recent.locator('option[value="basic_scene"]')).toHaveCount(1, { timeout: 20_000 }); + await recent.selectOption("basic_scene"); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 3", { timeout: 30_000 }); + await expect(app).toHaveAttribute("data-project-id", "basic_scene"); + await expect(app).toHaveAttribute("data-current-main-revision", String(expected.revision)); + await expect(app).toHaveAttribute("data-committed-main-revision", String(expected.revision)); + await expect(app).toHaveAttribute("data-save-committed-hash", expected.sha256); + await expect(app).toHaveAttribute("data-dirty", "false"); + await expect(app).toHaveAttribute("data-diagnostic-count", "0"); + await expect(app).not.toHaveAttribute("data-worker-fault-code", /.+/); + reopens += 1; + }; + + try { + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await page.getByTestId("blend-file-input").setInputFiles(basicBlend); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 3", { timeout: 30_000 }); + const firstDownload = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await firstDownload; + await expect(app).toHaveAttribute("data-dirty", "false"); + finalStored = await readStoredProject(page); + initialRevision = finalStored.revision; + revisionSamples.push(initialRevision); + await sampleResources("baseline"); + + soakStarted = Date.now(); + const reopenEveryCycles = allowShort ? 3 : 12; + const cyclePauseMs = allowShort ? 100 : 2_000; + while (Date.now() - soakStarted < configuredDurationMs) { + await page.getByRole("button", { name: "添加立方体" }).click(); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 4"); + await expect(app).toHaveAttribute("data-dirty", "true"); + await page.getByRole("button", { name: "删除对象" }).click(); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 3"); + await expect(app).toHaveAttribute("data-dirty", "false", { timeout: 15_000 }); + await expect(app).toHaveAttribute("data-save-transaction-status", "SUCCEEDED"); + const currentRevision = Number(await app.getAttribute("data-current-main-revision")); + const committedRevision = Number(await app.getAttribute("data-committed-main-revision")); + expect(currentRevision).toBe(committedRevision); + expect(currentRevision).toBeGreaterThan(revisionSamples.at(-1) ?? 0); + revisionSamples.push(currentRevision); + finalStored = await readStoredProject(page); + expect(finalStored.revision).toBe(currentRevision); + expect(finalStored.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(finalStored.snapshotCount).toBeLessThanOrEqual(5); + await expect(app).toHaveAttribute("data-save-committed-hash", finalStored.sha256); + await expect(app).toHaveAttribute("data-diagnostic-count", "0"); + cycles += 1; + autosaves += 1; + + if (cycles % reopenEveryCycles === 0) { + await reopen(finalStored); + await sampleResources(`reopen-${reopens}`); + console.log(`M7-18 soak progress elapsedMs=${Date.now() - soakStarted} cycles=${cycles} reopens=${reopens} revision=${finalStored.revision}`); + } + if (cyclePauseMs > 0) await page.waitForTimeout(cyclePauseMs); + } + actualDurationMs = Date.now() - soakStarted; + finalStored = await readStoredProject(page); + await reopen(finalStored); + await sampleResources("final"); + + const baseline = resourceSamples[0]; + const final = resourceSamples.at(-1)!; + const heapGrowthBytes = final.jsHeapBytes - baseline.jsHeapBytes; + const storageGrowthBytes = final.storageUsageBytes - baseline.storageUsageBytes; + expect(actualDurationMs).toBeGreaterThanOrEqual(configuredDurationMs); + expect(cycles).toBeGreaterThan(allowShort ? 0 : 100); + expect(reopens).toBeGreaterThanOrEqual(allowShort ? 1 : 20); + expect(autosaves).toBe(cycles); + expect(finalStored.revision).toBeGreaterThanOrEqual(initialRevision + cycles * 2); + expect(finalStored.snapshotCount).toBeLessThanOrEqual(5); + expect(downloadCount).toBe(1); + expect(pageErrors).toEqual([]); + expect(heapGrowthBytes).toBeLessThanOrEqual(64 * 1024 * 1024); + expect(storageGrowthBytes).toBeLessThanOrEqual(16 * 1024 * 1024); + } + catch (error) { + failure = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + throw error; + } + finally { + if (soakStarted && actualDurationMs === 0) actualDurationMs = Date.now() - soakStarted; + const baseline = resourceSamples[0]; + const final = resourceSamples.at(-1); + const report = { + schemaVersion: 1, + status: failure ? "FAILED" : "READY", + profile: allowShort ? "DEBUG" : "FORMAL", + requiredDurationMs: REQUIRED_DURATION_MS, + configuredDurationMs, + actualDurationMs, + startedAt, + finishedAt: new Date().toISOString(), + cycles, + autosaves, + reopens, + initialRevision, + finalRevision: finalStored?.revision ?? 0, + finalSha256: finalStored?.sha256 ?? null, + finalBytes: finalStored?.bytes ?? 0, + finalSnapshotCount: finalStored?.snapshotCount ?? 0, + downloadCount, + pageErrors, + failure, + limits: { maxHeapGrowthBytes: 64 * 1024 * 1024, maxStorageGrowthBytes: 16 * 1024 * 1024, maxSnapshots: 5 }, + observed: { + heapGrowthBytes: baseline && final ? final.jsHeapBytes - baseline.jsHeapBytes : null, + storageGrowthBytes: baseline && final ? final.storageUsageBytes - baseline.storageUsageBytes : null, + }, + resourceSamples, + }; + const body = `${JSON.stringify(report, null, 2)}\n`; + await testInfo.attach("editing-soak-report", { body, contentType: "application/json" }); + if (reportPath) { + await fs.mkdir(path.dirname(reportPath), { recursive: true }); + await fs.writeFile(reportPath, body); + } + } +}); diff --git a/web/tests/e2e/external-vfont.spec.ts b/web/tests/e2e/external-vfont.spec.ts new file mode 100644 index 00000000..782bf2cf --- /dev/null +++ b/web/tests/e2e/external-vfont.spec.ts @@ -0,0 +1,218 @@ +import { expect, test } from "@playwright/test"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const fontPath = path.resolve(import.meta.dirname, "../../../blender-5.2.0/release/datafiles/bfont.pfb"); +const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"); + +test("M9-01 validates a real external font in Chromium before any storage or Main call", async ({ page }) => { + await page.goto("/"); + const bytes = fs.readFileSync(fontPath); + const sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); + const result = await page.evaluate(({ bytes, sha256 }) => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/external-vfont-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + const data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + worker.postMessage({ data, sha256 }, [data]); + }), { bytes: new Uint8Array(bytes), sha256 }); + expect(result.metadata).toMatchObject({ schemaVersion: 1, sourcePath: "//fonts/browser-bfont.pfb", format: "PFB", byteLength: 25181, sha256 }); + expect(result.copied).toBe(true); + expect(result.spoofCode).toBe("NON_MESH_BINARY_INVALID"); + expect(result.stage).toBe("VALIDATED_BEFORE_STORAGE_OR_MAIN"); +}); + +test("M9-02 commits the verified font to OPFS before creating a packed Main VFont", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const fontBytes = fs.readFileSync(fontPath); + const blendBytes = fs.readFileSync(nonMeshBlend); + const sha256 = crypto.createHash("sha256").update(fontBytes).digest("hex"); + const result = await page.evaluate(async ({ fontBytes, blendBytes, sha256 }) => { + const [{ StorageClient }, { WebEngineClient }, { importExternalVFontIntoMain }] = await Promise.all([ + import("/src/storage/StorageClient.ts"), + import("/src/engine-client/WebEngineClient.ts"), + import("/src/fonts/external-vfont-import.ts"), + ]); + const projectId = "m9-vfont-browser"; + const storage = new StorageClient(); + const engine = new WebEngineClient({ timeoutMs: 60_000 }); + const events: string[] = []; + await engine.init(); + const blend = blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength) as ArrayBuffer; + const opened = await engine.openBlend(blend); + const storagePort = { + putAsset: async (...args: Parameters) => { + events.push("storage:start"); + const stored = await storage.putAsset(...args); + events.push("storage:committed"); + return stored; + }, + }; + const enginePort = { + applyCommand: async (...args: Parameters) => { + events.push("main:start"); + const applied = await engine.applyCommand(...args); + events.push("main:committed"); + return applied; + }, + }; + const data = fontBytes.buffer.slice(fontBytes.byteOffset, fontBytes.byteOffset + fontBytes.byteLength) as ArrayBuffer; + const imported = await importExternalVFontIntoMain({ + projectId, + request: { sourcePath: "//fonts/m9-browser-bfont.pfb", mimeType: "application/x-font-type1", byteLength: data.byteLength, sha256, data }, + storage: storagePort, + engine: enginePort, + }); + const restored = await storage.readAsset(projectId, sha256); + let failedMainCalls = 0; + let failureCode = ""; + try { + await importExternalVFontIntoMain({ + projectId, + request: { sourcePath: "//fonts/m9-storage-failure.pfb", mimeType: "application/x-font-type1", byteLength: restored.data.byteLength, sha256, data: restored.data.slice(0) }, + storage: { putAsset: async () => { throw new Error("STORAGE_TRANSACTION: injected before asset commit"); } }, + engine: { applyCommand: async () => { failedMainCalls += 1; throw new Error("unexpected Main call"); } }, + }); + } + catch (error) { + failureCode = error instanceof Error ? error.message : String(error); + } + const listed = await storage.listAssets(projectId); + storage.terminate(); + engine.terminate(); + return { + beforeCount: opened.snapshot.vfonts?.length ?? 0, + afterCount: imported.snapshot.vfonts?.length ?? 0, + events, + asset: imported.asset, + vfont: imported.vfont, + restoredHash: restored.asset.sha256, + restoredBytes: restored.data.byteLength, + listedCount: listed.assets.length, + failedMainCalls, + failureCode, + }; + }, { fontBytes: new Uint8Array(fontBytes), blendBytes: new Uint8Array(blendBytes), sha256 }); + expect(result.events).toEqual(["storage:start", "storage:committed", "main:start", "main:committed"]); + expect(result.afterCount).toBe(result.beforeCount + 1); + expect(result.asset).toMatchObject({ projectId: "m9-vfont-browser", sha256, bytes: fontBytes.byteLength, persisted: true }); + expect(result.asset.path).toBe(`projects/m9-vfont-browser/assets/sha256/${sha256.slice(0, 2)}/${sha256}`); + expect(result.vfont).toMatchObject({ name: "m9-browser-bfont", sourcePath: "//fonts/m9-browser-bfont.pfb", builtin: false, packed: true }); + expect(result.restoredHash).toBe(sha256); + expect(result.restoredBytes).toBe(fontBytes.byteLength); + expect(result.listedCount).toBe(1); + expect(result.failedMainCalls).toBe(0); + expect(result.failureCode).toContain("STORAGE_TRANSACTION"); +}); + +test("M9-03 replaces, undoes, saves and reopens a packed font with missing-asset closure", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const fontBytes = fs.readFileSync(fontPath); + const blendBytes = fs.readFileSync(nonMeshBlend); + const sha256 = crypto.createHash("sha256").update(fontBytes).digest("hex"); + const result = await page.evaluate(async ({ fontBytes, blendBytes, sha256 }) => { + const [{ StorageClient }, { WebEngineClient }, fontImport] = await Promise.all([ + import("/src/storage/StorageClient.ts"), + import("/src/engine-client/WebEngineClient.ts"), + import("/src/fonts/external-vfont-import.ts"), + ]); + const projectId = "m9-vfont-roundtrip"; + const storage = new StorageClient(); + const engine = new WebEngineClient({ timeoutMs: 60_000 }); + await engine.init(); + const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength)); + const fontData = opened.snapshot.nonMeshData?.find((candidate) => candidate.type === "FONT" && candidate.fontLinks); + if (!fontData?.fontLinks) throw new Error("font fixture has no style links"); + const originalLinks = { ...fontData.fontLinks }; + const imported = await fontImport.importExternalVFontIntoMain({ + projectId, + request: { + sourcePath: "//fonts/m9-roundtrip-bfont.pfb", + mimeType: "application/x-font-type1", + byteLength: fontBytes.byteLength, + sha256, + data: fontBytes.buffer.slice(fontBytes.byteOffset, fontBytes.byteOffset + fontBytes.byteLength), + }, + storage, + engine, + }); + const replaced = await fontImport.replaceExternalVFontStyleInMain({ + projectId, + sha256, + dataId: fontData.id, + vfontId: imported.vfont.id, + style: "regular", + snapshot: imported.snapshot, + storage, + engine, + }); + const undone = await engine.applyCommand({ type: "undo" }); + const redone = await engine.applyCommand({ type: "redo" }); + const undoLinks = undone.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id)?.fontLinks; + const redoLinks = redone.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id)?.fontLinks; + const savedBlend = await engine.saveBlend(); + await storage.saveProject(projectId, redone.snapshot.revision, savedBlend); + + const root = await navigator.storage.getDirectory(); + let assetDirectory = root; + for (const segment of ["projects", projectId, "assets", "sha256", sha256.slice(0, 2)]) { + assetDirectory = await assetDirectory.getDirectoryHandle(segment); + } + await assetDirectory.removeEntry(sha256); + let missingCode = ""; + let missingMainCalls = 0; + try { + await fontImport.replaceExternalVFontStyleInMain({ + projectId, + sha256, + dataId: fontData.id, + vfontId: imported.vfont.id, + style: "bold", + snapshot: redone.snapshot, + storage, + engine: { applyCommand: async () => { missingMainCalls += 1; throw new Error("unexpected Main call"); } }, + }); + } + catch (error) { + missingCode = (error as { code?: string }).code ?? ""; + } + engine.terminate(); + + const persisted = await storage.readProject(projectId); + storage.terminate(); + const reopenedEngine = new WebEngineClient({ timeoutMs: 60_000 }); + await reopenedEngine.init(); + const reopened = await reopenedEngine.openBlend(persisted.buffer); + reopenedEngine.terminate(); + const reopenedData = reopened.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id); + const reopenedVFont = reopened.snapshot.vfonts?.find((candidate) => candidate.id === imported.vfont.id); + return { + originalLinks, + replacementLinks: replaced.links, + undoLinks, + redoLinks, + importedVFont: imported.vfont, + reopenedLinks: reopenedData?.fontLinks, + reopenedVFont, + missingCode, + missingMainCalls, + }; + }, { fontBytes: new Uint8Array(fontBytes), blendBytes: new Uint8Array(blendBytes), sha256 }); + expect(result.replacementLinks.regular).toBe(result.importedVFont.id); + expect(result.undoLinks).toEqual(result.originalLinks); + expect(result.redoLinks).toEqual(result.replacementLinks); + expect(result.missingCode).toBe("NON_MESH_RESOURCE_MISSING"); + expect(result.missingMainCalls).toBe(0); + expect(result.reopenedLinks).toEqual(result.replacementLinks); + expect(result.reopenedVFont).toMatchObject({ + id: result.importedVFont.id, + sourcePath: "//fonts/m9-roundtrip-bfont.pfb", + builtin: false, + packed: true, + packedByteLength: fontBytes.byteLength, + sha256, + }); +}); diff --git a/web/tests/e2e/file-import-progress.spec.ts b/web/tests/e2e/file-import-progress.spec.ts index 78335e5e..a9ae2d07 100644 --- a/web/tests/e2e/file-import-progress.spec.ts +++ b/web/tests/e2e/file-import-progress.spec.ts @@ -45,7 +45,8 @@ test("M7-03 cancels a large streamed open before WebEngine and preserves the cur const actualTotal = Number(element?.getAttribute("data-total-bytes")); const cancel = document.querySelector('button[aria-label="取消打开"]'); if (bytesRead <= 0 || bytesRead >= expectedTotal || !cancel) return false; - cancel.click(); + cancel.focus(); + cancel.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); return { bytesRead, totalBytes: actualTotal }; }, totalBytes, { polling: "raf", timeout: 10_000 }); const observed = await observation.jsonValue() as { bytesRead: number; totalBytes: number }; diff --git a/web/tests/e2e/geometry-node-allowlist.spec.ts b/web/tests/e2e/geometry-node-allowlist.spec.ts new file mode 100644 index 00000000..270312f6 --- /dev/null +++ b/web/tests/e2e/geometry-node-allowlist.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const expected = JSON.parse(fs.readFileSync( + path.join(root, "tests/golden/M10-02/geometry-node-allowlist.json"), + "utf8", +)); +const blendBytes = fs.readFileSync(path.join(root, expected.fixture)); + +test("M10-02 blocks non-allowlisted Main nodes without replacing the preserved graph", async ({ page }) => { + test.setTimeout(120_000); + expect((await import("node:crypto")).createHash("sha256").update(blendBytes).digest("hex")) + .toBe(expected.fixtureSha256); + await page.goto("/"); + const result = await page.evaluate(async ({ bytes, expected }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const client = new WebEngineClient({ timeoutMs: 60_000 }); + await client.init(); + const opened = await client.openBlend(source); + const supported = opened.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.supportedGraph); + const unsupported = opened.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.unsupportedGraph); + const meshId = opened.snapshot.meshes[0]?.id ?? "mesh:missing"; + if (!supported || !unsupported) throw new Error("Geometry Node allowlist fixture graphs are missing"); + const baseline = JSON.stringify(opened.snapshot.geometryNodeGraphs); + const revision = opened.snapshot.revision; + const unsupportedTypes = unsupported.nodes + .filter((node) => !expected.allowlist.includes(node.type)) + .map((node) => node.type); + const attempt = async (graph: typeof supported) => { + try { + await client.applyCommand({ type: "setGeometryNodeGraph", meshId, graph }); + return null; + } + catch (error) { + return error as { code?: string; message?: string }; + } + }; + const unsupportedError = await attempt(unsupported); + const afterUnsupported = await client.snapshot(); + const supportedError = await attempt(supported); + const afterSupported = await client.snapshot(); + client.terminate(); + return { + unsupportedTypes, + unsupportedErrorCode: unsupportedError?.code, + supportedErrorCode: supportedError?.code, + unsupportedPreserved: JSON.stringify(afterUnsupported.snapshot.geometryNodeGraphs) === baseline, + supportedPreserved: JSON.stringify(afterSupported.snapshot.geometryNodeGraphs) === baseline, + revisions: [revision, afterUnsupported.snapshot.revision, afterSupported.snapshot.revision], + graphHashes: [unsupported.graphHash, afterUnsupported.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.unsupportedGraph)?.graphHash], + }; + }, { bytes: new Uint8Array(blendBytes), expected }); + + expect(result).toEqual({ + unsupportedTypes: expected.unsupportedNodeTypes, + unsupportedErrorCode: expected.unsupportedErrorCode, + supportedErrorCode: expected.evaluatorUnavailableErrorCode, + unsupportedPreserved: true, + supportedPreserved: true, + revisions: [result.revisions[0], result.revisions[0], result.revisions[0]], + graphHashes: [result.graphHashes[0], result.graphHashes[0]], + }); + expect(result.graphHashes[0]).toMatch(/^[0-9a-f]{64}$/); +}); diff --git a/web/tests/e2e/geometry-node-evaluator-golden.spec.ts b/web/tests/e2e/geometry-node-evaluator-golden.spec.ts new file mode 100644 index 00000000..21b007c6 --- /dev/null +++ b/web/tests/e2e/geometry-node-evaluator-golden.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from "@playwright/test"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +interface GoldenMesh { + attributes: Record; + bounds: { min: number[]; max: number[] }; + indices: number[]; + positions: number[]; + triangleCount: number; + vertexCount: number; +} + +interface GoldenCase { + graph: string; + mesh: GoldenMesh; + name: string; + nodeTypes: string[]; +} + +interface Golden { + allowlist: string[]; + cases: GoldenCase[]; + fixture: string; + fixtureSha256: string; + nodeCoverage: Record; + schemaVersion: number; + tolerance: { + boundsError: number; + maxAttributeError: number; + maxPositionError: number; + rmsPositionError: number; + }; +} + +interface EvaluatedMesh { + attributes?: GoldenMesh["attributes"]; + indices: number[]; + modifiers: Array<{ status: string }>; + objectId: string; + positions: number[]; + triangleCount: number; + vertexCount: number; +} + +interface EvaluationResult { + graphs: Array<{ name: string; nodes: Array<{ type: string }> }>; + report: { engine: string; status: string; meshes: EvaluatedMesh[] }; +} + +const root = path.resolve(import.meta.dirname, "../../.."); +const golden = JSON.parse(fs.readFileSync( + path.join(root, "tests/golden/M10-03/geometry-node-evaluator.json"), + "utf8", +)) as Golden; +const blendBytes = fs.readFileSync(path.join(root, golden.fixture)); + +function errorMetrics(expected: number[], actual: number[]) { + expect(actual).toHaveLength(expected.length); + const errors = actual.map((value, index) => value - expected[index]); + return { + maximum: Math.max(0, ...errors.map((value) => Math.abs(value))), + rms: errors.length === 0 ? 0 : Math.sqrt( + errors.reduce((sum, value) => sum + value * value, 0) / errors.length, + ), + }; +} + +function bounds(positions: number[]) { + if (positions.length === 0) return { min: [0, 0, 0], max: [0, 0, 0] }; + 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]); + } + } + return result; +} + +function verifyEvaluation(result: EvaluationResult) { + expect(result.report.engine).toBe("BlenderDepsgraph"); + expect(result.report.status).toBe("EVALUATED"); + const graphs = new Map(result.graphs.map((graph) => [graph.name, graph])); + const meshes = new Map(result.report.meshes.map((mesh) => [mesh.objectId, mesh])); + + for (const expectedCase of golden.cases) { + const graph = graphs.get(expectedCase.graph); + expect(graph, `${expectedCase.name} graph`).toBeDefined(); + expect(graph?.nodes.map((node) => node.type).sort(), `${expectedCase.name} node inventory`) + .toEqual([...expectedCase.nodeTypes].sort()); + + const actual = meshes.get(`object:${expectedCase.name}`); + expect(actual, `${expectedCase.name} evaluated mesh`).toBeDefined(); + if (!actual) continue; + expect(actual.vertexCount, `${expectedCase.name} vertex count`).toBe(expectedCase.mesh.vertexCount); + expect(actual.triangleCount, `${expectedCase.name} triangle count`).toBe(expectedCase.mesh.triangleCount); + expect(actual.indices, `${expectedCase.name} topology`).toEqual(expectedCase.mesh.indices); + expect(actual.modifiers).toHaveLength(1); + expect(actual.modifiers[0].status, `${expectedCase.name} modifier status`).toBe("EVALUATED"); + + const positionError = errorMetrics(expectedCase.mesh.positions, actual.positions); + expect(positionError.maximum, `${expectedCase.name} maximum position error`) + .toBeLessThanOrEqual(golden.tolerance.maxPositionError); + expect(positionError.rms, `${expectedCase.name} RMS position error`) + .toBeLessThanOrEqual(golden.tolerance.rmsPositionError); + + const actualBounds = bounds(actual.positions); + expect(errorMetrics(expectedCase.mesh.bounds.min, actualBounds.min).maximum, + `${expectedCase.name} minimum bounds error`).toBeLessThanOrEqual(golden.tolerance.boundsError); + expect(errorMetrics(expectedCase.mesh.bounds.max, actualBounds.max).maximum, + `${expectedCase.name} maximum bounds error`).toBeLessThanOrEqual(golden.tolerance.boundsError); + + expect(Object.keys(actual.attributes ?? {}).sort(), `${expectedCase.name} attribute inventory`) + .toEqual(Object.keys(expectedCase.mesh.attributes).sort()); + for (const [name, expectedAttribute] of Object.entries(expectedCase.mesh.attributes)) { + const actualAttribute = actual.attributes?.[name]; + expect(actualAttribute, `${expectedCase.name}/${name}`).toBeDefined(); + expect(actualAttribute?.domain).toBe(expectedAttribute.domain); + expect(actualAttribute?.dataType).toBe(expectedAttribute.dataType); + expect(errorMetrics(expectedAttribute.values, actualAttribute?.values ?? []).maximum, + `${expectedCase.name}/${name} value error`) + .toBeLessThanOrEqual(golden.tolerance.maxAttributeError); + } + } +} + +test("M10-03 matches every allowlisted Geometry Node against Blender 5.2 desktop goldens", async ({ page }) => { + test.setTimeout(180_000); + expect(golden.schemaVersion).toBe(1); + expect(crypto.createHash("sha256").update(blendBytes).digest("hex")).toBe(golden.fixtureSha256); + expect(Object.keys(golden.nodeCoverage).sort()).toEqual([...golden.allowlist].sort()); + expect(golden.allowlist).toHaveLength(16); + + await page.goto("/"); + const evaluations = await page.evaluate(async ({ bytes }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const first = new WebEngineClient({ timeoutMs: 90_000 }); + await first.init(); + const opened = await first.openBlend(source); + const firstReport = await first.evaluateDepsgraph(); + const saved = await first.saveBlend(); + const initial = { graphs: opened.snapshot.geometryNodeGraphs ?? [], report: firstReport.depsgraph }; + first.terminate(); + + const second = new WebEngineClient({ timeoutMs: 90_000 }); + await second.init(); + const reopened = await second.openBlend(saved); + const secondReport = await second.evaluateDepsgraph(); + const restored = { graphs: reopened.snapshot.geometryNodeGraphs ?? [], report: secondReport.depsgraph }; + second.terminate(); + return [initial, restored]; + }, { bytes: new Uint8Array(blendBytes) }) as EvaluationResult[]; + + expect(evaluations).toHaveLength(2); + verifyEvaluation(evaluations[0]); + verifyEvaluation(evaluations[1]); +}); diff --git a/web/tests/e2e/geometry-node-field-budget.spec.ts b/web/tests/e2e/geometry-node-field-budget.spec.ts new file mode 100644 index 00000000..e787368a --- /dev/null +++ b/web/tests/e2e/geometry-node-field-budget.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { + GEOMETRY_NODE_FIELD_BUDGET, + parseGeometryNodeFieldMaterializationBatch, + type GeometryNodeDomainCardinalityIR, +} from "../../protocol/geometry-nodes"; +import { parseDepsgraphEvaluation, type DepsgraphEvaluationIR } from "../../protocol/depsgraph"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const fixture = fs.readFileSync(path.join(root, "tests/files/web/geometry_node_allowlist_evaluator.blend")); + +test("M10-04 binds field conversion budgets to native mesh domain cardinality", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const result = await page.evaluate(async ({ bytes }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const client = new WebEngineClient({ timeoutMs: 90_000 }); + await client.init(); + await client.openBlend(source); + const evaluated = await client.evaluateDepsgraph(); + client.terminate(); + const mesh = evaluated.depsgraph.meshes.find((candidate) => + candidate.objectId === "object:M10GN_StoreAttribute"); + if (!mesh?.domainCardinality) throw new Error("Store Named Attribute domain cardinality is missing"); + return { + depsgraph: evaluated.depsgraph, + domainCardinality: mesh.domainCardinality, + fieldMaterializations: mesh.fieldMaterializations ?? [], + attributes: mesh.attributes ?? {}, + }; + }, { bytes: new Uint8Array(fixture) }); + + expect(result.domainCardinality).toEqual({ + POINT: 8, + EDGE: 12, + FACE: 6, + CORNER: 24, + CURVE: 0, + INSTANCE: 0, + LAYER: 0, + }); + expect(result.fieldMaterializations).toEqual([{ + schemaVersion: 1, + fieldId: "attribute:m10_value", + domain: "POINT", + dataType: "FLOAT", + elementCount: 8, + scalarValueCount: 8, + materializedByteLength: 32, + transport: "JSON", + }]); + expect(result.attributes.m10_value.values).toHaveLength(8); + expect(result.attributes.m10_value.values.every((value: number) => value === 0.375)).toBe(true); + + const rejects = (mutate: (candidate: DepsgraphEvaluationIR) => void): void => { + const candidate = structuredClone(result.depsgraph); + mutate(candidate); + expect(() => parseDepsgraphEvaluation(candidate)).toThrow(); + }; + rejects((candidate) => { + const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!; + target.domainCardinality!.POINT += 1; + }); + rejects((candidate) => { + const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!; + target.fieldMaterializations![0].materializedByteLength += 4; + }); + rejects((candidate) => { + const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!; + (target.fieldMaterializations![0] as unknown as Record).values = [0]; + }); + + const common = { + schemaVersion: 1 as const, + graphId: "node-group:M10GN_StoreAttributeGraph", + graphHash: "a".repeat(64), + revision: 1, + transport: "JSON" as const, + domainCardinality: result.domainCardinality as GeometryNodeDomainCardinalityIR, + }; + const batch = parseGeometryNodeFieldMaterializationBatch([ + { + ...common, + fieldId: "field:point-to-corner", + sourceDomain: "POINT", + targetDomain: "CORNER", + dataType: "FLOAT", + }, + { + ...common, + fieldId: "field:constant-offset", + sourceDomain: "CONSTANT", + targetDomain: "POINT", + dataType: "VECTOR", + }, + ]); + expect(batch).toMatchObject({ + fieldCount: 2, + domainConversionCount: 1, + materializedElementCount: 32, + materializedByteLength: 192, + }); + expect(batch.fields.every((field) => field.scalarValueCount <= + GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField)).toBe(true); +}); diff --git a/web/tests/e2e/geometry-node-main-reader.spec.ts b/web/tests/e2e/geometry-node-main-reader.spec.ts new file mode 100644 index 00000000..9238f10c --- /dev/null +++ b/web/tests/e2e/geometry-node-main-reader.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const geometryNodesBlend = path.resolve( + import.meta.dirname, + "../../../tests/files/web/modifier_geometry_nodes_scene.blend", +); + +test("M10-01 production Worker preserves Main Geometry Node topology across save and reopen", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const blendBytes = fs.readFileSync(geometryNodesBlend); + const result = await page.evaluate(async ({ blendBytes }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const source = blendBytes.buffer.slice( + blendBytes.byteOffset, + blendBytes.byteOffset + blendBytes.byteLength, + ) as ArrayBuffer; + const first = new WebEngineClient({ timeoutMs: 60_000 }); + await first.init(); + const opened = await first.openBlend(source); + const initial = opened.snapshot.geometryNodeGraphs ?? []; + const saved = await first.saveBlend(); + first.terminate(); + + const second = new WebEngineClient({ timeoutMs: 60_000 }); + await second.init(); + const reopened = await second.openBlend(saved); + const restored = reopened.snapshot.geometryNodeGraphs ?? []; + second.terminate(); + return { + graphNames: initial.map((graph) => graph.name), + nodeCount: initial.reduce((count, graph) => count + graph.nodes.length, 0), + linkCount: initial.reduce((count, graph) => count + graph.links.length, 0), + defaultCount: initial.reduce((count, graph) => count + graph.nodes.reduce( + (nodeCount, node) => nodeCount + node.sockets.filter((socket) => socket.defaultValue !== undefined).length, + 0, + ), 0), + unsupportedPreserved: initial.some((graph) => graph.nodes.some((node) => node.type === "GeometryNodeSimulationOutput")), + stable: JSON.stringify(initial) === JSON.stringify(restored), + hashes: initial.map((graph) => graph.graphHash), + }; + }, { blendBytes: new Uint8Array(blendBytes) }); + + expect(result).toEqual({ + graphNames: ["WebGeometryNodes", "WebGeometryNodesSetPosition", "WebGeometryNodesSimulation"], + nodeCount: 10, + linkCount: 7, + defaultCount: 9, + unsupportedPreserved: true, + stable: true, + hashes: result.hashes, + }); + expect(result.hashes).toHaveLength(3); + expect(result.hashes.every((hash) => /^[0-9a-f]{64}$/.test(hash ?? ""))).toBe(true); +}); diff --git a/web/tests/e2e/grease-pencil-marquee.spec.ts b/web/tests/e2e/grease-pencil-marquee.spec.ts new file mode 100644 index 00000000..16185ec7 --- /dev/null +++ b/web/tests/e2e/grease-pencil-marquee.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend"); +const expectedPointIds = Array.from({ length: 4 }, (_, index) => `grease-pencil-point:GreasePencilData:0:0:${index}`); +const expectedStrokeId = "grease-pencil-stroke:GreasePencilData:0:0"; + +for (const offscreen of [false, true]) { + test(`M9-06 marquee selects only the current drawing stable IDs in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => { + test.setTimeout(90_000); + await page.goto(offscreen ? "/?offscreen=1" : "/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await page.getByTestId("blend-file-input").setInputFiles(fixture); + await page.getByText("GreasePencilObject", { exact: true }).click(); + await page.getByRole("button", { name: "Object Mode" }).click(); + + const app = page.locator(".blender-app"); + const revision = Number(await app.getAttribute("data-current-main-revision")); + await page.getByRole("button", { name: "Grease Pencil 框选工具" }).click(); + const surface = page.getByTestId("grease-pencil-marquee-surface"); + await expect(surface).toHaveAttribute("data-drawing-id", "grease-pencil-drawing:GreasePencilData:0"); + const bounds = await surface.boundingBox(); + if (!bounds) throw new Error("Grease Pencil marquee surface has no bounds"); + await page.mouse.move(bounds.x + 3, bounds.y + 3); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width - 3, bounds.y + bounds.height - 3, { steps: 4 }); + await page.mouse.up(); + + const canvas = page.locator("canvas.viewport-canvas"); + await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-count", "4", { timeout: 20_000 }); + await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-drawing-id", "grease-pencil-drawing:GreasePencilData:0"); + await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-stroke-ids", expectedStrokeId); + await expect.poll(async () => (await app.getAttribute("data-selected-grease-pencil-point-ids"))?.split(",").filter(Boolean).sort()).toEqual(expectedPointIds); + expect(Number(await app.getAttribute("data-current-main-revision"))).toBe(revision); + await expect(canvas).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr"); + }); +} diff --git a/web/tests/e2e/grease-pencil-reorder.spec.ts b/web/tests/e2e/grease-pencil-reorder.spec.ts new file mode 100644 index 00000000..7843a941 --- /dev/null +++ b/web/tests/e2e/grease-pencil-reorder.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend"); +const golden = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M9-08/grease-pencil-reorder.json"), "utf8")); + +test("M9-08 reorders Grease Pencil layers and frames through Main undo, save and reopen", async ({ page }) => { + test.setTimeout(120_000); + expect(crypto.createHash("sha256").update(fs.readFileSync(fixture)).digest("hex")).toBe(golden.fixtureSha256); + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await page.getByTestId("blend-file-input").setInputFiles(fixture); + await page.getByText("GreasePencilObject", { exact: true }).click(); + + const app = page.locator(".blender-app"); + const editor = page.getByTestId("grease-pencil-editor"); + const revision = async () => Number(await app.getAttribute("data-current-main-revision")); + await expect(editor).toHaveAttribute("data-layer-order", golden.source.layerOrder.join(",")); + await editor.getByLabel("Grease Pencil new layer name").fill("Web Drafts"); + await editor.getByRole("button", { name: "Add Layer" }).click(); + await expect(editor).toHaveAttribute("data-layer-order", golden.beforeReorder.layerOrder.join(",")); + await editor.getByLabel("Grease Pencil layer", { exact: true }).selectOption({ label: "Web Drafts" }); + await editor.getByRole("button", { name: "Add Frame" }).click(); + await expect(editor).toHaveAttribute("data-selected-layer-frames", "1"); + const drawingId = await editor.getAttribute("data-selected-layer-drawing-ids"); + expect(drawingId).toMatch(/^grease-pencil-drawing:GreasePencilData:\d+$/); + + const beforeLayerMove = await revision(); + await editor.getByRole("button", { name: "Move layer down" }).click(); + await expect(editor).toHaveAttribute("data-layer-order", golden.afterReorder.layerOrder.join(",")); + expect(await revision()).toBe(beforeLayerMove + 1); + + await page.getByRole("button", { name: "撤销" }).click(); + await expect(editor).toHaveAttribute("data-layer-order", golden.beforeReorder.layerOrder.join(",")); + await page.getByRole("button", { name: "重做" }).click(); + await expect(editor).toHaveAttribute("data-layer-order", golden.afterReorder.layerOrder.join(",")); + + const beforeFrameMove = await revision(); + await editor.getByLabel("Grease Pencil target frame").fill("12"); + await editor.getByRole("button", { name: "Move Grease Pencil frame" }).click(); + await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.afterReorder.framesByLayer["Web Drafts"].join(",")); + await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!); + expect(await revision()).toBe(beforeFrameMove + 1); + + await page.getByRole("button", { name: "撤销" }).click(); + await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.beforeReorder.framesByLayer["Web Drafts"].join(",")); + await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!); + await page.getByRole("button", { name: "重做" }).click(); + await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.afterReorder.framesByLayer["Web Drafts"].join(",")); + + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await download; + await expect(app).toHaveAttribute("data-dirty", "false"); + await page.getByRole("button", { name: "关闭项目" }).click(); + await expect(editor).toHaveCount(0); + await page.getByRole("button", { name: "恢复项目" }).click(); + await page.getByText("GreasePencilObject", { exact: true }).click(); + await expect(editor).toHaveAttribute("data-layer-order", golden.reopened.layerOrder.join(",")); + await editor.getByLabel("Grease Pencil layer", { exact: true }).selectOption({ label: "Web Drafts" }); + await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.reopened.framesByLayer["Web Drafts"].join(",")); + await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!); +}); diff --git a/web/tests/e2e/grease-pencil-selection.spec.ts b/web/tests/e2e/grease-pencil-selection.spec.ts new file mode 100644 index 00000000..8f7fefc3 --- /dev/null +++ b/web/tests/e2e/grease-pencil-selection.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend"); +const expectedPointIds = Array.from({ length: 4 }, (_, index) => `grease-pencil-point:GreasePencilData:0:0:${index}`); + +for (const offscreen of [false, true]) { + test(`M9-07 shares one Grease Pencil selection revision between 2D canvas and ${offscreen ? "OffscreenCanvas" : "main-thread"} 3D viewport`, async ({ page }) => { + test.setTimeout(90_000); + await page.goto(offscreen ? "/?offscreen=1" : "/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await page.getByTestId("blend-file-input").setInputFiles(fixture); + await page.getByText("GreasePencilObject", { exact: true }).click(); + await page.getByRole("button", { name: "Object Mode" }).click(); + + const app = page.locator(".blender-app"); + const viewport = page.locator("canvas.viewport-canvas"); + const canvas = page.getByTestId("grease-pencil-canvas-2d"); + await expect(canvas).toHaveAttribute("data-selection-revision", "0"); + const mainRevision = Number(await app.getAttribute("data-current-main-revision")); + const first = await canvas.evaluate((element) => JSON.parse(element.dataset.pointLayout ?? "[]")[0] as { pointId: string; x: number; y: number }); + await canvas.scrollIntoViewIfNeeded(); + const bounds = await canvas.boundingBox(); + if (!bounds || !first) throw new Error("Grease Pencil 2D canvas point layout is unavailable"); + await page.mouse.click(bounds.x + (first.x / 280) * bounds.width, bounds.y + (first.y / 150) * bounds.height); + + await expect(app).toHaveAttribute("data-grease-pencil-selection-revision", "1"); + await expect(app).toHaveAttribute("data-grease-pencil-selection-source", "CANVAS_2D"); + await expect(canvas).toHaveAttribute("data-selected-point-ids", first.pointId); + await expect(viewport).toHaveAttribute("data-grease-pencil-selection-revision", "1", { timeout: 20_000 }); + await expect(viewport).toHaveAttribute("data-grease-pencil-selection-point-ids", first.pointId); + + await page.getByRole("button", { name: "Grease Pencil 框选工具" }).click(); + const marquee = page.getByTestId("grease-pencil-marquee-surface"); + const marqueeBounds = await marquee.boundingBox(); + if (!marqueeBounds) throw new Error("Grease Pencil 3D marquee surface is unavailable"); + await page.mouse.move(marqueeBounds.x + 3, marqueeBounds.y + 3); + await page.mouse.down(); + await page.mouse.move(marqueeBounds.x + marqueeBounds.width - 3, marqueeBounds.y + marqueeBounds.height - 3, { steps: 4 }); + await page.mouse.up(); + + await expect(app).toHaveAttribute("data-grease-pencil-selection-revision", "2", { timeout: 20_000 }); + await expect(app).toHaveAttribute("data-grease-pencil-selection-source", "VIEWPORT_3D"); + await expect.poll(async () => (await canvas.getAttribute("data-selected-point-ids"))?.split(",").filter(Boolean).sort()).toEqual(expectedPointIds); + await expect(canvas).toHaveAttribute("data-selection-revision", "2"); + await expect(viewport).toHaveAttribute("data-grease-pencil-selection-revision", "2", { timeout: 20_000 }); + expect(Number(await app.getAttribute("data-current-main-revision"))).toBe(mainRevision); + await expect(viewport).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr"); + }); +} diff --git a/web/tests/e2e/keyboard-accessibility.spec.ts b/web/tests/e2e/keyboard-accessibility.spec.ts new file mode 100644 index 00000000..71cbe264 --- /dev/null +++ b/web/tests/e2e/keyboard-accessibility.spec.ts @@ -0,0 +1,100 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Locator, type Page } from "@playwright/test"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); +const app = (page: Page) => page.locator("main.blender-app"); + +async function focusWithKeyboard(page: Page, target: Locator, backwards = false): Promise { + for (let index = 0; index < 80; index += 1) { + if (await target.evaluate((element) => element === document.activeElement)) return; + await page.keyboard.press(backwards ? "Shift+Tab" : "Tab"); + } + throw new Error(`Keyboard focus did not reach ${await target.getAttribute("aria-label") ?? await target.textContent() ?? "target"}`); +} + +async function expectVisibleKeyboardFocus(target: Locator): Promise { + await expect(target).toBeFocused(); + await expect.poll(() => target.evaluate((element) => { + const style = getComputedStyle(element); + return style.outlineStyle !== "none" && Number.parseFloat(style.outlineWidth) >= 2; + })).toBe(true); +} + +async function expectNoSeriousAccessibilityViolations(page: Page, checkpoint: string): Promise { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + const violations = results.violations + .filter((violation) => violation.impact === "critical" || violation.impact === "serious") + .map((violation) => ({ + id: violation.id, + impact: violation.impact, + help: violation.help, + targets: violation.nodes.map((node) => node.target.join(" ")), + })); + expect(violations, `${checkpoint}: ${JSON.stringify(violations, null, 2)}`).toEqual([]); +} + +test("M7-16 completes the P0 project loop without pointer input and passes the accessibility gate", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await expectNoSeriousAccessibilityViolations(page, "empty project"); + + const fileMenu = page.getByRole("button", { name: "文件", exact: true }); + await focusWithKeyboard(page, fileMenu); + await expectVisibleKeyboardFocus(fileMenu); + await page.keyboard.press("Enter"); + await expect(page.getByRole("menu", { name: "文件" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused(); + + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.keyboard.press("Enter"); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(basicBlend); + await expect(page.getByText("BasicCube", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(app(page)).toHaveAttribute("data-user-action-open-status", "SUCCEEDED"); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 3"); + + await page.keyboard.press("F3"); + const search = page.getByRole("textbox", { name: "搜索操作" }); + await expectVisibleKeyboardFocus(search); + await page.keyboard.insertText("Add Cube"); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 4"); + await expect(app(page)).toHaveAttribute("data-dirty", "true"); + + const blendDownload = page.waitForEvent("download"); + await page.keyboard.press("Control+s"); + await expect((await blendDownload).suggestedFilename()).toBe("blender-web.blend"); + await expect(app(page)).toHaveAttribute("data-user-action-save-status", "SUCCEEDED"); + await expect(app(page)).toHaveAttribute("data-dirty", "false"); + + await page.keyboard.press("F3"); + await expect(search).toBeFocused(); + await page.keyboard.insertText("Close Project"); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 0"); + + await page.reload(); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const recentProjects = page.getByRole("combobox", { name: "最近项目" }); + await focusWithKeyboard(page, recentProjects); + await expectVisibleKeyboardFocus(recentProjects); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 4", { timeout: 30_000 }); + await expect(app(page)).toHaveAttribute("data-dirty", "false"); + await expectNoSeriousAccessibilityViolations(page, "reopened project"); + + const renderMenu = page.getByRole("button", { name: "渲染", exact: true }); + await focusWithKeyboard(page, renderMenu, true); + await expectVisibleKeyboardFocus(renderMenu); + await page.keyboard.press("Enter"); + await expect(page.getByRole("menuitem", { name: "导出 GLB" })).toBeFocused(); + const glbDownload = page.waitForEvent("download"); + await page.keyboard.press("Enter"); + await expect((await glbDownload).suggestedFilename()).toBe("blender-web.glb"); + await expect(app(page)).toHaveAttribute("data-user-action-export-status", "SUCCEEDED"); +}); diff --git a/web/tests/e2e/lighting-field-roundtrip.spec.ts b/web/tests/e2e/lighting-field-roundtrip.spec.ts new file mode 100644 index 00000000..3d67f66f --- /dev/null +++ b/web/tests/e2e/lighting-field-roundtrip.spec.ts @@ -0,0 +1,143 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const blend = fs.readFileSync(path.join(root, "tests/files/web/basic_scene.blend")); +const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-02/lighting-field-roundtrip.json"), "utf8")); + +function expectClose(actual: unknown, reference: unknown): void { + if (typeof reference === "number") { + expect(actual).toBeCloseTo(reference, 5); + return; + } + if (Array.isArray(reference)) { + expect(Array.isArray(actual)).toBe(true); + expect(actual).toHaveLength(reference.length); + reference.forEach((value, index) => expectClose((actual as unknown[])[index], value)); + return; + } + if (reference && typeof reference === "object") { + expect(actual && typeof actual === "object").toBe(true); + for (const [field, value] of Object.entries(reference)) { + expectClose((actual as Record)[field], value); + } + return; + } + expect(actual).toEqual(reference); +} + +test("M11-02 edits, undoes, redoes, reopens and maps supported lighting fields", async ({ page }) => { + test.setTimeout(60_000); + await page.goto("/"); + const result = await page.evaluate(async (input) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const { configurePBRCamera, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts"); + const { Object3D, PerspectiveCamera } = await import("/src/vendor/three/three.module.js"); + const client = new WebEngineClient({ timeoutMs: 30_000 }); + const reopened = new WebEngineClient({ timeoutMs: 30_000 }); + try { + const opened = await client.openBlend(Uint8Array.from(input).buffer); + const camera = opened.snapshot.cameras.find((item) => item.id === "camera:Camera.001")!; + const light = opened.snapshot.lights.find((item) => item.id === "light:Area")!; + const world = opened.snapshot.worlds.find((item) => item.id === "world:World")!; + const revisions = [opened.snapshot.revision]; + + const cameraEdit = await client.applyCommand({ type: "setCameraProperties", dataId: camera.id, properties: { + lensMm: 35, sensorWidthMm: 32, sensorHeightMm: 18, sensorFit: 2, shift: [0.1, -0.2], + near: 0.2, far: 500, orthoScale: 8, + depthOfField: { enabled: true, focusDistance: 4.5, apertureFStop: 1.8, apertureBlades: 7, apertureRotation: 0.25, apertureRatio: 1.2 }, + } }); + revisions.push(cameraEdit.snapshot.revision); + const cameraUndo = await client.applyCommand({ type: "undo" }); + revisions.push(cameraUndo.snapshot.revision); + const cameraRedo = await client.applyCommand({ type: "redo" }); + revisions.push(cameraRedo.snapshot.revision); + + const lightEdit = await client.applyCommand({ type: "setLightProperties", dataId: light.id, properties: { + 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, + } }); + revisions.push(lightEdit.snapshot.revision); + const lightUndo = await client.applyCommand({ type: "undo" }); + revisions.push(lightUndo.snapshot.revision); + const lightRedo = await client.applyCommand({ type: "redo" }); + revisions.push(lightRedo.snapshot.revision); + + const worldEdit = await client.applyCommand({ type: "setWorldProperties", dataId: world.id, properties: { + color: [0.1, 0.2, 0.3], exposure: 0.5, + mist: { enabled: true, type: "LINEAR", start: 2, depth: 50, intensity: 0.2, height: 3 }, + } }); + revisions.push(worldEdit.snapshot.revision); + const worldUndo = await client.applyCommand({ type: "undo" }); + revisions.push(worldUndo.snapshot.revision); + const worldRedo = await client.applyCommand({ type: "redo" }); + revisions.push(worldRedo.snapshot.revision); + + const saved = await client.saveBlend(); + const reopenedResult = await reopened.openBlend(saved); + const reopenedCamera = reopenedResult.snapshot.cameras.find((item) => item.id === camera.id)!; + const reopenedLight = reopenedResult.snapshot.lights.find((item) => item.id === light.id)!; + const reopenedWorld = reopenedResult.snapshot.worlds.find((item) => item.id === world.id)!; + const mappedCamera = new PerspectiveCamera(); + configurePBRCamera(mappedCamera, reopenedCamera); + const horizontalCamera = new PerspectiveCamera(); + configurePBRCamera(horizontalCamera, { ...reopenedCamera, sensorFit: 1 }); + const spot = createPBRLight({ ...reopenedLight, lightType: 2 }); + const area = createPBRLight({ ...reopenedLight, lightType: 4 }); + const lightNode = reopenedResult.snapshot.nodes.find((item) => item.dataId === light.id)!; + configurePBRLight(spot, lightNode, new Object3D()); + return { + revisions, + cameraUndoLens: cameraUndo.snapshot.cameras.find((item) => item.id === camera.id)!.lensMm, + cameraRedoLens: cameraRedo.snapshot.cameras.find((item) => item.id === camera.id)!.lensMm, + lightUndoEnergy: lightUndo.snapshot.lights.find((item) => item.id === light.id)!.energy, + lightRedoEnergy: lightRedo.snapshot.lights.find((item) => item.id === light.id)!.energy, + worldUndoColor: worldUndo.snapshot.worlds.find((item) => item.id === world.id)!.color, + worldRedoColor: worldRedo.snapshot.worlds.find((item) => item.id === world.id)!.color, + camera: reopenedCamera, + light: reopenedLight, + world: reopenedWorld, + viewport: { + camera: { fov: mappedCamera.fov, horizontalFov: horizontalCamera.fov, near: mappedCamera.near, far: mappedCamera.far, filmGauge: mappedCamera.filmGauge, filmOffset: mappedCamera.filmOffset }, + spot: { color: spot.color.toArray(), intensity: spot.intensity, angle: "angle" in spot ? spot.angle : 0, penumbra: "penumbra" in spot ? spot.penumbra : 0, castShadow: spot.castShadow }, + area: { intensity: area.intensity, width: "width" in area ? area.width : 0, height: "height" in area ? area.height : 0 }, + worldColor: reopenedWorld.color, + }, + }; + } + finally { + client.terminate(); + reopened.terminate(); + } + }, Array.from(blend)); + + expect(result.revisions.every((revision, index) => index === 0 || revision === result.revisions[index - 1] + 1)).toBe(true); + expect(result.cameraUndoLens).toBe(50); + expect(result.cameraRedoLens).toBe(expected.camera.lensMm); + expect(result.lightUndoEnergy).toBe(800); + expect(result.lightRedoEnergy).toBe(expected.light.energy); + expect(result.worldUndoColor).not.toEqual(expected.world.color); + expectClose(result.worldRedoColor, expected.world.color); + for (const [field, value] of Object.entries(expected.camera).filter(([field]) => !field.startsWith("viewport"))) { + expectClose(result.camera[field as keyof typeof result.camera], value); + } + for (const [field, value] of Object.entries(expected.light).filter(([field]) => !field.startsWith("viewport"))) { + expectClose(result.light[field as keyof typeof result.light], value); + } + expectClose(result.world, expected.world); + expect(result.viewport.camera.fov).toBeCloseTo(expected.camera.viewportFov, 5); + expect(result.viewport.camera.horizontalFov).toBeCloseTo(expected.camera.viewportHorizontalFov, 5); + expectClose(result.viewport.camera.near, expected.camera.near); + expectClose(result.viewport.camera.far, expected.camera.far); + expectClose(result.viewport.camera.filmGauge, expected.camera.viewportFilmGauge); + expect(result.viewport.camera.filmOffset).toBeCloseTo(expected.camera.viewportFilmOffset, 6); + expectClose(result.viewport.spot.intensity, expected.light.viewportIntensity); + expectClose(result.viewport.spot.color, expected.light.viewportColor); + expectClose(result.viewport.spot.angle, expected.light.spotAngle); + expectClose(result.viewport.spot.penumbra, expected.light.spotBlend); + expect(result.viewport.spot.castShadow).toBe(false); + expectClose(result.viewport.area, { intensity: expected.light.viewportIntensity, width: expected.light.areaSize, height: expected.light.areaSizeY }); + expectClose(result.viewport.worldColor, expected.world.color); +}); diff --git a/web/tests/e2e/long-media-performance.spec.ts b/web/tests/e2e/long-media-performance.spec.ts index f2e6d432..31ce0e9b 100644 --- a/web/tests/e2e/long-media-performance.spec.ts +++ b/web/tests/e2e/long-media-performance.spec.ts @@ -183,7 +183,20 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time const restartedDisposed = await restarted.dispose(); restarted.terminate(); - const codec = gateSequencerCodec("video/mp4", new Set(["image/png", "audio/wav"])); + const codecRequest = { + schemaVersion: 1 as const, + stripType: "MOVIE" as const, + mimeType: "video/mp4", + byteLength: 1, + sourceSha256: "0".repeat(64), + }; + const codec = gateSequencerCodec(codecRequest, { + ...codecRequest, + status: "BLOCKED", + backend: null, + reason: "RUNTIME_UNAVAILABLE", + decoded: null, + }); const runtime = sequencerRuntimeCapabilities(); let corruptManifestCode = ""; try { parseLongMediaSessionManifest({ ...manifest, assets: [{ ...manifest.assets[0], sha256: "bad" }, manifest.assets[1]] }); } diff --git a/web/tests/e2e/m10-domain-browser-gates.spec.ts b/web/tests/e2e/m10-domain-browser-gates.spec.ts new file mode 100644 index 00000000..0d76ac52 --- /dev/null +++ b/web/tests/e2e/m10-domain-browser-gates.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-15/domain-browser-gates.json"), "utf8")); + +test("M10-15 gates GN, Shader, NLA and Simulation performance, OOM and malicious inputs", async ({ page }) => { + test.setTimeout(60_000); + await page.goto("/"); + const results = await page.evaluate(async () => { + const domains = ["GN", "SHADER", "NLA", "SIMULATION"] as const; + const run = (domain: typeof domains[number]) => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/m10-domain-gate-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent<{ ok: boolean; result?: Record; error?: string }>) => { + worker.terminate(); + if (event.data.ok && event.data.result) resolve(event.data.result); + else reject(new Error(event.data.error ?? `${domain} gate failed`)); + }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({ domain }); + }); + return Promise.all(domains.map(run)); + }) as Array<{ + domain: string; + performanceMs: number; + workUnits: number; + oomCode: string; + maliciousCode: string; + recovered: boolean; + performanceStatus: string; + oomPreventedBeforeAllocation: boolean; + }>; + + expect(results.map((result) => result.domain)).toEqual(["GN", "SHADER", "NLA", "SIMULATION"]); + for (const result of results) { + const gate = expected.domains[result.domain]; + expect(result.workUnits).toBe(gate.workUnits); + expect(result.performanceMs).toBeLessThan(gate.maximumMs); + expect(result.performanceStatus).toBe(gate.performanceStatus); + expect(result.oomCode).toBe(gate.oomCode); + expect(result.maliciousCode).toBe(gate.maliciousCode); + expect(result.oomPreventedBeforeAllocation).toBe(true); + expect(result.recovered).toBe(true); + } + console.log("m10-domain-browser-gates", JSON.stringify(results)); +}); diff --git a/web/tests/e2e/nanovdb-main-thread.spec.ts b/web/tests/e2e/nanovdb-main-thread.spec.ts new file mode 100644 index 00000000..73c972fd --- /dev/null +++ b/web/tests/e2e/nanovdb-main-thread.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; + +test("M8-11 main-thread WebGPU loads a missing page and renders deterministic pixels", async ({ page }) => { + test.setTimeout(90_000); + await page.goto("/"); + const result = await page.evaluate(async () => { + const [viewport, renderer, { NanoVDBFloat32Sampler }] = await Promise.all([ + import("/src/volume/nanovdb-viewport.ts"), + import("/src/render/nanovdb-volume-renderer.ts"), + import("/src/volume/nanovdb-float32.ts"), + ]); + const manifest = await viewport.loadNanoVDBViewportAsset("main-thread-fixture", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal); + const density = manifest.manifest.grids.find((grid) => grid.name === manifest.manifest.material.densityGrid); + if (!density || !manifest.manifest.gpu.float32TreeLayout) throw new Error("NANOVDB_MANIFEST_INVALID: main-thread fixture is incomplete"); + const response = await fetch("/__vdb_fixture__/bundle", { + cache: "no-store", + headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` }, + }); + if (response.status !== 206) throw new Error("NANOVDB_STREAM_INCOMPLETE: main-thread fixture range was not served"); + const payload = await response.arrayBuffer(); + const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.manifest.gpu.float32TreeLayout); + const coordinate = [0, 0, 0] as const; + const leafByteOffset = sampler.leafByteOffset(coordinate); + if (leafByteOffset === null) throw new Error("NANOVDB_GRID_UNSUPPORTED: main-thread fixture has no leaf"); + + const pageByteLength = 256 * 1024; + const pageCount = Math.ceil(payload.byteLength / pageByteLength); + const leafPageId = Math.floor(leafByteOffset / pageByteLength); + if (leafPageId <= 0 || leafPageId >= pageCount) throw new Error("NANOVDB_GRID_UNSUPPORTED: main-thread leaf page is not pageable"); + const session = new renderer.NanoVDBWebGPUDeviceSession(); + const device = await session.open(pageCount * pageByteLength, 6); + const grid = renderer.createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, pageCount * pageByteLength); + for (let pageId = 0; pageId < pageCount; pageId++) { + if (pageId === leafPageId) continue; + grid.uploadPage(pageId, payload.slice(pageId * pageByteLength, Math.min(payload.byteLength, (pageId + 1) * pageByteLength))); + } + + const feedbackBuffer = renderer.createNanoVDBPageFeedbackGPUBuffer(device, 4); + const beforeLoad = await renderer.sampleNanoVDBFloat32WebGPU(device, grid, [coordinate], feedbackBuffer); + const missing = await renderer.readNanoVDBPageFeedbackGPUBuffer(device, feedbackBuffer, 4, pageCount, 17); + const pageSource = async (pageId: number, signal: AbortSignal): Promise => { + if (signal.aborted) throw new DOMException("main-thread page request cancelled", "AbortError"); + const start = density.byteOffset + pageId * pageByteLength; + const end = Math.min(density.byteOffset + density.byteLength, start + pageByteLength) - 1; + const pageResponse = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${start}-${end}` }, signal }); + if (pageResponse.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: page range returned ${pageResponse.status}`); + return pageResponse.arrayBuffer(); + }; + const loadedPages: number[] = []; + const page = await pageSource(missing.pageIds[0], new AbortController().signal); + const frames: Array<() => void> = []; + let redraws = 0; + const scheduler = new renderer.NanoVDBProgressiveRedrawScheduler((callback) => frames.push(callback), () => { redraws++; }); + const uploader = new renderer.NanoVDBProgressivePageUploader(grid, scheduler); + loadedPages.push(missing.pageIds[0]); + const upload = uploader.upload(missing.pageIds[0], page.slice(0)); + const queuedBeforeRedraw = frames.length; + frames.shift()?.(); + + const afterLoad = await renderer.sampleNanoVDBFloat32WebGPU(device, grid, [coordinate]); + const material = { ...manifest.manifest.material, interpolation: "LINEAR" as const }; + const pixelsA = await renderer.renderNanoVDBFloat32WebGPU(device, grid, density, material, 64, 64); + const pixelsB = await renderer.renderNanoVDBFloat32WebGPU(device, grid, density, material, 64, 64); + const digest = async (pixels: Uint8Array): Promise => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", pixels))).map((byte) => byte.toString(16).padStart(2, "0")).join(""); + const visible = (pixels: Uint8Array): number => { let count = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 24) count++; return count; }; + const output = { + pageCount, + leafPageId, + beforeLoad, + missing, + loadedPages, + upload, + queuedBeforeRedraw, + redraws, + afterLoad, + pixels: { width: 64, height: 64, bytes: pixelsA.byteLength, visible: visible(pixelsA), sha256A: await digest(pixelsA), sha256B: await digest(pixelsB) }, + scheduler: scheduler.stats(), + }; + scheduler.dispose(); + grid.dispose(); + feedbackBuffer.destroy(); + session.dispose(); + return output; + }); + + expect(result.pageCount).toBeGreaterThan(1); + expect(result.leafPageId).toBeGreaterThan(0); + expect(result.beforeLoad[0].valid).toBe(true); + expect(result.missing.status).toBe("READY"); + expect(result.missing.pageIds).toEqual([result.leafPageId]); + expect(result.loadedPages).toEqual([result.leafPageId]); + expect(result.upload).toEqual({ pageId: result.leafPageId, redrawScheduled: true }); + expect(result.queuedBeforeRedraw).toBe(1); + expect(result.redraws).toBe(1); + expect(result.afterLoad[0].valid).toBe(true); + expect(result.pixels).toMatchObject({ width: 64, height: 64, bytes: 64 * 64 * 4 }); + expect(result.pixels.visible).toBeGreaterThan(0); + expect(result.pixels.sha256A).toBe("87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c"); + expect(result.pixels.sha256A).toBe(result.pixels.sha256B); + expect(result.scheduler).toMatchObject({ pending: false, scheduledCount: 1, redrawCount: 1, capped: false, errorCode: null }); +}); diff --git a/web/tests/e2e/nanovdb-offscreen-page-feedback.spec.ts b/web/tests/e2e/nanovdb-offscreen-page-feedback.spec.ts new file mode 100644 index 00000000..9cebf33a --- /dev/null +++ b/web/tests/e2e/nanovdb-offscreen-page-feedback.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; + +test("M8-12 Offscreen Worker repeats the main-thread NanoVDB page sequence and pixels", async ({ page }) => { + test.setTimeout(90_000); + await page.goto("/"); + const result = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/nanovdb-offscreen-page-feedback-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })); + expect(result.pageCount).toBeGreaterThan(1); + expect(result.leafPageId).toBeGreaterThan(0); + expect(result.beforeLoad[0].valid).toBe(true); + expect(result.missing.status).toBe("READY"); + expect(result.missing.pageIds).toEqual([result.leafPageId]); + expect(result.upload).toEqual({ pageId: result.leafPageId, redrawScheduled: true }); + expect(result.queuedBeforeRedraw).toBe(1); + expect(result.redraws).toBe(1); + expect(result.afterLoad[0].valid).toBe(true); + expect(result.pixels.bytes).toBe(64 * 64 * 4); + expect(result.pixels.sha256A).toBe("87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c"); + expect(result.pixels.sha256B).toBe(result.pixels.sha256A); + expect(result.scheduler).toMatchObject({ pending: false, scheduledCount: 1, redrawCount: 1, capped: false, errorCode: null }); +}); diff --git a/web/tests/e2e/nanovdb-opfs-restart.spec.ts b/web/tests/e2e/nanovdb-opfs-restart.spec.ts new file mode 100644 index 00000000..0f187831 --- /dev/null +++ b/web/tests/e2e/nanovdb-opfs-restart.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; + +test("M8-14 Worker restart restores the OPFS manifest without trusting resident pages", async ({ page }) => { + await page.goto("/"); + const run = (action: "prepare" | "reopen", state?: unknown): Promise => page.evaluate(({ action, state }) => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/nanovdb-opfs-restart-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({ action, state }); + }), { action, state }); + const prepared = await run("prepare"); + const reopened = await run("reopen", { + projectId: prepared.projectId, + bundleSha256: prepared.bundleSha256, + claimedResidentPages: prepared.claimedResidentPages, + }); + expect(prepared.residentBeforeRestart).toEqual([0]); + expect(reopened.manifestSha256).toBe(prepared.bundleSha256); + expect(reopened.claimedResidentPages).toEqual([0]); + expect(reopened.residentBeforeRestore).toEqual([]); + expect(reopened.residentAfterRestore).toEqual([0]); + expect(reopened.pageBytes).toBe(64 * 1024); + expect(reopened.tamperedError).toContain("NANOVDB_HASH_MISMATCH"); + expect(reopened.residentAfterTamperedRestore).toEqual([]); +}); diff --git a/web/tests/e2e/nanovdb-page-feedback.spec.ts b/web/tests/e2e/nanovdb-page-feedback.spec.ts new file mode 100644 index 00000000..e4c6c47f --- /dev/null +++ b/web/tests/e2e/nanovdb-page-feedback.spec.ts @@ -0,0 +1,197 @@ +import { expect, test } from "@playwright/test"; + +test("M8-02 through M8-10 gates, pins and bounded redraws manifest-backed page faults", async ({ page }) => { + test.setTimeout(90_000); + await page.goto("/"); + const result = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/nanovdb-page-feedback-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { + worker.terminate(); + event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); + }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })); + + expect(result.leafByteOffset).toBeGreaterThan(0); + expect(result.leafPageId).toBeGreaterThan(0); + expect(result.leafPageId).toBeLessThan(result.pageCount); + expect(result.manifestPageBytes).toBe(4 * 1024 * 1024); + expect(result.leafSampleCount).toBe(256); + expect(result.leafSamplesFallback).toBe(true); + expect(result.leafResult).toEqual({ + schemaVersion: 1, + renderRevision: 17, + attemptedCount: 1, + gpuStoredCount: 1, + uniqueCount: 1, + pageIds: [result.leafPageId], + status: "READY", + errorCode: null, + }); + expect(result.leafGuard).toEqual(result.guardWords); + expect(result.staleDispatch).toEqual({ + schemaVersion: 1, + renderRevision: 17, + currentRenderRevision: 18, + status: "STALE", + requestedPageIds: [], + requestedCount: 0, + errorCode: "REVISION_CONFLICT", + }); + expect(result.stalePageIoCount).toBe(0); + expect(result.currentDispatch).toEqual({ + schemaVersion: 1, + renderRevision: 17, + currentRenderRevision: 17, + status: "ACCEPTED", + requestedPageIds: [result.leafPageId], + requestedCount: 1, + errorCode: null, + }); + expect(result.pageIoRequests).toEqual([{ pageId: result.leafPageId, renderRevision: 17 }]); + expect(result.manifestFeedbackResult).toEqual({ + schemaVersion: 1, + renderRevision: 19, + attemptedCount: 1, + gpuStoredCount: 1, + uniqueCount: 1, + pageIds: [0], + status: "READY", + errorCode: null, + }); + expect(result.manifestWordsZero).toBe(true); + expect(result.manifestStaleDispatch).toEqual({ + schemaVersion: 1, + renderRevision: 19, + currentRenderRevision: 20, + status: "STALE", + requestedPageIds: [], + requestedCount: 0, + errorCode: "REVISION_CONFLICT", + }); + expect(result.staleManifestRangeCount).toBe(0); + expect(result.manifestDispatch).toEqual({ + schemaVersion: 1, + renderRevision: 19, + currentRenderRevision: 19, + status: "ACCEPTED", + requestedPageIds: [0], + requestedCount: 1, + errorCode: null, + }); + expect(result.manifestPageIoRequests).toEqual([{ pageId: 0, renderRevision: 19 }]); + expect(result.manifestRanges.length).toBeGreaterThan(0); + expect(result.manifestRanges).toEqual(result.declaredManifestRanges); + expect(result.manifestRanges.every((range: { sha256: string }) => /^[a-f0-9]{64}$/.test(range.sha256))).toBe(true); + expect(result.manifestPageMatchesPayload).toBe(true); + expect(result.coalesced.rangeCalls).toBe(1); + expect(result.coalesced.underlyingAborts).toBe(0); + expect(result.coalesced.beforeCancel).toEqual({ pendingPages: 1, subscribers: 2, pageIds: [0] }); + expect(result.coalesced.afterFirstCancel).toEqual({ pendingPages: 1, subscribers: 1, pageIds: [0] }); + expect(result.coalesced.afterResolve).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] }); + expect(result.coalesced.outcomes[0]).toEqual({ status: "REJECTED", name: "AbortError" }); + expect(result.coalesced.outcomes[1].status).toBe("RESOLVED"); + expect(result.coalesced.consumers).toEqual(["second:0"]); + expect(result.lastCancel.rangeCalls).toBe(1); + expect(result.lastCancel.underlyingAborts).toBe(1); + expect(result.lastCancel.afterFirst).toEqual({ + stats: { pendingPages: 1, subscribers: 1, pageIds: [0] }, + underlyingAborts: 0, + }); + expect(result.lastCancel.afterLast).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] }); + expect(result.lastCancel.outcomes).toEqual([ + { status: "REJECTED", name: "AbortError" }, + { status: "REJECTED", name: "AbortError" }, + ]); + expect(result.tamperedPage).toEqual({ + rangeCalls: 1, + residentWrites: 0, + errorCode: "NANOVDB_HASH_MISMATCH", + errorMessage: expect.stringContaining("NANOVDB_HASH_MISMATCH"), + cache: { + residentPageCount: 0, + residentBytes: 0, + residentVirtualPages: [], + coordinator: { pendingPages: 0, subscribers: 0, pageIds: [] }, + }, + }); + expect(result.framePin).toEqual({ + pinnedPage0: true, + pinnedMissingPage: false, + afterPinnedEviction: { + residentVirtualPages: [0, 2], + evictionCount: 1, + page0: true, + page1: false, + page2: true, + }, + allPinnedError: expect.stringContaining("NANOVDB_GPU_BUDGET_EXCEEDED: all resident NanoVDB pages are pinned"), + afterAllPinned: { + residentVirtualPages: [0, 2], + evictionCount: 1, + page3: false, + }, + afterNextFrame: { + residentVirtualPages: [2, 3], + evictionCount: 2, + page0: false, + page2: true, + page3: true, + }, + pageTable: [0xffffffff, 0xffffffff, 1, 0], + }); + expect(result.progressiveRedraw).toEqual({ + firstUpload: { pageId: 0, redrawScheduled: true }, + secondUpload: { pageId: 1, redrawScheduled: false }, + failedUpload: expect.stringContaining("NANOVDB_STREAM_INCOMPLETE"), + beforeFirst: { + queuedFrames: 1, + callbacks: 0, + stats: { pending: true, scheduledCount: 1, redrawCount: 0, maxRedraws: 32, capped: false, errorCode: null }, + }, + afterFirst: { + queuedFrames: 0, + callbacks: 1, + stats: { pending: false, scheduledCount: 1, redrawCount: 1, maxRedraws: 32, capped: false, errorCode: null }, + }, + thirdUpload: { pageId: 2, redrawScheduled: true }, + beforeSecond: { + queuedFrames: 1, + callbacks: 1, + stats: { pending: true, scheduledCount: 2, redrawCount: 1, maxRedraws: 32, capped: false, errorCode: null }, + }, + afterSecond: { + queuedFrames: 0, + callbacks: 2, + stats: { pending: false, scheduledCount: 2, redrawCount: 2, maxRedraws: 32, capped: false, errorCode: null }, + }, + }); + + expect(result.progressiveRedrawCap).toEqual({ + cappedRedraw: { + uploads: [ + { pageId: 0, redrawScheduled: true }, + { pageId: 0, redrawScheduled: true }, + { pageId: 0, redrawScheduled: false }, + { pageId: 0, redrawScheduled: false }, + ], + callbacks: 2, + queuedFrames: 0, + stats: { pending: false, scheduledCount: 2, redrawCount: 2, maxRedraws: 2, capped: true, errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" }, + }, + afterCappedReset: { pending: false, scheduledCount: 0, redrawCount: 0, maxRedraws: 2, capped: false, errorCode: null }, + }); + + expect(result.missingWordsZero).toBe(true); + expect(result.overflowResult.status).toBe("OVERFLOW"); + expect(result.overflowResult.renderRevision).toBe(18); + expect(result.overflowResult.errorCode).toBe("NANOVDB_PAGE_FEEDBACK_OVERFLOW"); + expect(result.overflowResult.attemptedCount).toBeGreaterThan(2); + expect(result.overflowResult.gpuStoredCount).toBe(2); + expect(result.overflowResult.uniqueCount).toBe(2); + expect(new Set(result.overflowResult.pageIds).size).toBe(2); + expect(result.overflowResult.pageIds).toEqual([...result.overflowResult.pageIds].sort((left, right) => left - right)); + expect(result.overflowResult.pageIds.every((pageId: number) => result.missingPageIds.includes(pageId))).toBe(true); + expect(result.overflowGuard).toEqual(result.guardWords); +}); diff --git a/web/tests/e2e/nanovdb-page-resume.spec.ts b/web/tests/e2e/nanovdb-page-resume.spec.ts new file mode 100644 index 00000000..6ea5fcc3 --- /dev/null +++ b/web/tests/e2e/nanovdb-page-resume.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; + +test("M8-13 resumes a feedback page from the exact interrupted byte offset", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/nanovdb-page-resume-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })); + expect(result.dispatch).toMatchObject({ status: "ACCEPTED", renderRevision: 17, requestedPageIds: [0], requestedCount: 1, errorCode: null }); + expect(result.ranges.length).toBeGreaterThanOrEqual(2); + expect(result.ranges[0]).toMatch(/^bytes=\d+-\d+$/); + expect(result.resumedRangeStart).toBe(Number(result.ranges[0].match(/^bytes=(\d+)-/)?.[1]) + 4096); + expect(result.ifRanges[0]).toBe(""); + expect(result.ifRanges[1]).toMatch(/^"vdb-/); + expect(result.consumedBytes).toBe(result.expectedBytes); + expect(result.consumedSha256).toBe(result.expectedSha256); + expect(result.coordinator).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] }); +}); diff --git a/web/tests/e2e/nanovdb-render-golden.spec.ts b/web/tests/e2e/nanovdb-render-golden.spec.ts new file mode 100644 index 00000000..eefd8caa --- /dev/null +++ b/web/tests/e2e/nanovdb-render-golden.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { compareNanoVDBRenderGolden, type NanoVDBRenderGoldenThresholdsIR } from "../../protocol/nanovdb-render-golden"; + +const goldenRoot = path.resolve(import.meta.dirname, "../../../tests/golden/M8-19"); +const goldenManifest = JSON.parse(fs.readFileSync(path.join(goldenRoot, "manifest.json"), "utf8")) as { + schemaVersion: number; + source: { sha256: string }; + renderContract: { + shaderSemanticVersion: string; + width: number; + height: number; + axes: Array<"X" | "Y" | "Z">; + thresholds: NanoVDBRenderGoldenThresholdsIR; + }; + images: Array<{ axis: "X" | "Y" | "Z"; file: string; sha256: string }>; +}; +const referenceImages = Object.fromEntries(goldenManifest.images.map((image) => [image.axis, new Uint8Array(fs.readFileSync(path.join(goldenRoot, image.file))) ])) as Record<"X" | "Y" | "Z", Uint8Array>; + +test("M8-19 compares desktop OpenVDB with main-thread and Offscreen WebGPU goldens", async ({ page }) => { + test.setTimeout(180_000); + await page.goto("/"); + const main = await page.evaluate(async () => { + const [viewport, renderer] = await Promise.all([ + import("/src/volume/nanovdb-viewport.ts"), + import("/src/render/nanovdb-volume-renderer.ts"), + ]); + const asset = await viewport.loadNanoVDBViewportAsset("m8-19-main", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal); + const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid); + const payload = asset.grids.find((grid) => grid.name === density?.name)?.data; + if (!density || !payload) throw new Error("NANOVDB_STREAM_INCOMPLETE: M8-19 density payload is missing"); + const session = new renderer.NanoVDBWebGPUDeviceSession(); + const device = await session.open(payload.byteLength, 4); + const uploaded = renderer.uploadNanoVDBFloat32Grid(device, payload); + const material = { ...asset.manifest.material, temperatureGrid: undefined, colorGrid: undefined, emissionGrid: undefined, interpolation: "LINEAR" as const }; + const images: Record = {}; + const hashes: Record = {}; + for (const axis of ["X", "Y", "Z"] as const) { + const pixels = await renderer.renderNanoVDBFloat32WebGPU(device, uploaded, density, material, 64, 64, {}, axis); + images[axis] = Array.from(pixels); + hashes[axis] = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", pixels))).map((byte) => byte.toString(16).padStart(2, "0")).join(""); + } + uploaded.dispose(); + session.dispose(); + return { sourceSha256: asset.manifest.sourceSha256, shaderSemanticVersion: asset.manifest.gpu.shaderSemanticVersion, hashes, images }; + }); + const offscreen = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/nanovdb-render-golden-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { + worker.terminate(); + if (event.data.error) { reject(new Error(event.data.error)); return; } + resolve({ + ...event.data, + images: Object.fromEntries(Object.entries(event.data.images).map(([axis, buffer]) => [axis, Array.from(new Uint8Array(buffer as ArrayBuffer))])), + }); + }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })); + + expect(goldenManifest.schemaVersion).toBe(1); + expect(main.sourceSha256).toBe(goldenManifest.source.sha256); + expect(offscreen.sourceSha256).toBe(goldenManifest.source.sha256); + expect(main.shaderSemanticVersion).toBe(goldenManifest.renderContract.shaderSemanticVersion); + expect(offscreen.shaderSemanticVersion).toBe(goldenManifest.renderContract.shaderSemanticVersion); + const report: Record = {}; + for (const image of goldenManifest.images) { + const reference = referenceImages[image.axis]; + const mainPixels = Uint8Array.from(main.images[image.axis]); + const offscreenPixels = Uint8Array.from(offscreen.images[image.axis]); + const mainComparison = compareNanoVDBRenderGolden(reference, mainPixels, goldenManifest.renderContract.thresholds); + const offscreenComparison = compareNanoVDBRenderGolden(reference, offscreenPixels, goldenManifest.renderContract.thresholds); + const backendComparison = compareNanoVDBRenderGolden(mainPixels, offscreenPixels, { + maxChannelError: 0, + meanAbsoluteError: 0, + rmsError: 0, + alphaCoverageDeltaRatio: 0, + }); + expect(main.hashes[image.axis]).toBe(image.sha256); + expect(offscreen.hashes[image.axis]).toBe(image.sha256); + expect(mainComparison.status).toBe("READY"); + expect(offscreenComparison.status).toBe("READY"); + expect(backendComparison.status).toBe("READY"); + report[image.axis] = { main: mainComparison, offscreen: offscreenComparison, backend: backendComparison, sha256: image.sha256 }; + } + console.log("nanovdb-render-golden", JSON.stringify(report)); +}); diff --git a/web/tests/e2e/nanovdb-sparse-performance.spec.ts b/web/tests/e2e/nanovdb-sparse-performance.spec.ts new file mode 100644 index 00000000..4a16ab0c --- /dev/null +++ b/web/tests/e2e/nanovdb-sparse-performance.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +test("M8-17 streams a 64 MiB sparse bundle with bounded paging and cancellation", async ({ page }) => { + test.setTimeout(60_000); + await page.goto("/"); + const result = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/nanovdb-sparse-performance-test.worker.ts", { type: "module" }); + worker.onmessage = (event) => { + worker.terminate(); + event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); + }; + worker.onerror = (event) => { + worker.terminate(); + reject(new Error(event.message)); + }; + worker.postMessage({}); + })); + + console.log("nanovdb-sparse-performance", JSON.stringify({ + bundleBytes: result.bundleBytes, + requestedPageIds: result.requestedPageIds, + loadedPageCount: result.loadedPageCount, + transferredBytes: result.transferredBytes, + peakRangeBytes: result.peakRangeBytes, + firstPageMs: result.firstPageMs, + successElapsedMs: result.successElapsedMs, + cancel: result.cancel, + })); + + const mib = 1024 * 1024; + expect(result.bundleBytes).toBe(64 * mib); + expect(result.chunkBytes).toBe(4 * mib); + expect(result.pageBytes).toBe(256 * 1024); + expect(result.requestedPageIds).toEqual([0, 16, 128, 192]); + expect(result.loadedPageCount).toBe(result.requestedPageIds.length); + expect(result.transferredBytes).toBe(4 * result.chunkBytes); + expect(result.rangeCalls).toBe(result.requestedPageIds.length); + expect(result.peakRangeBytes).toBe(result.chunkBytes); + expect(result.firstPageMs).toBeLessThan(10_000); + expect(result.successElapsedMs).toBeLessThan(30_000); + expect(result.successCoordinatorStats).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] }); + + expect(result.cancel.status).toBe("CANCELLED"); + expect(result.cancel.errorName).toBe("AbortError"); + expect(result.cancel.consumerCalls).toBe(0); + expect(result.cancel.rangeCalls).toBe(1); + expect(result.cancel.abortedRequests).toBe(1); + expect(result.cancel.transferredBytes).toBeGreaterThan(0); + expect(result.cancel.transferredBytes).toBeLessThan(result.chunkBytes); + expect(result.cancel.peakRangeBytes).toBe(result.chunkBytes); + expect(result.cancel.cancelLatencyMs).toBeLessThan(2_000); + expect(result.cancel.coordinatorStats).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] }); +}); diff --git a/web/tests/e2e/nanovdb-volume-roundtrip.spec.ts b/web/tests/e2e/nanovdb-volume-roundtrip.spec.ts new file mode 100644 index 00000000..965db21e --- /dev/null +++ b/web/tests/e2e/nanovdb-volume-roundtrip.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"); + +test("M8-18 saves Volume Main and its NanoVDB binding, then reopens both production viewports", async ({ page }) => { + test.setTimeout(240_000); + await page.goto("/"); + const blend = fs.readFileSync(nonMeshBlend); + const result = await page.evaluate(async ({ blendBytes }) => { + const [engineModule, storageModule, volumeModule, opfsModule, viewportModule, offscreenModule] = await Promise.all([ + import("/src/engine-client/WebEngineClient.ts"), + import("/src/storage/StorageClient.ts"), + import("/src/volume/nanovdb-viewport.ts"), + import("/src/volume/nanovdb-opfs.ts"), + import("/src/three-adapter/viewport.ts"), + import("/src/three-adapter/offscreen-viewport.ts"), + ]); + const { WebEngineClient } = engineModule; + const { StorageClient } = storageModule; + const { + loadAndCommitNanoVDBViewportAsset, + reopenNanoVDBViewportAssetFromOPFS, + } = volumeModule; + const { listVDBProjectBindings, pruneNanoVDBOPFS } = opfsModule; + const { ViewportRenderer } = viewportModule; + const { OffscreenViewportRenderer } = offscreenModule; + const projectId = "m8-volume-roundtrip"; + const mainSourcePath = "//volumes/generated-smoke.vdb"; + const bindingSourcePath = "volumes/generated-smoke.vdb"; + const data = blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength) as ArrayBuffer; + const digest = async (value: ArrayBuffer): Promise => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", value)), (byte) => byte.toString(16).padStart(2, "0")).join(""); + const waitFor = async (condition: () => boolean, timeoutMs = 120_000): Promise => { + const deadline = performance.now() + timeoutMs; + while (!condition()) { + if (performance.now() > deadline) throw new Error("M8-18 viewport volume timeout"); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + }; + const renderViewport = async ( + backend: "main" | "offscreen", + snapshot: any, + geometryBuffers: any[], + nonMeshGeometryBuffers: any[], + asset: any, + ): Promise => { + const canvas = document.createElement("canvas"); + canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px;z-index:10000"; + document.body.append(canvas); + const renderer: any = backend === "main" ? new ViewportRenderer(canvas) : new OffscreenViewportRenderer(canvas); + renderer.setSnapshot(snapshot, geometryBuffers, nonMeshGeometryBuffers); + renderer.setVolumeAssets([asset]); + await waitFor(() => ["ready", "blocked"].includes(canvas.dataset.volumeStatus ?? "")); + if (canvas.dataset.volumeStatus !== "ready") throw new Error(`${backend} volume ${canvas.dataset.volumeErrorCode ?? "blocked"}`); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const visible = backend === "offscreen" + ? Number(canvas.dataset.rendererPixels ?? 0) + : (() => { + const gl = renderer.renderer.getContext(); + const pixels = new Uint8Array(64 * 64 * 4); + gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)), 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + return Array.from({ length: 64 * 64 }, (_, index) => pixels[index * 4 + 3] > 0 && pixels[index * 4] + pixels[index * 4 + 1] + pixels[index * 4 + 2] > 40).filter(Boolean).length; + })(); + let volumeObjects = 0; + if (backend === "main") renderer.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) volumeObjects += 1; }); + const status = { backend, volumeStatus: canvas.dataset.volumeStatus, volumeCount: Number(canvas.dataset.volumeCount), volumeObjects, visible }; + renderer.dispose(); + canvas.remove(); + return status; + }; + + await pruneNanoVDBOPFS(projectId, 0); + const firstEngine = new WebEngineClient({ timeoutMs: 60_000 }); + await firstEngine.init(); + const opened = await firstEngine.openBlend(data.slice(0)); + const volume = opened.snapshot.nonMeshData.find((candidate: any) => candidate.type === "VOLUME"); + if (!volume) throw new Error("M8-18 nonmesh fixture has no Volume Main data"); + const properties = { displayDensity: 1.75, interpolation: "NEAREST" as const, stepSize: 0.125, velocityGrid: "velocity", velocityScale: 1.5 }; + const changed = await firstEngine.applyCommand({ type: "setVolumeProperties", dataId: volume.id, sourcePath: mainSourcePath, ...properties }); + const saved = await firstEngine.saveBlend(); + const savedHash = await digest(saved); + const firstVolume = changed.snapshot.nonMeshData.find((candidate: any) => candidate.id === volume.id); + if (!firstVolume || firstVolume.sourcePath !== mainSourcePath) throw new Error("M8-18 Volume Main source path did not commit"); + const storage = new StorageClient(); + const savedProject = await storage.saveProject(projectId, 7, saved.slice(0)); + storage.terminate(); + firstEngine.terminate(); + + const committedStorage = new StorageClient(); + const persisted = await committedStorage.readProject(projectId); + committedStorage.terminate(); + const committedAsset = await loadAndCommitNanoVDBViewportAsset( + volume.id, + bindingSourcePath, + "/volumes/generated-smoke.nanovdb.json", + "/volumes/generated-smoke.nvdb", + { projectId, sourceBlendSha256: savedHash }, + new AbortController().signal, + ); + const bindings = await listVDBProjectBindings(projectId); + const binding = bindings.find((candidate: any) => candidate.bundleSha256 === committedAsset.manifest.bundleSha256); + if (!binding) throw new Error("M8-18 VDB binding was not discoverable after commit"); + + const reopenedEngine = new WebEngineClient({ timeoutMs: 60_000 }); + await reopenedEngine.init(); + const reopened = await reopenedEngine.openBlend(persisted.buffer.slice(0)); + const reopenedVolume = reopened.snapshot.nonMeshData.find((candidate: any) => candidate.id === volume.id); + if (!reopenedVolume) throw new Error("M8-18 reopened Main has no Volume data"); + const reopenedAsset = await reopenNanoVDBViewportAssetFromOPFS( + volume.id, + bindingSourcePath, + { projectId, sourceBlendSha256: savedHash }, + new AbortController().signal, + ); + const assetHashes = async (asset: any): Promise => Promise.all(asset.grids.map((grid: any) => digest(grid.data))); + const committedHashes = await assetHashes(committedAsset); + const reopenedHashes = await assetHashes(reopenedAsset); + const mainViewport = await renderViewport("main", reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? [], reopenedAsset); + const offscreenViewport = await renderViewport("offscreen", reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? [], reopenedAsset); + const reopenedBytesHash = await digest(persisted.buffer); + reopenedEngine.terminate(); + await pruneNanoVDBOPFS(projectId, 0); + return { + project: { revision: savedProject.revision, bytes: savedProject.bytes, persisted: savedProject.persisted, backend: savedProject.backend, hash: savedProject.sha256 }, + savedHash, + reopenedStorage: { revision: persisted.revision, bytes: persisted.bytes, sha256: persisted.sha256, backend: persisted.backend, recovered: persisted.recovered, bytesHash: reopenedBytesHash }, + main: { id: firstVolume.id, sourcePath: firstVolume.sourcePath, properties: firstVolume.volumeProperties }, + reopenedMain: { id: reopenedVolume.id, sourcePath: reopenedVolume.sourcePath, properties: reopenedVolume.volumeProperties, revision: reopened.snapshot.revision }, + binding: { projectId: binding.projectId, sourcePath: binding.sourcePath, sourceBlendSha256: binding.sourceBlendSha256, bundleSha256: binding.bundleSha256, manifestSha256: binding.manifestSha256 }, + asset: { dataId: reopenedAsset.dataId, bundleSha256: reopenedAsset.manifest.bundleSha256, committedHashes, reopenedHashes, gridCount: reopenedAsset.grids.length }, + viewports: { main: mainViewport, offscreen: offscreenViewport }, + }; + }, { blendBytes: new Uint8Array(blend) }); + + console.log("nanovdb-volume-roundtrip", JSON.stringify({ + project: result.project, + savedHash: result.savedHash, + reopenedStorage: result.reopenedStorage, + binding: result.binding, + asset: result.asset, + viewports: result.viewports, + })); + expect(result.project).toMatchObject({ revision: 7, persisted: true, backend: "opfs", hash: result.savedHash }); + expect(result.project.bytes).toBeGreaterThan(0); + expect(result.reopenedStorage).toMatchObject({ revision: 7, sha256: result.savedHash, backend: "opfs", recovered: false, bytesHash: result.savedHash }); + expect(result.main).toMatchObject({ id: result.reopenedMain.id, sourcePath: "//volumes/generated-smoke.vdb", properties: { displayDensity: 1.75, interpolation: "NEAREST", stepSize: 0.125, velocityGrid: "velocity", velocityScale: 1.5 } }); + expect(result.reopenedMain).toMatchObject(result.main); + expect(result.binding).toMatchObject({ projectId: "m8-volume-roundtrip", sourcePath: "volumes/generated-smoke.vdb", sourceBlendSha256: result.savedHash }); + expect(result.asset).toMatchObject({ dataId: result.main.id, committedHashes: result.asset.reopenedHashes }); + expect(result.asset.gridCount).toBeGreaterThan(0); + expect(result.viewports.main).toMatchObject({ backend: "main", volumeStatus: "ready", volumeCount: 1, volumeObjects: 1 }); + expect(result.viewports.main.visible).toBeGreaterThan(50); + expect(result.viewports.offscreen).toMatchObject({ backend: "offscreen", volumeStatus: "ready", volumeCount: 1 }); + expect(result.viewports.offscreen.visible).toBeGreaterThan(10); +}); diff --git a/web/tests/e2e/nla-evaluation-golden.spec.ts b/web/tests/e2e/nla-evaluation-golden.spec.ts new file mode 100644 index 00000000..3ff6eeaf --- /dev/null +++ b/web/tests/e2e/nla-evaluation-golden.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-11/nla-evaluation.json"), "utf8")); +const blendBytes = fs.readFileSync(path.join(root, golden.fixture)); + +test("M10-11 evaluates NLA track, strip and time mapping through the production Worker", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const result = await page.evaluate(async ({ bytes, expected }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const client = new WebEngineClient({ timeoutMs: 60_000 }); + await client.init(); + const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const opened = await client.openBlend(source); + const initial = JSON.stringify({ nlaTracks: opened.snapshot.nlaTracks, animations: opened.snapshot.animations }); + const samples = []; + for (const frame of expected.frames) { + await client.applyCommand({ type: "setFrame", frame: frame.frame }); + const report = await client.evaluateDepsgraph(); + const mesh = report.depsgraph.meshes.find((candidate) => candidate.objectId === `object:${expected.object}`); + samples.push({ frame: frame.frame, status: report.depsgraph.status, worldMatrix: mesh?.worldMatrix ?? null }); + } + const after = await client.snapshot(); + client.terminate(); + return { snapshot: opened.snapshot, initial, after: JSON.stringify({ nlaTracks: after.snapshot.nlaTracks, animations: after.snapshot.animations }), samples }; + }, { bytes: new Uint8Array(blendBytes), expected: golden }); + + expect(result.snapshot.nlaTracks).toHaveLength(golden.tracks.length); + expect(result.snapshot.nlaTracks[0].strips).toHaveLength(golden.tracks[0].strips.length); + expect(result.after).toBe(result.initial); + expect(result.samples).toHaveLength(golden.frames.length); + for (const [index, sample] of result.samples.entries()) { + expect(sample.status, `frame ${sample.frame}`).toBe("EVALUATED"); + expect(sample.worldMatrix, `frame ${sample.frame}`).not.toBeNull(); + const maximumError = Math.max(...sample.worldMatrix.map((value: number, matrixIndex: number) => + Math.abs(value - golden.frames[index].worldMatrix[matrixIndex]))); + expect(maximumError, `frame ${sample.frame} matrix error`).toBeLessThanOrEqual(golden.tolerance.maxMatrixError); + } +}); diff --git a/web/tests/e2e/nla-operator.spec.ts b/web/tests/e2e/nla-operator.spec.ts new file mode 100644 index 00000000..c0019e76 --- /dev/null +++ b/web/tests/e2e/nla-operator.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const fixture = fs.readFileSync(path.resolve( + import.meta.dirname, + "../../../tests/files/web/nla_time_mapping_scene.blend", +)); + +test("M10-12 moves one NLA strip through Main, undo, redo and save/reopen", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const result = await page.evaluate(async ({ bytes }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const first = new WebEngineClient({ timeoutMs: 60_000 }); + await first.init(); + const opened = await first.openBlend(source); + const track = opened.snapshot.nlaTracks?.find((candidate) => candidate.name === "M10 Time Mapping"); + const strip = track?.strips.find((candidate) => candidate.id === "M10 Scaled Clip"); + if (!track || !strip) throw new Error("NLA move fixture is incomplete"); + const initialRevision = opened.snapshot.revision; + const initialTracks = JSON.stringify(opened.snapshot.nlaTracks); + + let staleCode = ""; + try { + await first.applyCommand({ + type: "moveNLAStrip", + objectId: track.ownerId, + trackId: track.id, + stripId: strip.id, + frameStart: 5, + baseRevision: initialRevision - 1, + }); + } + catch (error) { + staleCode = (error as { code?: string }).code ?? ""; + } + const afterStale = await first.snapshot(); + + const moved = await first.applyCommand({ + type: "moveNLAStrip", + objectId: track.ownerId, + trackId: track.id, + stripId: strip.id, + frameStart: 5, + baseRevision: initialRevision, + }); + const movedStrip = moved.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name) + ?.strips.find((candidate) => candidate.id === strip.id); + + const undone = await first.applyCommand({ type: "undo" }); + const undoneStrip = undone.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name) + ?.strips.find((candidate) => candidate.id === strip.id); + const redone = await first.applyCommand({ type: "redo" }); + const redoneStrip = redone.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name) + ?.strips.find((candidate) => candidate.id === strip.id); + const saved = await first.saveBlend(); + first.terminate(); + + const second = new WebEngineClient({ timeoutMs: 60_000 }); + await second.init(); + const reopened = await second.openBlend(saved); + const reopenedStrip = reopened.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name) + ?.strips.find((candidate) => candidate.id === strip.id); + await second.applyCommand({ type: "setFrame", frame: 10 }); + const report = await second.evaluateDepsgraph(); + const mesh = report.depsgraph.meshes.find((candidate) => candidate.objectId === track.ownerId); + second.terminate(); + + return { + staleCode, + stalePreserved: JSON.stringify(afterStale.snapshot.nlaTracks) === initialTracks, + revisions: [initialRevision, moved.snapshot.revision, undone.snapshot.revision, redone.snapshot.revision], + delta: [moved.delta.baseRevision, moved.delta.nextRevision], + moved: movedStrip ? [movedStrip.frameStart, movedStrip.frameEnd] : null, + undone: undoneStrip ? [undoneStrip.frameStart, undoneStrip.frameEnd] : null, + redone: redoneStrip ? [redoneStrip.frameStart, redoneStrip.frameEnd] : null, + reopened: reopenedStrip ? [reopenedStrip.frameStart, reopenedStrip.frameEnd] : null, + evaluatedX: mesh?.worldMatrix[3] ?? null, + evaluationStatus: report.depsgraph.status, + }; + }, { bytes: new Uint8Array(fixture) }); + + expect(result.staleCode).toBe("REVISION_CONFLICT"); + expect(result.stalePreserved).toBe(true); + expect(result.revisions).toEqual([ + result.revisions[0], + result.revisions[0] + 1, + result.revisions[0] + 2, + result.revisions[0] + 3, + ]); + expect(result.delta).toEqual([result.revisions[0], result.revisions[0] + 1]); + expect(result.moved).toEqual([5, 25]); + expect(result.undone).toEqual([20, 40]); + expect(result.redone).toEqual([5, 25]); + expect(result.reopened).toEqual([5, 25]); + expect(result.evaluationStatus).toBe("EVALUATED"); + expect(result.evaluatedX).toBeCloseTo(2.5, 5); +}); diff --git a/web/tests/e2e/oom-recovery.spec.ts b/web/tests/e2e/oom-recovery.spec.ts index 30d60ca0..66411741 100644 --- a/web/tests/e2e/oom-recovery.spec.ts +++ b/web/tests/e2e/oom-recovery.spec.ts @@ -75,9 +75,17 @@ test("recovers deterministically from WASM, OPFS, GPU and NanoVDB allocation fau const nanoVdb = result.reports.find((report) => report.scenario === "NANOVDB_RESIDENT")!; expect(nanoVdb.faults[0]).toMatchObject({ point: "NANOVDB_PAGE_TABLE", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" }); - expect(nanoVdb.memory.releasedBytes).toBe(64 * 1024); - expect(nanoVdb.state.temporaryResourcesPeak).toBe(1); - expect(nanoVdb.checks).toEqual(expect.arrayContaining(["resident-buffer-destroyed-once", "same-device-recovers", "lru-eviction-recovers"])); + expect(nanoVdb.faults[1]).toMatchObject({ point: "NANOVDB_FEEDBACK_BUFFER", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_FEEDBACK_BUFFER" }); + expect(nanoVdb.memory.releasedBytes).toBe(2 * 64 * 1024 + 8 + 32); + expect(nanoVdb.state.temporaryResourcesPeak).toBe(3); + expect(nanoVdb.checks).toEqual(expect.arrayContaining([ + "resident-buffer-destroyed-once", + "page-table-destroyed-once", + "feedback-buffer-destroyed-once", + "resource-group-dispose-idempotent", + "same-device-recovers", + "lru-eviction-recovers", + ])); console.log("oom-recovery", JSON.stringify(result.reports.map((report) => ({ scenario: report.scenario, diff --git a/web/tests/e2e/paint-depth-visibility.spec.ts b/web/tests/e2e/paint-depth-visibility.spec.ts new file mode 100644 index 00000000..3fd8fb5c --- /dev/null +++ b/web/tests/e2e/paint-depth-visibility.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; + +test("M9-09 derives the same occlusion set from real main-thread and Offscreen GPU depth passes", async ({ page }) => { + test.setTimeout(60_000); + await page.goto("/"); + const result = await page.evaluate(async () => { + const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts"); + const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts"); + + const normalize = (value: number[]) => { + const length = Math.hypot(...value); + return value.map((component) => component / length); + }; + const cross = (left: number[], right: number[]) => [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ]; + const add = (...values: number[][]) => values[0].map((_, axis) => values.reduce((sum, value) => sum + value[axis], 0)); + const scale = (value: number[], factor: number) => value.map((component) => component * factor); + const camera = [ + 7 * Math.cos(0.55) * Math.cos(-Math.PI / 4), + 7 * Math.cos(0.55) * Math.sin(-Math.PI / 4), + 7 * Math.sin(0.55), + ]; + const direction = normalize(camera.map((component) => -component)); + const horizontal = normalize(cross(direction, [0, 0, 1])); + const vertical = normalize(cross(direction, horizontal)); + const front = [ + add(scale(horizontal, -2), scale(vertical, -2)), + add(scale(horizontal, -2), scale(vertical, 2)), + add(scale(horizontal, 2), scale(vertical, 2)), + add(scale(horizontal, 2), scale(vertical, -2)), + ]; + const behind = scale(direction, 1.5); + const back = [ + add(behind, scale(horizontal, -0.12), scale(vertical, -0.08)), + add(behind, scale(horizontal, 0.12), scale(vertical, -0.08)), + add(behind, scale(vertical, 0.12)), + ]; + const toBlender = (value: number[]) => [value[0], -value[2], value[1]]; + const positions = new Float32Array([...front, ...back].flatMap(toBlender)); + const indices = new Uint32Array([0, 1, 2, 0, 2, 3, 4, 6, 5]); + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const snapshot = { + schemaVersion: 1 as const, + revision: 9, + sceneId: "scene:paint-depth", + source: { kind: "mock" as const }, + coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, + nodes: [{ + id: "object:PaintDepth", + name: "PaintDepth", + type: "MESH" as const, + parentId: null, + dataId: "mesh:PaintDepth", + visible: true, + selectable: true, + localMatrix: identity, + worldMatrix: identity, + transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 }, + }], + meshes: [{ id: "mesh:PaintDepth", name: "PaintDepth", vertexCount: 7, edgeCount: 0, faceCount: 3, cornerCount: 9, triangleCount: 3, geometryStatus: "binary" as const }], + materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [], + activeObjectId: "object:PaintDepth", + frame: { current: 1, start: 1, end: 1 }, + }; + const geometry = { + schemaVersion: 1 as const, + meshId: "mesh:PaintDepth", + byteLength: positions.byteLength + indices.byteLength, + positions: positions.buffer, + indices: indices.buffer, + }; + const request = { schemaVersion: 1 as const, objectId: "object:PaintDepth", meshId: "mesh:PaintDepth", revision: 9, vertexIndices: [0, 1, 2, 3, 4, 5, 6] }; + + const createCanvas = () => { + const canvas = document.createElement("canvas"); + canvas.style.width = "320px"; + canvas.style.height = "240px"; + document.body.append(canvas); + return canvas; + }; + + const mainCanvas = createCanvas(); + const mainRenderer = new ViewportRenderer(mainCanvas); + mainRenderer.setSnapshot(snapshot, [geometry]); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const main = await mainRenderer.samplePaintVisibility(request); + mainRenderer.dispose(); + mainCanvas.remove(); + + const offscreenCanvas = createCanvas(); + const offscreenRenderer = new OffscreenViewportRenderer(offscreenCanvas); + offscreenRenderer.setSnapshot(snapshot, [geometry]); + let stale = ""; + try { await offscreenRenderer.samplePaintVisibility({ ...request, revision: 8 }); } + catch (error) { stale = error instanceof Error ? error.message : String(error); } + const offscreen = await offscreenRenderer.samplePaintVisibility(request); + offscreenRenderer.dispose(); + offscreenCanvas.remove(); + + return { main, offscreen, stale }; + }); + + expect(result.main.backend).toBe("MAIN_THREAD_WEBGL2"); + expect(result.offscreen.backend).toBe("OFFSCREEN_WEBGL2"); + expect(result.main.source).toBe("GPU_RGBA_DEPTH_READBACK"); + expect(result.main.occluderPixelCount).toBeGreaterThan(0); + expect(result.offscreen.occluderPixelCount).toBeGreaterThan(0); + expect(result.main.depthReadbackBytes).toBe(result.main.width * result.main.height * 4); + expect(result.offscreen.depthReadbackBytes).toBe(result.offscreen.width * result.offscreen.height * 4); + expect(result.main.visibleVertexIndices).toEqual([0, 1, 2, 3]); + expect(result.offscreen.visibleVertexIndices).toEqual(result.main.visibleVertexIndices); + expect(result.stale).toContain("REVISION_CONFLICT"); +}); diff --git a/web/tests/e2e/paint-pbvh-capability.spec.ts b/web/tests/e2e/paint-pbvh-capability.spec.ts new file mode 100644 index 00000000..75a796e3 --- /dev/null +++ b/web/tests/e2e/paint-pbvh-capability.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { paintPBVHBrushInventory } from "../../protocol/paint-pbvh-capability"; + +const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend"); + +test("M9-13 production Worker blocks the full PBVH brush inventory without changing Main", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const blendBytes = fs.readFileSync(attributeBlend); + const result = await page.evaluate(async ({ blendBytes, inventory }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const engine = new WebEngineClient({ timeoutMs: 60_000 }); + await engine.init(); + const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength)); + const object = opened.snapshot.nodes.find((candidate) => candidate.id === opened.snapshot.activeObjectId && candidate.type === "MESH"); + if (!object?.dataId) throw new Error("PBVH fixture has no active Mesh object"); + const before = await engine.snapshot(); + const gates = []; + for (const entry of inventory) { + gates.push(await engine.queryPaintPBVHCapability({ + schemaVersion: 1, + operation: "PBVH_BRUSH", + domain: entry.domain, + brush: entry.brush, + objectId: object.id, + meshId: object.dataId, + baseRevision: before.snapshot.revision, + })); + } + const stale = await engine.queryPaintPBVHCapability({ + schemaVersion: 1, + operation: "PBVH_BRUSH", + domain: "WEIGHT", + brush: "DRAW", + objectId: object.id, + meshId: object.dataId, + baseRevision: before.snapshot.revision + 1, + }); + let malformed: { code?: string; severity?: string; recoverable?: boolean } = {}; + try { + await engine.queryPaintPBVHCapability({ + schemaVersion: 1, + operation: "PBVH_BRUSH", + domain: "WEIGHT", + brush: "DRAW", + objectId: object.id, + meshId: object.dataId, + baseRevision: before.snapshot.revision, + proxySuccess: true, + } as never); + } + catch (error) { + malformed = error as typeof malformed; + } + const after = await engine.snapshot(); + engine.terminate(); + return { + count: gates.length, + statuses: [...new Set(gates.map((gate) => gate.status))], + taskIds: [...new Set(gates.map((gate) => gate.taskId))], + issueCodes: [...new Set(gates.flatMap((gate) => gate.issues.map((issue) => issue.code)))], + capabilities: new Set(gates.map((gate) => gate.capability)).size, + staleCode: stale.issues[0]?.code, + malformed, + beforeRevision: before.snapshot.revision, + afterRevision: after.snapshot.revision, + beforeHandles: before.status.liveHandles, + afterHandles: after.status.liveHandles, + beforeBytes: before.status.allocatedBytes, + afterBytes: after.status.allocatedBytes, + }; + }, { blendBytes: new Uint8Array(blendBytes), inventory: paintPBVHBrushInventory() }); + + expect(result).toEqual({ + count: 46, + statuses: ["BLOCKED"], + taskIds: ["N-017"], + issueCodes: ["PAINT_PBVH_UNAVAILABLE"], + capabilities: 46, + staleCode: "REVISION_CONFLICT", + malformed: { + code: "PAINT_SCHEMA_INVALID", + severity: "error", + recoverable: true, + message: "PBVH capability request contains unsupported field proxySuccess", + }, + beforeRevision: result.beforeRevision, + afterRevision: result.beforeRevision, + beforeHandles: result.beforeHandles, + afterHandles: result.beforeHandles, + beforeBytes: result.beforeBytes, + afterBytes: result.beforeBytes, + }); +}); diff --git a/web/tests/e2e/paint-stroke-session.spec.ts b/web/tests/e2e/paint-stroke-session.spec.ts new file mode 100644 index 00000000..7a5cb258 --- /dev/null +++ b/web/tests/e2e/paint-stroke-session.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend"); + +test("M9-10 commits pointer chunks as one Main revision and one undo step", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const blendBytes = fs.readFileSync(attributeBlend); + const result = await page.evaluate(async ({ blendBytes }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const engine = new WebEngineClient({ timeoutMs: 60_000 }); + await engine.init(); + const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength)); + const mesh = opened.snapshot.meshes.find((candidate) => candidate.id === "mesh:AttributeMesh"); + if (!mesh || mesh.vertexCount < 4) throw new Error("paint fixture is missing its vertex domain"); + const pointerSessionId = "paint-pointer:chromium-main-1"; + const session = { + schemaVersion: 1 as const, + pointerSessionId, + baseRevision: opened.snapshot.revision, + target: { mode: "VERTEX_COLOR" as const, meshId: mesh.id, attributeName: "M9StrokeColor", domain: "POINT" as const }, + }; + const started = await engine.beginPaintStroke(session); + const first = await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, chunkIndex: 0, indices: [0, 1], values: [1, 0, 0, 1, 0, 1, 0, 1] }); + const during = await engine.snapshot(); + const second = await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, chunkIndex: 1, indices: [1, 2, 3], values: [0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1] }); + const committed = await engine.commitPaintStroke({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, expectedChunkCount: 2 }); + const committedMesh = committed.snapshot.meshes.find((candidate) => candidate.id === mesh.id); + const undone = await engine.applyCommand({ type: "undo" }); + const undoneMesh = undone.snapshot.meshes.find((candidate) => candidate.id === mesh.id); + let secondUndoCode = ""; + try { await engine.applyCommand({ type: "undo" }); } + catch (error) { secondUndoCode = (error as { code?: string }).code ?? String(error); } + const redone = await engine.applyCommand({ type: "redo" }); + const redoneMesh = redone.snapshot.meshes.find((candidate) => candidate.id === mesh.id); + + const cancelId = "paint-pointer:chromium-cancel-2"; + const cancelBase = redone.snapshot.revision; + await engine.beginPaintStroke({ ...session, pointerSessionId: cancelId, baseRevision: cancelBase }); + await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId: cancelId, baseRevision: cancelBase, chunkIndex: 0, indices: [0], values: [0, 0, 0, 1] }); + const cancelled = await engine.cancelPaintStroke({ schemaVersion: 1, pointerSessionId: cancelId, baseRevision: cancelBase }); + const afterCancel = await engine.snapshot(); + engine.terminate(); + const hasAttribute = (candidate: typeof mesh | undefined) => candidate?.attributes?.some((attribute) => attribute.name === "M9StrokeColor" && attribute.domain === "POINT") ?? false; + return { + started, + first, + second, + duringRevision: during.snapshot.revision, + committedRevision: committed.snapshot.revision, + committedReceipt: committed.paintStrokeSession, + committedAttribute: hasAttribute(committedMesh), + undoneAttribute: hasAttribute(undoneMesh), + redoneAttribute: hasAttribute(redoneMesh), + secondUndoCode, + cancelled, + cancelBase, + afterCancelRevision: afterCancel.snapshot.revision, + }; + }, { blendBytes: new Uint8Array(blendBytes) }); + + expect(result.started).toMatchObject({ state: "OPEN", chunkCount: 0 }); + expect(result.first).toMatchObject({ state: "OPEN", chunkCount: 1, receivedEntryCount: 2, uniqueEntryCount: 2 }); + expect(result.second).toMatchObject({ state: "OPEN", chunkCount: 2, receivedEntryCount: 5, uniqueEntryCount: 4 }); + expect(result.duringRevision).toBe(result.started.baseRevision); + expect(result.committedRevision).toBe(result.started.baseRevision + 1); + expect(result.committedReceipt).toMatchObject({ state: "COMMITTED", chunkCount: 2, receivedEntryCount: 5, uniqueEntryCount: 4, committedRevision: result.committedRevision }); + expect(result.committedAttribute).toBe(true); + expect(result.undoneAttribute).toBe(false); + expect(result.redoneAttribute).toBe(true); + expect(result.secondUndoCode).toBe("INVALID_ARGUMENT"); + expect(result.cancelled.state).toBe("CANCELLED"); + expect(result.afterCancelRevision).toBe(result.cancelBase); +}); diff --git a/web/tests/e2e/physics-cache-family.spec.ts b/web/tests/e2e/physics-cache-family.spec.ts new file mode 100644 index 00000000..7935b468 --- /dev/null +++ b/web/tests/e2e/physics-cache-family.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-14/physics-cache-family.json"), "utf8")); + +test("M10-14 verifies every Physics family cache before playback", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/physics-cache-family-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })) as { + verified: Array<{ family: string; source: string; bytes: number; frames: number }>; + sourceMismatch: string; + payloadMismatch: string; + versionMismatch: string; + budgetExceeded: string; + }; + expect(result.verified.map((entry) => entry.family)).toEqual(expected.families); + expect(result.verified.map((entry) => entry.source)).toEqual(expected.sources); + expect(result.verified.every((entry) => entry.bytes === expected.byteLength)).toBe(true); + expect(result.verified.every((entry) => entry.frames === expected.frameCount)).toBe(true); + expect(result.sourceMismatch).toBe(expected.sourceMismatch); + expect(result.payloadMismatch).toBe(expected.payloadMismatch); + expect(result.versionMismatch).toBe(expected.versionMismatch); + expect(result.budgetExceeded).toBe(expected.budgetExceeded); +}); diff --git a/web/tests/e2e/physics-solver-probe.spec.ts b/web/tests/e2e/physics-solver-probe.spec.ts new file mode 100644 index 00000000..320aa495 --- /dev/null +++ b/web/tests/e2e/physics-solver-probe.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-13/physics-solver-probe.json"), "utf8")); + +test("M10-13 probes each Physics solver family and keeps failures bake-only", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/physics-solver-probe-test.worker.ts", { type: "module" }); + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + worker.postMessage({}); + })) as { + familyOrder: string[]; + defaultProbe: string[]; + probes: Record; + routes: Record; + localGate: { status: string; issues: number }; + fallbackGate: { status: string; code: string; message: string }; + }; + expect(result.familyOrder).toEqual(expected.familyOrder); + expect(result.defaultProbe).toEqual(expected.familyOrder.map(() => expected.defaultProbe)); + expect(result.probes).toEqual(expected.probes); + expect(result.routes.RIGID_BODY).toBe(expected.localRoute); + for (const family of expected.familyOrder.filter((family: string) => family !== "RIGID_BODY")) { + expect(result.routes[family]).toBe(expected.fallbackRoute); + } + expect(result.localGate).toEqual({ status: "READY", issues: 0 }); + expect(result.fallbackGate.status).toBe("BLOCKED"); + expect(result.fallbackGate.code).toBe(expected.fallbackErrorCode); + expect(result.fallbackGate.message).toContain("desktop/server bake"); +}); diff --git a/web/tests/e2e/recent-projects-recovery.spec.ts b/web/tests/e2e/recent-projects-recovery.spec.ts new file mode 100644 index 00000000..3286553a --- /dev/null +++ b/web/tests/e2e/recent-projects-recovery.spec.ts @@ -0,0 +1,198 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); + +test("M7-09 persists recent projects and restores UI state after a damaged-index Worker restart", async ({ page }) => { + await page.goto("/?worker-fault=storage"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const app = page.locator(".blender-app"); + + await page.getByTestId("blend-file-input").setInputFiles(basicBlend); + await expect(page.getByText("Cube", { exact: true })).toBeVisible(); + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await download; + await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); + + await page.getByRole("button", { name: "Modeling" }).click(); + await page.getByRole("slider", { name: "当前帧" }).fill("12"); + await expect(app).toHaveAttribute("data-current-frame", "12"); + await page.locator('.tree-row.child').filter({ hasText: "Camera" }).click(); + await page.locator('.tree-row.child').filter({ hasText: "Cube" }).click({ modifiers: ["Shift"] }); + const selectedBefore = await app.getAttribute("data-selected-object-ids"); + const revisionBefore = await app.getAttribute("data-current-main-revision"); + expect(selectedBefore?.split(",")).toHaveLength(2); + + await page.evaluate(async () => { + const database = await new Promise((resolve, reject) => { + const request = indexedDB.open("blender-web-metadata", 7); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const transaction = database.transaction("setting", "readwrite"); + transaction.objectStore("setting").put({ id: "recent-projects:v1", value: { schemaVersion: 1, projects: [{ broken: true }] } }); + await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + database.close(); + }); + + await page.getByTestId("inject-worker-crash").click(); + await expect(app).toHaveAttribute("data-worker-fault-source", "storage"); + await page.getByTestId("restart-and-recover").click(); + await expect(app).toHaveAttribute("data-worker-recovery-status", "SUCCEEDED", { timeout: 30_000 }); + await expect(app).toHaveAttribute("data-workspace", "Modeling"); + await expect(app).toHaveAttribute("data-current-frame", "12"); + await expect(app).toHaveAttribute("data-selected-object-ids", selectedBefore ?? ""); + await expect(app).toHaveAttribute("data-current-main-revision", revisionBefore ?? ""); + await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); + await expect(app).toHaveAttribute("data-recent-project-quarantined", "1"); + const persistedIndex = await page.evaluate(async () => { + const database = await new Promise((resolve, reject) => { + const request = indexedDB.open("blender-web-metadata", 7); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const transaction = database.transaction("setting", "readonly"); + const row = await new Promise<{ value?: { projects?: Array<{ projectId?: string }> } } | undefined>((resolve, reject) => { + const request = transaction.objectStore("setting").get("recent-projects:v1"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + database.close(); + return row?.value?.projects?.map((project) => project.projectId) ?? []; + }); + expect(persistedIndex).toEqual(["basic_scene"]); + + await page.reload(); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); + await page.getByTestId("recent-projects").selectOption("basic_scene"); + await expect(page.getByText("Cube", { exact: true })).toBeVisible(); + await expect(app).toHaveAttribute("data-project-id", "basic_scene"); + await expect(page.getByTestId("engine-status")).toContainText("Recovery:"); +}); + +test("M7-09 StorageClient keeps deterministic recent projects across Worker instances", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const base = { + schemaVersion: 1 as const, + revision: 1, + bytes: 8, + sha256: "a".repeat(64), + updatedAt: "2026-08-15T12:00:00.000Z", + backend: "unknown" as const, + }; + const first = new StorageClient(); + await Promise.all([ + first.touchRecentProject({ ...base, projectId: "older", displayName: "Older", lastOpenedAt: "2026-08-15T11:00:00.000Z" }), + first.touchRecentProject({ ...base, projectId: "newer", displayName: "Newer", lastOpenedAt: "2026-08-15T13:00:00.000Z" }), + first.touchRecentProject({ ...base, projectId: "older", displayName: "Older latest", revision: 2, lastOpenedAt: "2026-08-15T14:00:00.000Z" }), + ]); + first.terminate(); + const restarted = new StorageClient(); + const listed = await restarted.listRecentProjects(); + restarted.terminate(); + return listed; + }); + + expect(result.quarantined).toBe(0); + expect(result.projects.map(({ projectId, revision }) => [projectId, revision])).toEqual([["older", 2], ["newer", 1]]); +}); + +test("M7-10 isolates a missing recent project and removes only its reference", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const storage = new StorageClient(); + await storage.touchRecentProject({ + schemaVersion: 1, + projectId: "missing_recent", + displayName: "Missing recent.blend", + revision: 1, + bytes: 8, + sha256: "a".repeat(64), + updatedAt: "2026-08-15T12:00:00.000Z", + lastOpenedAt: "2026-08-15T12:00:00.000Z", + backend: "opfs", + }); + storage.terminate(); + }); + const app = page.locator(".blender-app"); + await page.getByTestId("blend-file-input").setInputFiles(basicBlend); + await expect(page.getByText("Cube", { exact: true })).toBeVisible(); + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await download; + await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); + await expect(app).toHaveAttribute("data-recent-project-invalid-count", "1"); + await expect(app).toHaveAttribute("data-recent-project-invalid-ids", "missing_recent"); + await expect(page.getByTestId("recent-project-repair-banner")).toContainText("项目内容缺失"); + await expect(page.locator('[data-testid="recent-project-repair-banner"] [data-issue-code="MISSING"]')).toBeVisible(); + + await page.getByTestId("remove-recent-project-missing_recent").click(); + await expect(app).toHaveAttribute("data-recent-project-invalid-count", "0"); + await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); + await expect(page.getByTestId("recent-project-repair-banner")).toHaveCount(0); + const listed = await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const storage = new StorageClient(); + const result = await storage.listRecentProjects(); + storage.terminate(); + return result; + }); + expect(listed.projects.map(({ projectId }) => projectId)).toEqual(["basic_scene"]); + expect(listed.issues).toEqual([]); +}); + +test("M7-10 isolates a hash-mismatched entry while retaining other recent projects", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const app = page.locator(".blender-app"); + await page.getByTestId("blend-file-input").setInputFiles(basicBlend); + await expect(page.getByText("Cube", { exact: true })).toBeVisible(); + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await download; + await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene"); + + await page.evaluate(async () => { + const database = await new Promise((resolve, reject) => { + const request = indexedDB.open("blender-web-metadata", 7); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const transaction = database.transaction("setting", "readwrite"); + const store = transaction.objectStore("setting"); + const row = await new Promise<{ value?: { projects?: Array> } } | undefined>((resolve, reject) => { + const request = store.get("recent-projects:v1"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const projects = (row?.value?.projects ?? []).map((project) => project.projectId === "basic_scene" ? { ...project, sha256: "f".repeat(64) } : project); + store.put({ id: "recent-projects:v1", value: { schemaVersion: 1, projects } }); + await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + database.close(); + }); + await page.reload(); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await expect(app).toHaveAttribute("data-recent-project-invalid-count", "1"); + await expect(app).toHaveAttribute("data-recent-project-invalid-ids", "basic_scene"); + await expect(app).toHaveAttribute("data-recent-project-ids", ""); + await expect(page.locator('[data-testid="recent-project-repair-banner"] [data-issue-code="HASH_MISMATCH"]')).toBeVisible(); + + await page.getByTestId("remove-recent-project-basic_scene").click(); + await expect(app).toHaveAttribute("data-recent-project-invalid-count", "0"); + await expect(app).toHaveAttribute("data-recent-project-ids", ""); + await expect(page.getByText("Cube", { exact: true })).toHaveCount(0); +}); diff --git a/web/tests/e2e/render-reference.spec.ts b/web/tests/e2e/render-reference.spec.ts new file mode 100644 index 00000000..0e74cfd9 --- /dev/null +++ b/web/tests/e2e/render-reference.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const goldenRoot = path.join(root, "tests/golden/M11-04"); +const manifest = JSON.parse(fs.readFileSync(path.join(goldenRoot, "manifest.json"), "utf8")); +const blend = fs.readFileSync(path.join(root, manifest.source.fixture)); +const referencePng = fs.readFileSync(path.join(goldenRoot, manifest.reference.file)); + +async function decodeAndCompare(page: import("@playwright/test").Page, reference: Buffer, actual: Buffer) { + return page.evaluate(async ({ referenceBytes, actualBytes, thresholds }) => { + const { compareRenderImages } = await import("/src/three-adapter/render-image-comparison.ts"); + const decode = async (bytes: number[]) => { + const bitmap = await createImageBitmap(new Blob([Uint8Array.from(bytes)], { type: "image/png" }), { + colorSpaceConversion: "none", + premultiplyAlpha: "none", + }); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("2D decode context is unavailable"); + context.drawImage(bitmap, 0, 0); + const pixels = new Uint8Array(context.getImageData(0, 0, bitmap.width, bitmap.height).data); + const result = { width: bitmap.width, height: bitmap.height, pixels }; + bitmap.close(); + return result; + }; + const [referenceFrame, actualFrame] = await Promise.all([decode(referenceBytes), decode(actualBytes)]); + if (referenceFrame.width !== actualFrame.width || referenceFrame.height !== actualFrame.height) { + throw new Error("render frame dimensions differ"); + } + return compareRenderImages( + referenceFrame.pixels, + actualFrame.pixels, + referenceFrame.width, + referenceFrame.height, + thresholds, + ); + }, { referenceBytes: Array.from(reference), actualBytes: Array.from(actual), thresholds: manifest.thresholds }); +} + +for (const offscreen of [false, true]) { + test(`M11-04 ${offscreen ? "Offscreen" : "main-thread"} production render matches the Blender reference metrics`, async ({ page }) => { + test.setTimeout(60_000); + await page.goto("/"); + await page.evaluate(async ({ input, useOffscreen }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts"); + const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts"); + document.body.replaceChildren(); + document.body.style.margin = "0"; + const canvas = document.createElement("canvas"); + canvas.id = "m11-reference-canvas"; + canvas.style.width = "256px"; + canvas.style.height = "256px"; + document.body.append(canvas); + const client = new WebEngineClient({ timeoutMs: 30_000 }); + const opened = await client.openBlend(Uint8Array.from(input).buffer); + opened.snapshot.activeObjectId = null; + const renderer = useOffscreen ? new OffscreenViewportRenderer(canvas) : new ViewportRenderer(canvas); + renderer.setSnapshot(opened.snapshot, opened.geometryBuffers, opened.nonMeshGeometryBuffers ?? []); + for (let attempt = 0; attempt < 300; attempt++) { + if (!useOffscreen || (Number(canvas.dataset.rendererPixels) > 0 && canvas.dataset.renderBudgetStatus === "ready")) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + (globalThis as typeof globalThis & { __m11Reference?: { renderer: { dispose(): void }; client: { terminate(): void } } }).__m11Reference = { renderer, client }; + }, { input: Array.from(blend), useOffscreen: offscreen }); + + const canvas = page.locator("#m11-reference-canvas"); + await expect(canvas).toHaveAttribute("data-render-budget-status", "ready"); + const actualPng = await canvas.screenshot({ animations: "disabled" }); + const report = await decodeAndCompare(page, referencePng, actualPng); + console.log(`m11-render-reference-${offscreen ? "offscreen" : "main"}`, JSON.stringify(report)); + expect(report.status).toBe("READY"); + expect(report.errorCode).toBeNull(); + expect(report.checks.every((item) => item.passed)).toBe(true); + + await page.evaluate(() => { + const state = (globalThis as typeof globalThis & { __m11Reference?: { renderer: { dispose(): void }; client: { terminate(): void } } }).__m11Reference; + state?.renderer.dispose(); + state?.client.terminate(); + delete (globalThis as typeof globalThis & { __m11Reference?: unknown }).__m11Reference; + }); + }); +} + +test("M11-04 rejects a non-empty frame with the wrong composition", async ({ page }) => { + await page.goto("/"); + const wrong = await page.evaluate(async () => { + const canvas = new OffscreenCanvas(256, 256); + const context = canvas.getContext("2d")!; + context.fillStyle = "rgb(58,58,58)"; + context.fillRect(0, 0, 256, 256); + context.fillStyle = "rgb(255,0,0)"; + context.fillRect(0, 0, 32, 32); + return Array.from(new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer())); + }); + const report = await decodeAndCompare(page, referencePng, Buffer.from(wrong)); + expect(report.status).toBe("BLOCKED"); + expect(report.errorCode).toBe("RENDER_REFERENCE_MISMATCH"); + expect(report.checks.some((item) => !item.passed)).toBe(true); +}); diff --git a/web/tests/e2e/render-resource-budget.spec.ts b/web/tests/e2e/render-resource-budget.spec.ts new file mode 100644 index 00000000..2b47d8eb --- /dev/null +++ b/web/tests/e2e/render-resource-budget.spec.ts @@ -0,0 +1,187 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const blend = fs.readFileSync(path.join(root, "tests/files/web/basic_scene.blend")); +const texturePng = fs.readFileSync(path.join(root, "tests/files/web/resources/udim_1001.png")); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-03/render-resource-budget.json"), "utf8")); + +for (const offscreen of [false, true]) { + test(`M11-03 ${offscreen ? "Offscreen" : "main-thread"} renderer enforces light, shadow and texture budgets`, async ({ page }) => { + test.setTimeout(60_000); + await page.goto("/"); + const result = await page.evaluate(async ({ input, useOffscreen, requestedLights }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts"); + const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts"); + const client = new WebEngineClient({ timeoutMs: 30_000 }); + const canvas = document.createElement("canvas"); + canvas.style.width = "256px"; + canvas.style.height = "256px"; + document.body.append(canvas); + const renderer = useOffscreen ? new OffscreenViewportRenderer(canvas) : new ViewportRenderer(canvas); + const waitFor = async (attribute: string): Promise => { + for (let attempt = 0; attempt < 300; attempt++) { + const value = canvas.getAttribute(attribute); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`${attribute} was not published`); + }; + try { + const opened = await client.openBlend(Uint8Array.from(input).buffer); + const snapshot = structuredClone(opened.snapshot); + const sourceNode = snapshot.nodes.find((node) => node.type === "LIGHT")!; + const sourceLight = snapshot.lights.find((light) => light.id === sourceNode.dataId)!; + snapshot.nodes = [ + ...snapshot.nodes.filter((node) => node.type !== "LIGHT"), + ...Array.from({ length: requestedLights }, (_, index) => ({ + ...sourceNode, + id: `object:M11BudgetLight${index.toString().padStart(2, "0")}`, + name: `M11BudgetLight${index.toString().padStart(2, "0")}`, + dataId: `light:M11BudgetLight${index.toString().padStart(2, "0")}`, + visible: true, + })), + ]; + snapshot.lights = Array.from({ length: requestedLights }, (_, index) => ({ + ...sourceLight, + id: `light:M11BudgetLight${index.toString().padStart(2, "0")}`, + name: `M11BudgetLight${index.toString().padStart(2, "0")}`, + lightType: 2, + castsShadow: true, + })); + renderer.setSnapshot(snapshot, [], []); + await waitFor("data-render-budget-status"); + + const textures = Array.from({ length: 257 }, (_, index) => ({ + schemaVersion: 1 as const, + assetId: `asset:m11-budget-${index}`, + imageId: `image:m11-budget-${index}`, + mimeType: "image/png", + width: 1, + height: 1, + usage: "BASE_COLOR" as const, + colorSpace: "SRGB" as const, + sha256: "0".repeat(64), + byteLength: 1, + data: Uint8Array.of(index & 0xff).buffer, + })); + renderer.setTextureAssets(textures); + await waitFor("data-texture-budget-status"); + + let actualLights: number | null = null; + let actualShadows: number | null = null; + if (!useOffscreen) { + actualLights = 0; + actualShadows = 0; + (renderer as InstanceType).scene.traverse((object) => { + if (!("isLight" in object) || !object.isLight) return; + actualLights! += 1; + if ("castShadow" in object && object.castShadow) actualShadows! += 1; + }); + } + return { + render: { + backend: canvas.dataset.renderBudgetBackend, + status: canvas.dataset.renderBudgetStatus, + code: canvas.dataset.renderBudgetCode, + requestedLights: Number(canvas.dataset.renderBudgetLights), + renderedLights: Number(canvas.dataset.renderBudgetRenderedLights), + droppedLights: Number(canvas.dataset.renderBudgetDroppedLights), + requestedShadows: Number(canvas.dataset.renderBudgetShadows), + renderedShadows: Number(canvas.dataset.renderBudgetRenderedShadows), + blockedShadows: Number(canvas.dataset.renderBudgetBlockedShadows), + dimension: Number(canvas.dataset.renderBudgetShadowMapDimension), + }, + texture: { + status: canvas.dataset.textureBudgetStatus, + code: canvas.dataset.textureBudgetCode, + assets: Number(canvas.dataset.textureBudgetAssets), + loaded: Number(canvas.dataset.textureLoaded), + bytes: Number(canvas.dataset.textureBytes), + }, + actualLights, + actualShadows, + }; + } + finally { + renderer.dispose(); + client.terminate(); + canvas.remove(); + } + }, { input: Array.from(blend), useOffscreen: offscreen, requestedLights: golden.overflow.requestedLights }); + + expect(result.render).toEqual({ + backend: "THREE_WEBGL2", + status: "blocked", + code: "GPU_LIGHT_BUDGET_EXCEEDED", + requestedLights: golden.overflow.requestedLights, + renderedLights: golden.overflow.renderedLights, + droppedLights: golden.overflow.droppedLights, + requestedShadows: golden.overflow.requestedShadowMaps, + renderedShadows: golden.overflow.renderedShadowMaps, + blockedShadows: golden.overflow.blockedShadowMaps, + dimension: golden.webgl2.shadowMapDimension, + }); + expect(result.texture).toEqual({ + status: "blocked", + code: "GPU_TEXTURE_BUDGET_EXCEEDED", + assets: 257, + loaded: 0, + bytes: 0, + }); + if (!offscreen) { + expect(result.actualLights).toBe(golden.webgl2.maxLights); + expect(result.actualShadows).toBe(golden.webgl2.maxShadowMaps); + } + }); +} + +test("M11-03 keeps the previous texture set when an aggregate batch is over budget", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async (input) => { + const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts"); + const store = new GPUTextureStore("THREE_WEBGL2"); + try { + const data = Uint8Array.from(input).buffer; + const digest = await crypto.subtle.digest("SHA-256", data); + const kept = { + schemaVersion: 1 as const, + assetId: "asset:kept", + imageId: "image:kept", + mimeType: "image/png", + width: 8, + height: 8, + usage: "BASE_COLOR", + colorSpace: "SRGB", + byteLength: data.byteLength, + sha256: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""), + data, + } as const; + const accepted = await store.upload([kept]); + const before = store.getAsset("image:kept", "BASE_COLOR"); + const overBudget = Array.from({ length: 257 }, (_, index) => ({ + ...kept, + assetId: `asset:overflow:${index}`, + imageId: `image:overflow:${index}`, + data: kept.data.slice(0), + })); + const blocked = await store.upload(overBudget); + const after = store.getAsset("image:kept", "BASE_COLOR"); + return { + accepted: [accepted.loaded, accepted.rejected, accepted.budget.status], + blocked: [blocked.loaded, blocked.rejected, blocked.budget.status, blocked.budget.requestedAssets, blocked.errorCodes[0]], + retained: before === after && after?.sha256 === kept.sha256, + revision: store.getRevision(), + }; + } + finally { + store.dispose(); + } + }, Array.from(texturePng)); + expect(result.accepted).toEqual([1, 0, "READY"]); + expect(result.blocked).toEqual([0, 257, "BLOCKED", 258, "GPU_TEXTURE_BUDGET_EXCEEDED"]); + expect(result.retained).toBe(true); + expect(result.revision).toBe(2); +}); diff --git a/web/tests/e2e/render-routing.spec.ts b/web/tests/e2e/render-routing.spec.ts new file mode 100644 index 00000000..e7a73578 --- /dev/null +++ b/web/tests/e2e/render-routing.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const blend = fs.readFileSync(path.join(root, "tests/files/web/m11_render_reference.blend")); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-05/render-routing.json"), "utf8")); + +test("M11-05 exposes fail-closed render routing for Web, server and hardware capabilities", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async (input) => { + const { routeRenderExecution } = await import("/src/three-adapter/render-routing.ts"); + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const client = new WebEngineClient({ timeoutMs: 30_000 }); + try { + const opened = await client.openBlend(Uint8Array.from(input).buffer); + const sourceRenderEngine = opened.snapshot.scenes[0]?.renderEngine; + if (sourceRenderEngine !== "BLENDER_EEVEE" && sourceRenderEngine !== "BLENDER_EEVEE_NEXT") throw new Error(`Unexpected fixture render engine: ${sourceRenderEngine}`); + const base = { schemaVersion: 1 as const, renderEngine: sourceRenderEngine, backend: "WEBGL2" as const, complexity: "BOUNDED" as const }; + const bounded = routeRenderExecution(base); + const cycles = routeRenderExecution({ ...base, renderEngine: "BLENDER_CYCLES", backend: "CYCLES" }); + const complex = routeRenderExecution({ ...base, backend: "EEVEE_COMPLEX", complexity: "COMPLEX" }); + const hardware = routeRenderExecution({ ...base, hardwareBackend: "OPTIX" }); + const availableServer = routeRenderExecution({ ...base, renderEngine: "BLENDER_CYCLES", backend: "CYCLES" }, { serverRenderAvailable: true }); + const unavailableGpu = routeRenderExecution({ ...base, backend: "WEBGPU" }); + const unknownEngine = routeRenderExecution({ ...base, renderEngine: "UNKNOWN_ENGINE" }); + return { sourceRenderEngine, bounded, cycles, complex, hardware, availableServer, unavailableGpu, unknownEngine }; + } + finally { client.terminate(); } + }, Array.from(blend)); + + expect(result.sourceRenderEngine).toBe("BLENDER_EEVEE"); + expect(result.bounded).toMatchObject(golden.boundedEevee); + for (const serverResult of [result.cycles, result.complex, result.hardware]) { + expect(serverResult).toMatchObject({ target: "SERVER_JOB", status: "BLOCKED" }); + expect(serverResult.issues[0].code).toBe("SERVER_JOB_UNAVAILABLE"); + } + expect(result.availableServer).toMatchObject({ target: golden.cycles.target, ...golden.cycles.withEndpoint }); + expect(result.unavailableGpu).toMatchObject({ target: golden.webgpu.target, status: golden.webgpu.status }); + expect(result.unavailableGpu.issues[0].code).toBe(golden.webgpu.code); + expect(result.unknownEngine).toMatchObject({ target: golden.unknownEngine.target, status: golden.unknownEngine.status, capability: golden.unknownEngine.capability }); + expect(result.unknownEngine.issues[0].code).toBe(golden.unknownEngine.code); +}); diff --git a/web/tests/e2e/responsive-layout.spec.ts b/web/tests/e2e/responsive-layout.spec.ts new file mode 100644 index 00000000..f296c0e4 --- /dev/null +++ b/web/tests/e2e/responsive-layout.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +const viewports = [ + { name: "desktop-1440x900", width: 1440, height: 900 }, + { name: "desktop-1280x720", width: 1280, height: 720 }, + { name: "mobile-narrow", width: 360, height: 640 }, + { name: "mobile-extra-narrow", width: 320, height: 568 }, +] as const; + +test.describe("M7-14 responsive layout", () => { + for (const viewport of viewports) { + test(`${viewport.name} keeps controls inside their layout bands`, async ({ page }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + + const report = await page.evaluate(() => { + const visible = (element: Element): element is HTMLElement => { + const node = element as HTMLElement; + const style = getComputedStyle(node); + return style.display !== "none" && style.visibility !== "hidden" && node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0; + }; + const bands = [".topbar", ".workspace-toolbar", ".storage-budget-panel", ".status-bar"] + .map((selector) => document.querySelector(selector)) + .filter((element): element is HTMLElement => Boolean(element) && visible(element)); + const bandViolations = bands.flatMap((band) => { + const bandRect = band.getBoundingClientRect(); + const horizontalConstrained = !["auto", "scroll", "hidden"].includes(getComputedStyle(band).overflowX); + return [...band.querySelectorAll("button, input, select, output, span")] + .filter(visible) + .filter((control) => { + const rect = control.getBoundingClientRect(); + return rect.top < bandRect.top - 1 || rect.bottom > bandRect.bottom + 1 || (horizontalConstrained && (rect.left < bandRect.left - 1 || rect.right > bandRect.right + 1)); + }) + .map((control) => `${band.className}:${control.textContent?.trim() || control.getAttribute("aria-label") || control.tagName}`); + }); + const textOverflow = [...document.querySelectorAll(".topbar button, .topbar select, .workspace-toolbar button, .workspace-toolbar > span, .storage-budget-panel span, .storage-budget-panel output, .editor-header button")] + .filter(visible) + .filter((element) => element.scrollWidth > element.clientWidth + 1) + .map((element) => element.textContent?.trim() || element.getAttribute("aria-label") || element.tagName); + const root = document.documentElement; + return { bandViolations, textOverflow, rootScrollWidth: root.scrollWidth, viewportWidth: window.innerWidth }; + }); + + expect(report.bandViolations, JSON.stringify(report)).toEqual([]); + expect(report.textOverflow, JSON.stringify(report)).toEqual([]); + expect(report.rootScrollWidth, JSON.stringify(report)).toBeLessThanOrEqual(report.viewportWidth); + }); + } +}); diff --git a/web/tests/e2e/sequencer-audio-recovery.spec.ts b/web/tests/e2e/sequencer-audio-recovery.spec.ts new file mode 100644 index 00000000..521ed896 --- /dev/null +++ b/web/tests/e2e/sequencer-audio-recovery.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-13/sequencer-audio-recovery.json"), "utf8")) as { + outputGain: number; + lifecycle: Array<[string, string, boolean, number, string | null]>; + missingDevice: [string, string, string]; +}; + +test("M11-13 suspends, resumes, mutes, recovers, and closes a Chromium AudioContext", async ({ page }) => { + await page.goto("/"); + await page.evaluate((outputGain) => { + const button = document.createElement("button"); + button.id = "m11-audio-user-gesture"; + button.textContent = "Start audio test"; + document.body.append(button); + const result = new Promise((resolve, reject) => { + button.addEventListener("click", async () => { + try { + const { SequencerAudioSession } = await import("/src/sequencer/SequencerAudioSession.ts"); + const summarize = (report: import("/src/sequencer/SequencerAudioSession.ts").SequencerAudioSessionReportIR) => [ + report.contextState, + report.outputState, + report.muted, + report.outputGain, + report.issueCode, + ]; + const supported = "AudioContext" in globalThis; + const session = new SequencerAudioSession({ outputGain }); + const initialized = await session.initialize(); + const lifecycle = []; + lifecycle.push(summarize(await session.suspend())); + lifecycle.push(summarize(session.setMuted(true))); + lifecycle.push(summarize(await session.resume())); + lifecycle.push(summarize(session.setMuted(false))); + lifecycle.push(summarize(await session.suspend())); + lifecycle.push(summarize(await session.resume())); + lifecycle.push(summarize(await session.close())); + + const missingSession = new SequencerAudioSession({ scope: {}, outputGain }); + const missing = await missingSession.initialize(); + const missingDevice = [missing.contextState, missing.outputState, missing.issueCode]; + const missingClosed = await missingSession.close(); + resolve({ supported, initialized, lifecycle, missingDevice, missingClosed }); + } + catch (error) { + reject(error); + } + }, { once: true }); + }); + (globalThis as typeof globalThis & { __m11AudioResult?: Promise }).__m11AudioResult = result; + }, golden.outputGain); + + await page.locator("#m11-audio-user-gesture").click(); + const result = await page.evaluate(() => + (globalThis as typeof globalThis & { __m11AudioResult: Promise }).__m11AudioResult) as { + supported: boolean; + initialized: { contextState: string; issueCode: string | null }; + lifecycle: Array<[string, string, boolean, number, string | null]>; + missingDevice: [string, string, string]; + missingClosed: { contextState: string; outputState: string; outputGain: number }; + }; + + expect(result.supported).toBe(true); + expect(["RUNNING", "SUSPENDED"]).toContain(result.initialized.contextState); + expect(result.initialized.issueCode).toBeNull(); + expect(result.lifecycle).toEqual(golden.lifecycle); + expect(result.missingDevice).toEqual(golden.missingDevice); + expect(result.missingClosed).toMatchObject({ contextState: "CLOSED", outputState: "SILENT", outputGain: 0 }); +}); diff --git a/web/tests/e2e/sequencer-codec-probe.spec.ts b/web/tests/e2e/sequencer-codec-probe.spec.ts new file mode 100644 index 00000000..65193ed3 --- /dev/null +++ b/web/tests/e2e/sequencer-codec-probe.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from "@playwright/test"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as { + assets: Array<{ + stripType: "IMAGE" | "SOUND" | "MOVIE"; + mimeType: string; + path: string; + sourcePath: string; + byteLength: number; + sha256: string; + backend: string; + decoded: Record; + }>; + movieGenerator: { path: string; sha256: string }; + blockedCode: string; +}; + +test("M11-09 probes IMAGE, SOUND and MOVIE bytes at runtime without extension guessing", async ({ page }) => { + const assets = golden.assets.map((asset) => { + const bytes = fs.readFileSync(path.join(root, asset.path)); + expect(bytes.byteLength).toBe(asset.byteLength); + expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(asset.sha256); + return { ...asset, bytes: new Uint8Array(bytes) }; + }); + expect(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, golden.movieGenerator.path))).digest("hex")) + .toBe(golden.movieGenerator.sha256); + await page.goto("/"); + const result = await page.evaluate(async ({ assets }) => { + const [runtime, protocol] = await Promise.all([ + import("/src/sequencer/SequencerCodecProbe.ts"), + import("/src/sequencer/SequencerTimeline.ts"), + ]); + const ready = []; + for (const asset of assets) { + const request = runtime.createSequencerCodecProbeRequest(asset.stripType, asset.mimeType, asset.byteLength, asset.sha256); + const receipt = await runtime.probeSequencerCodec(request, asset.bytes.buffer); + const gate = protocol.gateSequencerCodec(request, receipt); + ready.push({ sourcePath: asset.sourcePath, receipt, gate: gate.status }); + } + + const source = assets[0]; + const corrupted = source.bytes.slice(); + corrupted.fill(0, 0, Math.min(32, corrupted.length)); + const corruptedHash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", corrupted.buffer)), + (byte) => byte.toString(16).padStart(2, "0")).join(""); + const corruptRequest = runtime.createSequencerCodecProbeRequest("IMAGE", "image/png", corrupted.byteLength, corruptedHash); + const corruptReceipt = await runtime.probeSequencerCodec(corruptRequest, corrupted.buffer); + const mismatchRequest = runtime.createSequencerCodecProbeRequest("IMAGE", "image/png", source.byteLength, source.sha256); + const mismatchReceipt = await runtime.probeSequencerCodec(mismatchRequest, corrupted.buffer); + const spoofRequest = runtime.createSequencerCodecProbeRequest("MOVIE", "video/mp4", source.byteLength, source.sha256); + const spoofReceipt = await runtime.probeSequencerCodec(spoofRequest, source.bytes.buffer); + const forgedReceipt = { ...ready[0].receipt, sourceSha256: "f".repeat(64) }; + const forgedGate = protocol.gateSequencerCodec(mismatchRequest, forgedReceipt); + return { + ready, + corrupt: { status: corruptReceipt.status, reason: corruptReceipt.reason }, + mismatch: { status: mismatchReceipt.status, reason: mismatchReceipt.reason }, + spoof: { status: spoofReceipt.status, reason: spoofReceipt.reason }, + forged: { status: forgedGate.status, code: forgedGate.issues[0]?.code }, + }; + }, { assets }); + + expect(result.ready.map((item) => ({ + sourcePath: item.sourcePath, + status: item.receipt.status, + backend: item.receipt.backend, + decoded: item.receipt.decoded, + gate: item.gate, + }))).toEqual(golden.assets.map((asset) => ({ + sourcePath: asset.sourcePath, + status: "READY", + backend: asset.backend, + decoded: asset.decoded, + gate: "READY", + }))); + expect(result.corrupt).toEqual({ status: "BLOCKED", reason: "DECODE_FAILED" }); + expect(result.mismatch).toEqual({ status: "BLOCKED", reason: "SOURCE_IDENTITY_MISMATCH" }); + expect(result.spoof.status).toBe("BLOCKED"); + expect(result.forged).toEqual({ status: "BLOCKED", code: golden.blockedCode }); +}); diff --git a/web/tests/e2e/sequencer-final-export.spec.ts b/web/tests/e2e/sequencer-final-export.spec.ts new file mode 100644 index 00000000..46a48100 --- /dev/null +++ b/web/tests/e2e/sequencer-final-export.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const blend = fs.readFileSync(path.join(root, "tests/files/web/sequencer_scene.blend")); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-12/sequencer-final-export.json"), "utf8")) as { + sourceBlendSha256: string; + settingsSha256: string; + requestSha256: string; +}; + +test("M11-12 routes a real Main timeline to server export even when VideoEncoder exists", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async (input) => { + const [{ WebEngineClient }, finalExport] = await Promise.all([ + import("/src/engine-client/WebEngineClient.ts"), + import("/src/sequencer/SequencerFinalExport.ts"), + ]); + const source = Uint8Array.from(input).buffer; + const sourceBlendSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", source)), (byte) => + byte.toString(16).padStart(2, "0")).join(""); + const client = new WebEngineClient({ timeoutMs: 20_000 }); + try { + const opened = await client.openBlend(source); + const timeline = opened.snapshot.scenes.find((scene) => scene.name === "SequencerScene")?.sequencerTimeline; + if (!timeline) throw new Error("Real Sequencer Main timeline is missing"); + const request = { + schemaVersion: 1 as const, + timelineId: timeline.id, + timelineRevision: timeline.revision, + sourceBlendSha256, + frameStart: timeline.frameStart, + frameEnd: timeline.frameEnd, + fpsNumerator: timeline.fpsNumerator, + fpsDenominator: timeline.fpsDenominator, + width: 1920, + height: 1080, + container: "MPEG4" as const, + videoCodec: "H264" as const, + audioCodec: "AAC" as const, + }; + const withoutServer = await finalExport.routeSequencerFinalExportInBrowser(request, false, {}); + const serverWithoutEncoder = await finalExport.routeSequencerFinalExportInBrowser(request, true, {}); + const serverWithEncoder = await finalExport.routeSequencerFinalExportInBrowser( + request, + true, + { VideoEncoder: class TestVideoEncoder {} }, + ); + const runtime = await finalExport.routeSequencerFinalExportInBrowser(request, true); + return { timeline, sourceBlendSha256, withoutServer, serverWithoutEncoder, serverWithEncoder, runtime }; + } + finally { + client.terminate(); + } + }, Array.from(blend)); + + expect(result.timeline).toMatchObject({ + id: "sequencer:scene:SequencerScene", + revision: 1, + frameStart: 1, + frameEnd: 250, + fpsNumerator: 24000, + fpsDenominator: 1001, + }); + expect(result.sourceBlendSha256).toBe(golden.sourceBlendSha256); + expect(result.withoutServer).toMatchObject({ + requestSha256: golden.requestSha256, + settingsSha256: golden.settingsSha256, + route: "SERVER_EXPORT", + status: "BLOCKED", + code: "SEQUENCER_EXPORT_SERVER_UNAVAILABLE", + localEncoding: "BLOCKED", + browserVideoEncoderDetected: false, + }); + expect(result.serverWithoutEncoder).toMatchObject({ + requestSha256: golden.requestSha256, + settingsSha256: golden.settingsSha256, + route: "SERVER_EXPORT", + status: "SERVER_EXPORT_REQUIRED", + code: null, + localEncoding: "BLOCKED", + browserVideoEncoderDetected: false, + }); + expect(result.serverWithEncoder).toMatchObject({ + ...result.serverWithoutEncoder, + browserVideoEncoderDetected: true, + }); + expect(result.runtime).toMatchObject({ + route: "SERVER_EXPORT", + status: "SERVER_EXPORT_REQUIRED", + localEncoding: "BLOCKED", + }); +}); diff --git a/web/tests/e2e/sequencer-media-cache.spec.ts b/web/tests/e2e/sequencer-media-cache.spec.ts new file mode 100644 index 00000000..f1689078 --- /dev/null +++ b/web/tests/e2e/sequencer-media-cache.spec.ts @@ -0,0 +1,132 @@ +import { expect, test } from "@playwright/test"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const codecGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as { + assets: Array<{ + stripType: "IMAGE" | "SOUND" | "MOVIE"; + mimeType: string; + path: string; + byteLength: number; + sha256: string; + decoded: Record; + }>; +}; +const cacheGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-10/sequencer-media-cache.json"), "utf8")) as { + sourceSha256: string; + profile: { kind: "MOVIE_RGBA8_FRAME"; width: number; height: number; colorSpace: "SRGB8"; alphaMode: "STRAIGHT" }; + identitySha256: string; + proxyByteLength: number; +}; + +test("M11-10 binds a real movie proxy cache to source hash and runtime decode capability", async ({ page }) => { + const movie = codecGolden.assets.find((asset) => asset.stripType === "MOVIE")!; + const bytes = fs.readFileSync(path.join(root, movie.path)); + expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(cacheGolden.sourceSha256); + await page.goto("/"); + const result = await page.evaluate(async ({ movie, profile, bytes }) => { + const [probeModule, cacheModule, storageModule] = await Promise.all([ + import("/src/sequencer/SequencerCodecProbe.ts"), + import("/src/sequencer/SequencerMediaProxyCache.ts"), + import("/src/storage/StorageClient.ts"), + ]); + const request = probeModule.createSequencerCodecProbeRequest("MOVIE", movie.mimeType, movie.byteLength, movie.sha256); + const sourceData = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const capability = await probeModule.probeSequencerCodec(request, sourceData.slice(0)); + const generated = await cacheModule.generateInitialSequencerMovieProxyFrame(request, capability, profile, sourceData.slice(0)); + const cache = new cacheModule.SequencerMediaProxyCache(generated.data.byteLength); + const firstKey = await cache.put(generated.manifest, generated.data.slice(0), request, capability); + const hit = await cache.get(request, capability, profile, 0); + const secondManifest = await import("/src/sequencer/SequencerTimeline.ts").then((module) => + module.createSequencerMediaCacheManifest(request, capability, profile, 1, generated.data.slice(0))); + const secondKey = await cache.put(secondManifest, generated.data.slice(0), request, capability); + const evicted = await cache.get(request, capability, profile, 0); + + const projectId = `m11-10-${Date.now()}`; + const writer = new storageModule.StorageClient(); + const storedPayload = await writer.putAsset( + projectId, + generated.data.slice(0), + "application/vnd.blender.sequencer-proxy-rgba8", + "cache/sequencer/proxy-frame-0.rgba8", + ); + const manifestData = new TextEncoder().encode(JSON.stringify(generated.manifest)).buffer as ArrayBuffer; + const storedManifest = await writer.putAsset( + projectId, + manifestData, + "application/vnd.blender.sequencer-proxy-cache+json", + "cache/sequencer/proxy-frame-0.json", + ); + writer.terminate(); + + const reader = new storageModule.StorageClient(); + const [reopenedPayload, reopenedManifestAsset] = await Promise.all([ + reader.readAsset(projectId, storedPayload.sha256), + reader.readAsset(projectId, storedManifest.sha256), + ]); + reader.terminate(); + const reopenedManifest = JSON.parse(new TextDecoder().decode(reopenedManifestAsset.data)); + const verified = await import("/src/sequencer/SequencerTimeline.ts").then((module) => + module.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, request, capability)); + + const changedSource = { ...request, sourceSha256: "e".repeat(64) }; + const changedSourceCapability = { ...capability, sourceSha256: changedSource.sourceSha256 }; + const changedCapability = { + ...capability, + decoded: { ...capability.decoded!, durationMicros: capability.decoded!.durationMicros! + 1 }, + }; + const corruptPayload = reopenedPayload.data.slice(0); + new Uint8Array(corruptPayload)[0] ^= 0xff; + const code = async (operation: () => Promise): Promise => { + try { await operation(); return "unexpected-success"; } + catch (error) { return (error as { code?: string }).code ?? String(error); } + }; + const protocol = await import("/src/sequencer/SequencerTimeline.ts"); + const errors = { + source: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, changedSource, changedSourceCapability)), + capability: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, request, changedCapability)), + payload: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, corruptPayload, request, capability)), + sourceBytes: await code(() => cacheModule.generateInitialSequencerMovieProxyFrame(request, capability, profile, new ArrayBuffer(request.byteLength))), + }; + const statsBeforeClear = cache.stats(); + const releasedBytes = cache.clear(); + return { + capability, + manifest: generated.manifest, + firstKey, + secondKey, + hitBytes: hit?.data.byteLength, + evicted: evicted === undefined, + statsBeforeClear, + releasedBytes, + statsAfterClear: cache.stats(), + storedPayloadSha256: storedPayload.sha256, + verifiedIdentity: verified.identitySha256, + errors, + }; + }, { movie, profile: cacheGolden.profile, bytes: new Uint8Array(bytes) }); + + expect(result.capability).toMatchObject({ status: "READY", backend: "HTML_MEDIA", decoded: movie.decoded }); + expect(result.manifest).toMatchObject({ + identitySha256: cacheGolden.identitySha256, + payloadByteLength: cacheGolden.proxyByteLength, + profile: cacheGolden.profile, + }); + expect(result.manifest.payloadSha256).toBe(result.storedPayloadSha256); + expect(result.verifiedIdentity).toBe(cacheGolden.identitySha256); + expect(result.firstKey).toBe(`sequencer-media-cache:v1:${cacheGolden.identitySha256}`); + expect(result.secondKey).not.toBe(result.firstKey); + expect(result.hitBytes).toBe(cacheGolden.proxyByteLength); + expect(result.evicted).toBe(true); + expect(result.statsBeforeClear).toMatchObject({ entries: 1, bytes: cacheGolden.proxyByteLength, hits: 1, misses: 1, evictions: 1 }); + expect(result.releasedBytes).toBe(cacheGolden.proxyByteLength); + expect(result.statsAfterClear).toMatchObject({ entries: 0, bytes: 0 }); + expect(result.errors).toEqual({ + source: "SEQUENCER_CACHE_SOURCE_MISMATCH", + capability: "SEQUENCER_CACHE_CAPABILITY_MISMATCH", + payload: "SEQUENCER_CACHE_HASH_MISMATCH", + sourceBytes: "SEQUENCER_CACHE_SOURCE_MISMATCH", + }); +}); diff --git a/web/tests/e2e/sequencer-media-revision.spec.ts b/web/tests/e2e/sequencer-media-revision.spec.ts new file mode 100644 index 00000000..b45487e7 --- /dev/null +++ b/web/tests/e2e/sequencer-media-revision.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const codecGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as { + assets: Array<{ stripType: "IMAGE" | "SOUND" | "MOVIE"; mimeType: string; path: string; byteLength: number; sha256: string }>; +}; +const revisionGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-11/sequencer-media-revision.json"), "utf8")) as { + sourceSha256: string; + decisions: Array<[string, number, "PUBLISH" | "STALE", string | null]>; + published: string[]; + cacheWrites: string[]; +}; + +test("M11-11 gates late seek, scrub and real decode results before publish or cache", async ({ page }) => { + const movie = codecGolden.assets.find((asset) => asset.stripType === "MOVIE")!; + const bytes = fs.readFileSync(path.join(root, movie.path)); + expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(revisionGolden.sourceSha256); + await page.goto("/"); + const result = await page.evaluate(async ({ movie, bytes }) => { + const [probeModule, revisionModule] = await Promise.all([ + import("/src/sequencer/SequencerCodecProbe.ts"), + import("/src/sequencer/SequencerMediaRevisionGate.ts"), + ]); + const gate = new revisionModule.SequencerMediaRevisionGate("sequencer:main", 7); + const published: string[] = []; + const cacheWrites: string[] = []; + const decisions: Array<[string, number, string, string | null]> = []; + const complete = ( + request: import("/src/sequencer/SequencerMediaRevisionGate.ts").SequencerMediaRevisionRequestIR, + payloadSha256 = movie.sha256, + ) => ({ ...request, status: "COMPLETED" as const, sourceFrame: request.frame, payloadSha256 }); + const resolve = ( + request: import("/src/sequencer/SequencerMediaRevisionGate.ts").SequencerMediaRevisionRequestIR, + response = complete(request), + ) => { + const decision = gate.resolve(request, response, (accepted) => { + const label = `${accepted.operation}@${accepted.requestRevision}`; + published.push(label); + if (accepted.operation === "DECODE") cacheWrites.push(label); + }); + decisions.push([decision.operation, decision.requestRevision, decision.status, decision.code]); + return decision; + }; + + const oldSeek = gate.begin("SEEK", 10); + const oldSeekResult = new Promise>((resolveResult) => + setTimeout(() => resolveResult(complete(oldSeek)), 30)); + const currentScrub = gate.begin("SCRUB", 20); + resolve(currentScrub); + resolve(oldSeek, await oldSeekResult); + + const oldDecode = gate.begin("DECODE", 30); + const request = probeModule.createSequencerCodecProbeRequest("MOVIE", movie.mimeType, movie.byteLength, movie.sha256); + const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const lateDecode = new Promise>>((resolveReceipt) => + setTimeout(() => { void probeModule.probeSequencerCodec(request, source.slice(0)).then(resolveReceipt); }, 20)); + const replacedState = gate.replaceTimeline("sequencer:main", 8); + const lateReceipt = await lateDecode; + resolve(oldDecode, complete(oldDecode, lateReceipt.sourceSha256)); + + const currentDecode = gate.begin("DECODE", 40); + const currentReceipt = await probeModule.probeSequencerCodec(request, source.slice(0)); + resolve(currentDecode, complete(currentDecode, currentReceipt.sourceSha256)); + + const forgedSeek = gate.begin("SEEK", 50); + resolve(forgedSeek, { ...complete(forgedSeek), requestId: "media:forged" }); + + let monotonicCode = ""; + try { gate.replaceTimeline("sequencer:main", 8); } + catch (error) { monotonicCode = (error as { code?: string }).code ?? String(error); } + return { + decisions, + published, + cacheWrites, + replacedState, + finalState: gate.state(), + lateReceipt: { status: lateReceipt.status, backend: lateReceipt.backend }, + currentReceipt: { status: currentReceipt.status, backend: currentReceipt.backend }, + monotonicCode, + }; + }, { movie, bytes: new Uint8Array(bytes) }); + + expect(result.decisions).toEqual(revisionGolden.decisions); + expect(result.published).toEqual(revisionGolden.published); + expect(result.cacheWrites).toEqual(revisionGolden.cacheWrites); + expect(result.replacedState).toEqual({ schemaVersion: 1, timelineId: "sequencer:main", timelineRevision: 8, latestRequestRevision: 4 }); + expect(result.finalState).toEqual({ schemaVersion: 1, timelineId: "sequencer:main", timelineRevision: 8, latestRequestRevision: 6 }); + expect(result.lateReceipt).toEqual({ status: "READY", backend: "HTML_MEDIA" }); + expect(result.currentReceipt).toEqual({ status: "READY", backend: "HTML_MEDIA" }); + expect(result.monotonicCode).toBe("REVISION_CONFLICT"); +}); diff --git a/web/tests/e2e/shader-capability-block.spec.ts b/web/tests/e2e/shader-capability-block.spec.ts new file mode 100644 index 00000000..30fd12d7 --- /dev/null +++ b/web/tests/e2e/shader-capability-block.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-10/shader-capability-block.json"), "utf8")); + +test("M10-10 returns a stable block for every unknown Shader node", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const client = new WebEngineClient({ timeoutMs: 30_000 }); + await client.init(); + const first = await client.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["VORONOI", "CUSTOM_OSL"] }); + const second = await client.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["CUSTOM_OSL", "VORONOI"] }); + client.terminate(); + return { + first: { taskId: first.taskId, capability: first.capability, status: first.status, code: first.issues[0]?.code, recoverable: first.issues[0]?.recoverable, message: first.issues[0]?.message }, + second: { taskId: second.taskId, capability: second.capability, status: second.status, code: second.issues[0]?.code, recoverable: second.issues[0]?.recoverable, message: second.issues[0]?.message }, + }; + }); + expect(result.first.taskId).toBe("PBR-012"); + expect(result.first.capability).toBe(expected.capability); + expect(result.first.status).toBe(expected.status); + expect(result.first.code).toBe(expected.errorCode); + expect(result.first.recoverable).toBe(expected.recoverable); + expect(result.first.message).toContain("VORONOI"); + expect(result.first.message).toContain("CUSTOM_OSL"); + expect(result.second).toEqual(result.first); +}); diff --git a/web/tests/e2e/shader-compile-key.spec.ts b/web/tests/e2e/shader-compile-key.spec.ts new file mode 100644 index 00000000..b99a1312 --- /dev/null +++ b/web/tests/e2e/shader-compile-key.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-08/shader-compile-key.json"), "utf8")); + +test("M10-08 compileKey changes with texture identity and color space", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts"); + const source = { + id: "material:key", + name: "Key", + baseColor: [0.2, 0.3, 0.4, 1] as [number, number, number, number], + roughness: 0.5, + metallic: 0, + emissionColor: [0, 0, 0, 1] as [number, number, number, number], + alpha: 1, + ior: 1.45, + nodes: [ + { id: "image", type: "IMAGE_TEXTURE" as const, name: "Image", imageId: "image:key" }, + { id: "normal", type: "NORMAL_MAP" as const, name: "Normal" }, + { id: "principled", type: "PRINCIPLED" as const, name: "Principled" }, + { id: "output", type: "OUTPUT" as const, name: "Output" }, + ], + links: [ + { fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" }, + { fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" }, + { fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }, + ], + }; + const identity = { assetId: "asset:key-v1", sha256: "d".repeat(64), colorSpace: "NON_COLOR" as const }; + const first = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", identity]]) }); + const changed = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", { ...identity, sha256: "e".repeat(64) }]]) }); + const linear = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", { ...identity, colorSpace: "LINEAR" as const }]]) }); + const firstReport = first.userData.shaderCompile; + const changedReport = changed.userData.shaderCompile; + const linearReport = linear.userData.shaderCompile; + first.dispose(); changed.dispose(); linear.dispose(); + return { status: firstReport.status, key: firstReport.compileKey, changed: changedReport.compileKey, linear: linearReport.compileKey }; + }); + expect(result.status).toBe("COMPILED"); + expect(result.key).toMatch(/^[0-9a-f]{64}$/); + expect(result.changed).not.toBe(result.key); + expect(result.linear).not.toBe(result.key); + expect(expected.keyDigest).toBe("sha256"); +}); diff --git a/web/tests/e2e/shader-compile.spec.ts b/web/tests/e2e/shader-compile.spec.ts new file mode 100644 index 00000000..f599e13c --- /dev/null +++ b/web/tests/e2e/shader-compile.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from "@playwright/test"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-07/shader-compile.json"), "utf8")); +const blendBytes = fs.readFileSync(path.join(root, expected.fixture)); + +function sockets() { + return { + rgb: [{ id: "color", name: "Color", direction: "OUTPUT", dataType: "COLOR", defaultValue: [0.15, 0.25, 0.35, 1] }], + value: [{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.2 }], + math: [ + { id: "a", name: "Value", direction: "INPUT", dataType: "VALUE", defaultValue: 0 }, + { id: "b", name: "Value_001", direction: "INPUT", dataType: "VALUE", defaultValue: 0 }, + { id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE" }, + ], + principled: [ + { id: "base", name: "Base Color", direction: "INPUT", dataType: "COLOR", defaultValue: [0.2, 0.3, 0.4, 1] }, + { id: "roughness", name: "Roughness", direction: "INPUT", dataType: "VALUE", defaultValue: 0.5 }, + { id: "bsdf", name: "BSDF", direction: "OUTPUT", dataType: "SHADER" }, + ], + output: [{ id: "surface", name: "Surface", direction: "INPUT", dataType: "SHADER" }], + }; +} + +test("M10-07 produces a bounded compile report from the real Main Shader graph", async ({ page }) => { + test.setTimeout(120_000); + expect(createHash("sha256").update(blendBytes).digest("hex")).toBe(expected.fixtureSha256); + await page.goto("/"); + const result = await page.evaluate(async ({ bytes, socketSet }) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const client = new WebEngineClient({ timeoutMs: 60_000 }); + await client.init(); + const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const opened = await client.openBlend(source); + const material = opened.snapshot.materials[0]; + if (!material) throw new Error("basic fixture has no editable material"); + const graph = { + schemaVersion: 1 as const, + id: "shader:m10-07", + materialId: material.id, + outputNodeId: "output", + nodes: [ + { id: "rgb", type: "RGB" as const, name: "RGB", sockets: socketSet.rgb }, + { id: "a", type: "VALUE" as const, name: "A", sockets: socketSet.value }, + { id: "b", type: "VALUE" as const, name: "B", sockets: [{ ...socketSet.value[0], defaultValue: 0.22 }] }, + { id: "math", type: "MATH" as const, name: "Add", properties: { operation: "ADD" as const }, sockets: socketSet.math }, + { id: "principled", type: "PRINCIPLED" as const, name: "Principled", sockets: socketSet.principled }, + { id: "output", type: "MATERIAL_OUTPUT" as const, name: "Output", sockets: socketSet.output }, + ], + links: [ + { fromNodeId: "rgb", fromSocketId: "color", toNodeId: "principled", toSocketId: "base" }, + { fromNodeId: "a", fromSocketId: "value", toNodeId: "math", toSocketId: "a" }, + { fromNodeId: "b", fromSocketId: "value", toNodeId: "math", toSocketId: "b" }, + { fromNodeId: "math", fromSocketId: "value", toNodeId: "principled", toSocketId: "roughness" }, + { fromNodeId: "principled", fromSocketId: "bsdf", toNodeId: "output", toSocketId: "surface" }, + ], + }; + const applied = await client.applyCommand({ type: "setShaderGraph", materialId: material.id, graph }); + const report = applied.shaderCompile; + const after = await client.snapshot(); + client.terminate(); + return { + openedHash: material.shaderGraphHash, + status: report?.status, + taskId: report?.taskId, + backend: report?.backend, + graphHash: report?.graphHash, + roughness: report?.material?.roughness, + nodeTypes: report?.compiledNodeTypes, + revisionDelta: after.snapshot.revision - opened.snapshot.revision, + }; + }, { bytes: new Uint8Array(blendBytes), socketSet: sockets() }); + + expect(result.status).toBe("COMPILED"); + expect(result.openedHash).toMatch(/^[0-9a-f]{64}$/); + expect(result.taskId).toBe("M10-07"); + expect(result.backend).toBe(expected.backend); + expect(result.graphHash).toMatch(/^[0-9a-f]{64}$/); + expect(result.roughness).toBeCloseTo(expected.expectedMathResult, 5); + expect(result.nodeTypes).toEqual(expect.arrayContaining(expected.allowlist.filter((type: string) => !["IMAGE_TEXTURE", "NORMAL_MAP"].includes(type)))); + expect(result.revisionDelta).toBe(1); +}); + +test("M10-07 shared viewport material path compiles Image/Normal/Math bindings", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts"); + const material = createPBRMaterial({ + id: "material:viewport", + name: "Viewport", + baseColor: [0.8, 0.8, 0.8, 1], + roughness: 0.5, + metallic: 0, + emissionColor: [0, 0, 0, 1], + alpha: 1, + ior: 1.45, + nodes: [ + { id: "a", type: "VALUE", name: "A", defaultValue: [0.2] }, + { id: "b", type: "VALUE", name: "B", defaultValue: [0.22] }, + { id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } }, + { id: "image", type: "IMAGE_TEXTURE", name: "Image", imageId: "image:normal" }, + { id: "normal", type: "NORMAL_MAP", name: "Normal" }, + { id: "principled", type: "PRINCIPLED", name: "Principled" }, + { id: "output", type: "OUTPUT", name: "Output" }, + ], + links: [ + { fromNodeId: "a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" }, + { fromNodeId: "b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" }, + { fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" }, + { fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" }, + { fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" }, + { fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }, + ], + }); + const compile = material.userData.shaderCompile; + const report = { + status: compile.status, + graphHash: compile.graphHash, + roughness: material.roughness, + textures: compile.textureBindings, + }; + material.dispose(); + return report; + }); + expect(result.status).toBe("COMPILED"); + expect(result.graphHash).toMatch(/^[0-9a-f]{64}$/); + expect(result.roughness).toBeCloseTo(expected.expectedMathResult, 5); + expect(result.textures).toEqual([{ imageId: "image:normal", usage: "NORMAL" }]); +}); + +test("M10-07 compiler blocks a non-declared node without mutating the input graph", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts"); + const material = { + id: "material:blocked", + name: "Blocked", + baseColor: [1, 1, 1, 1], + roughness: 0.5, + metallic: 0, + emissionColor: [0, 0, 0, 1], + alpha: 1, + ior: 1.45, + nodes: [{ id: "mix", type: "UNSUPPORTED", name: "Mix" }, { id: "output", type: "OUTPUT", name: "Output" }], + links: [], + } as const; + const before = JSON.stringify(material); + const threeMaterial = createPBRMaterial(material); + const report = threeMaterial.userData.shaderCompile; + threeMaterial.dispose(); + return { status: report.status, code: report.issues[0]?.code, preserved: JSON.stringify(material) === before }; + }); + expect(result).toEqual({ status: "BLOCKED", code: expected.unsupportedNodeCode, preserved: true }); +}); diff --git a/web/tests/e2e/shader-pipeline.spec.ts b/web/tests/e2e/shader-pipeline.spec.ts new file mode 100644 index 00000000..d7121d7b --- /dev/null +++ b/web/tests/e2e/shader-pipeline.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-09/shader-pipeline.json"), "utf8")); + +test("M10-09 keeps the previous material pipeline after compile failure", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const { PBRMaterialPipeline } = await import("/src/three-adapter/pbr.ts"); + const valid = { + id: "material:pipeline", + name: "Pipeline", + baseColor: [0.2, 0.3, 0.4, 1] as [number, number, number, number], + roughness: 0.5, + metallic: 0, + emissionColor: [0, 0, 0, 1] as [number, number, number, number], + alpha: 1, + ior: 1.45, + nodes: [ + { id: "principled", type: "PRINCIPLED" as const, name: "Principled" }, + { id: "output", type: "OUTPUT" as const, name: "Output" }, + ], + links: [{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }], + }; + const invalid = { ...valid, nodes: [{ id: "mix", type: "UNSUPPORTED" as const, name: "Mix" }, ...valid.nodes] }; + const pipeline = new PBRMaterialPipeline(); + const first = pipeline.update(valid); + const failed = pipeline.update(invalid); + const preserved = failed.material === first.material; + const failureReport = first.material.userData.shaderCompileFailure; + const replacement = pipeline.update({ ...valid, baseColor: [0.8, 0.2, 0.1, 1] }); + const replaced = replacement.material !== first.material; + const oldDisposed = first.material.userData.shaderCompile?.status !== "COMPILED"; + pipeline.dispose(); + return { + first: first.report?.status, + failure: failed.report?.status, + preserved, + failureCode: failureReport?.issues?.[0]?.code, + replaced, + replacedOnSuccess: replacement.replaced, + oldPipelineStillCompiled: !oldDisposed, + }; + }); + expect(result).toEqual({ + first: "COMPILED", + failure: expected.failureStatus, + preserved: expected.preservedPipeline, + failureCode: "SHADER_NODE_UNSUPPORTED", + replaced: true, + replacedOnSuccess: expected.replacedOnSuccess, + oldPipelineStillCompiled: expected.failureDoesNotDisposePrevious, + }); +}); diff --git a/web/tests/e2e/simulation-cache-identity.spec.ts b/web/tests/e2e/simulation-cache-identity.spec.ts new file mode 100644 index 00000000..fee6f70a --- /dev/null +++ b/web/tests/e2e/simulation-cache-identity.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from "@playwright/test"; + +test("M10-05 isolates Simulation caches by committed graph/source/revision identity", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const result = await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const digest = async (data: ArrayBuffer): Promise => Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", data)), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + const revisionHash = async (binding: { + graphId: string; graphHash: string; sourceBlendSha256: string; sourceRevision: number; + inputHash: string; blenderVersion: string; frameStart: number; frameEnd: number; + }): Promise => digest(new TextEncoder().encode(JSON.stringify([ + "blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash, + binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash, + binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd), + ])).buffer); + const errorCode = async (operation: () => Promise): Promise => { + try { await operation(); return ""; } + catch (error) { return String((error as Error & { code?: string }).code ?? ""); } + }; + + const projectId = `m10-05-${Date.now()}`; + const sourceOne = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 1]).buffer; + const sourceTwo = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 2]).buffer; + const payload = Uint8Array.from([11, 12, 13, 14]).buffer; + const frameHash = await digest(payload); + const common = { + graphId: "node-group:M10SimulationIdentity", + graphHash: await digest(Uint8Array.from([21]).buffer), + inputHash: await digest(Uint8Array.from([22]).buffer), + blenderVersion: "5.2.0", + frameStart: 1, + frameEnd: 1, + }; + const makeManifest = async (source: ArrayBuffer, sourceRevision: number) => { + const binding = { ...common, sourceBlendSha256: await digest(source), sourceRevision }; + return { + schemaVersion: 2 as const, + ...binding, + revisionHash: await revisionHash(binding), + cacheSha256: frameHash, + byteLength: payload.byteLength, + frames: [{ frame: 1, byteOffset: 0, byteLength: payload.byteLength, sha256: frameHash }], + }; + }; + + const firstManifest = await makeManifest(sourceOne, 7); + const first = new StorageClient(); + await first.saveProject(projectId, 7, sourceOne.slice(0)); + const storedOne = await first.putSimulationCache(projectId, firstManifest, payload.slice(0)); + const forgedGraphCode = await errorCode(() => first.putSimulationCache( + projectId, + { ...firstManifest, graphHash: "0".repeat(64) }, + payload.slice(0), + )); + const forgedSourceBinding = { ...firstManifest, sourceBlendSha256: "1".repeat(64) }; + const forgedSourceManifest = { + ...forgedSourceBinding, + revisionHash: await revisionHash(forgedSourceBinding), + }; + const forgedSourceCode = await errorCode(() => first.putSimulationCache( + projectId, + forgedSourceManifest, + payload.slice(0), + )); + first.terminate(); + + const restarted = new StorageClient(); + await restarted.prepareSimulationCachePlayback(projectId, storedOne.cacheKey); + const recovered = await restarted.readSimulationCacheFrame(projectId, storedOne.cacheKey, 1); + const listedBefore = await restarted.listSimulationCaches(projectId); + await restarted.saveProject(projectId, 8, sourceTwo.slice(0)); + const staleReadCode = await errorCode(() => restarted.readSimulationCache(projectId, storedOne.cacheKey)); + const listedAfter = await restarted.listSimulationCaches(projectId); + const stalePutCode = await errorCode(() => restarted.putSimulationCache( + projectId, + firstManifest, + payload.slice(0), + )); + + const secondManifest = await makeManifest(sourceTwo, 8); + const storedTwo = await restarted.putSimulationCache(projectId, secondManifest, payload.slice(0)); + const listedCurrent = await restarted.listSimulationCaches(projectId); + restarted.terminate(); + return { + firstKey: storedOne.cacheKey, + secondKey: storedTwo.cacheKey, + recoveredBytes: Array.from(new Uint8Array(recovered.data)), + listedBefore: listedBefore.caches.map((cache) => cache.cacheKey), + listedAfter: listedAfter.caches.map((cache) => cache.cacheKey), + listedCurrent: listedCurrent.caches.map((cache) => cache.cacheKey), + forgedGraphCode, + forgedSourceCode, + staleReadCode, + stalePutCode, + sourceRevision: storedTwo.manifest.sourceRevision, + }; + }); + + expect(result.firstKey).toMatch(/^sim2-[a-f0-9]{64}$/); + expect(result.secondKey).toMatch(/^sim2-[a-f0-9]{64}$/); + expect(result.secondKey).not.toBe(result.firstKey); + expect(result.recoveredBytes).toEqual([11, 12, 13, 14]); + expect(result.listedBefore).toEqual([result.firstKey]); + expect(result.listedAfter).toEqual([]); + expect(result.listedCurrent).toEqual([result.secondKey]); + expect(result.forgedGraphCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH"); + expect(result.forgedSourceCode).toBe("SIMULATION_CACHE_HASH_MISMATCH"); + expect(result.staleReadCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH"); + expect(result.stalePutCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH"); + expect(result.sourceRevision).toBe(8); +}); diff --git a/web/tests/e2e/simulation-cache-lifecycle.spec.ts b/web/tests/e2e/simulation-cache-lifecycle.spec.ts new file mode 100644 index 00000000..949db66e --- /dev/null +++ b/web/tests/e2e/simulation-cache-lifecycle.spec.ts @@ -0,0 +1,211 @@ +import { expect, test } from "@playwright/test"; + +test("M10-06 gates playback on verification and enforces cancel, LRU, restart and corruption quarantine", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const result = await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts"); + const { upgradeStorageSchema } = await import("/src/storage/migrations.ts"); + const digest = async (data: ArrayBuffer): Promise => Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", data)), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + const errorCode = async (operation: () => Promise): Promise => { + try { await operation(); return ""; } + catch (error) { + const code = (error as Error & { code?: unknown }).code; + return typeof code === "string" ? code : (error as Error).name; + } + }; + const projectId = `m10-06-${Date.now()}`; + const migrationDatabase = `m10-06-migration-${Date.now()}`; + await new Promise((resolve, reject) => { + const request = indexedDB.open(migrationDatabase, 6); + request.onupgradeneeded = () => { + request.result.createObjectStore("migration", { keyPath: "id" }); + request.result.createObjectStore("simulation_manifest", { keyPath: "id" }); + }; + request.onsuccess = () => { request.result.close(); resolve(); }; + request.onerror = () => reject(request.error); + }); + const migration = await new Promise<{ version: number; stores: string[]; record?: { version: number } }>((resolve, reject) => { + const request = indexedDB.open(migrationDatabase, 7); + request.onupgradeneeded = (event) => upgradeStorageSchema(request.result, request.transaction!, (event as IDBVersionChangeEvent).oldVersion); + request.onsuccess = () => { + const database = request.result; + const transaction = database.transaction("migration", "readonly"); + const record = transaction.objectStore("migration").get("schema-7"); + record.onsuccess = () => resolve({ version: database.version, stores: [...database.objectStoreNames], record: record.result as { version: number } | undefined }); + record.onerror = () => reject(record.error); + transaction.oncomplete = () => database.close(); + }; + request.onerror = () => reject(request.error); + }); + indexedDB.deleteDatabase(migrationDatabase); + const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 6]).buffer; + const sourceBlendSha256 = await digest(sourceBlend); + const makeManifest = async (name: string, payload: ArrayBuffer, frameCount = 1) => { + const frameBytes = payload.byteLength / frameCount; + const binding = { + graphId: `node-group:${name}`, + graphHash: await digest(new TextEncoder().encode(`graph:${name}`).buffer), + sourceBlendSha256, + sourceRevision: 6, + inputHash: await digest(new TextEncoder().encode(`input:${name}`).buffer), + blenderVersion: "5.2.0", + frameStart: 1, + frameEnd: frameCount, + }; + const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([ + "blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash, + binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash, + binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd), + ])).buffer); + const frames = await Promise.all(Array.from({ length: frameCount }, async (_, index) => ({ + frame: index + 1, + byteOffset: index * frameBytes, + byteLength: frameBytes, + sha256: await digest(payload.slice(index * frameBytes, (index + 1) * frameBytes)), + }))); + return { + schemaVersion: 2 as const, + ...binding, + revisionHash, + cacheSha256: await digest(payload), + byteLength: payload.byteLength, + frames, + }; + }; + + const first = new StorageClient(); + await first.saveProject(projectId, 6, sourceBlend.slice(0)); + const payloadA = Uint8Array.from([1, 2, 3, 4]).buffer; + const manifestA = await makeManifest("lru-a", payloadA); + const storedA = await first.putSimulationCache(projectId, manifestA, payloadA.slice(0)); + first.terminate(); + + const restarted = new StorageClient(); + const notReadyAfterRestart = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1)); + const prepared = await restarted.prepareSimulationCachePlayback(projectId, storedA.cacheKey); + const recovered = await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1); + let cancelledPublishedFrames = 0; + const playback = new BrowserTransformCachePlaybackSession({ + schemaVersion: 1 as const, + revision: 6, + sceneId: "scene:M10-06", + source: { kind: "mock" as const }, + coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, + activeObjectId: null, + frame: { current: 1, start: 1, end: 1 }, + nodes: [], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [], + }, { + frameStart: 1, + frameEnd: 1, + readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, frame, signal)).data, + }, () => { cancelledPublishedFrames += 1; }); + const cancellingPlayback = playback.play(); + playback.cancel(); + const playbackCancellation = await cancellingPlayback; + const pendingAfterPlaybackCancel = restarted.getPendingRequestCount(); + + await new Promise((resolve) => setTimeout(resolve, 5)); + const payloadB = Uint8Array.from([5, 6, 7, 8]).buffer; + const manifestB = await makeManifest("lru-b", payloadB); + const storedB = await restarted.putSimulationCache(projectId, manifestB, payloadB.slice(0)); + await new Promise((resolve) => setTimeout(resolve, 5)); + const payloadC = Uint8Array.from([9, 10, 11, 12]).buffer; + const manifestC = await makeManifest("lru-c", payloadC); + const storedC = await restarted.putSimulationCache(projectId, manifestC, payloadC.slice(0)); + const activePrune = await restarted.pruneSimulationCaches(projectId, 4); + const afterActivePrune = await restarted.listSimulationCaches(projectId); + await restarted.releaseSimulationCachePlayback(projectId, storedA.cacheKey); + const releasedCode = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1)); + const finalPrune = await restarted.pruneSimulationCaches(projectId, 0); + + const cancelledPayload = new ArrayBuffer(256 * 32); + const cancelledBytes = new Uint8Array(cancelledPayload); + for (let index = 0; index < cancelledBytes.length; index += 1) cancelledBytes[index] = index % 251; + const cancelledManifest = await makeManifest("cancelled", cancelledPayload, 256); + const controller = new AbortController(); + const cancelledWrite = restarted.putSimulationCache(projectId, cancelledManifest, cancelledPayload, controller.signal); + controller.abort(); + const cancelledCode = await errorCode(() => cancelledWrite); + const afterCancel = await restarted.listSimulationCaches(projectId); + const assetsAfterCancel = await restarted.listAssets(projectId); + + const corruptPayload = Uint8Array.from([31, 32, 33, 34]).buffer; + const corruptManifest = await makeManifest("corrupt", corruptPayload); + const corruptStored = await restarted.putSimulationCache(projectId, corruptManifest, corruptPayload.slice(0)); + restarted.terminate(); + const pathSegments = corruptStored.path.split("/"); + const fileName = pathSegments.pop()!; + let directory = await navigator.storage.getDirectory(); + for (const segment of pathSegments) directory = await directory.getDirectoryHandle(segment); + const handle = await directory.getFileHandle(fileName); + const writable = await handle.createWritable(); + await writable.write(Uint8Array.from([99, 98, 97, 96])); + await writable.close(); + + const quarantineReader = new StorageClient(); + const corruptNotReady = await errorCode(() => quarantineReader.readSimulationCacheFrame(projectId, corruptStored.cacheKey, 1)); + const corruptionCode = await errorCode(() => quarantineReader.prepareSimulationCachePlayback(projectId, corruptStored.cacheKey)); + const afterCorruption = await quarantineReader.listSimulationCaches(projectId); + const info = await quarantineReader.info(); + const pending = quarantineReader.getPendingRequestCount(); + quarantineReader.terminate(); + + return { + notReadyAfterRestart, + verifiedAt: prepared.verifiedAt, + recovered: Array.from(new Uint8Array(recovered.data)), + playbackCancellation, + cancelledPublishedFrames, + pendingAfterPlaybackCancel, + activePrune, + activeKeys: afterActivePrune.caches.map((cache) => cache.cacheKey), + expectedActiveKey: storedA.cacheKey, + evictedKeys: [storedB.cacheKey, storedC.cacheKey].sort(), + releasedCode, + finalPrune, + cancelledCode, + cancelledPublished: afterCancel.caches.some((cache) => cache.cacheKey === `sim2-${cancelledManifest.revisionHash}`), + cancelledAssetPresent: assetsAfterCancel.assets.some((asset) => asset.sha256 === cancelledManifest.cacheSha256), + corruptNotReady, + corruptionCode, + cachesAfterCorruption: afterCorruption.caches.length, + quarantined: afterCorruption.quarantined, + issues: afterCorruption.issues, + schemaVersion: info.schemaVersion, + stores: info.stores, + pending, + migration, + }; + }); + + expect(result.notReadyAfterRestart).toBe("SIMULATION_CACHE_NOT_READY"); + expect(result.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(result.recovered).toEqual([1, 2, 3, 4]); + expect(result.playbackCancellation).toEqual({ status: "CANCELLED", appliedFrames: 0, lastFrame: null }); + expect(result.cancelledPublishedFrames).toBe(0); + expect(result.pendingAfterPlaybackCancel).toBe(0); + expect(result.activePrune).toMatchObject({ beforeBytes: 12, remainingBytes: 4, removedBytes: 8, removed: 2, budgetSatisfied: true }); + expect(result.activePrune.cacheKeys.sort()).toEqual(result.evictedKeys); + expect(result.activePrune.protectedCacheKeys).toContain(result.expectedActiveKey); + expect(result.activeKeys).toEqual([result.expectedActiveKey]); + expect(result.releasedCode).toBe("SIMULATION_CACHE_NOT_READY"); + expect(result.finalPrune).toMatchObject({ beforeBytes: 4, remainingBytes: 0, removedBytes: 4, removed: 1, budgetSatisfied: true }); + expect(result.cancelledCode).toBe("AbortError"); + expect(result.cancelledPublished).toBe(false); + expect(result.cancelledAssetPresent).toBe(false); + expect(result.corruptNotReady).toBe("SIMULATION_CACHE_NOT_READY"); + expect(result.corruptionCode).toBe("SIMULATION_CACHE_HASH_MISMATCH"); + expect(result.cachesAfterCorruption).toBe(0); + expect(result.quarantined).toBe(1); + expect(result.issues).toEqual([expect.objectContaining({ code: "SIMULATION_CACHE_HASH_MISMATCH" })]); + expect(result.schemaVersion).toBe(7); + expect(result.stores).toContain("simulation_quarantine"); + expect(result.pending).toBe(0); + expect(result.migration).toMatchObject({ version: 7, record: { version: 7 } }); + expect(result.migration.stores).toContain("simulation_quarantine"); +}); diff --git a/web/tests/e2e/simulation-cache-performance.spec.ts b/web/tests/e2e/simulation-cache-performance.spec.ts index 6171e120..e98e028e 100644 --- a/web/tests/e2e/simulation-cache-performance.spec.ts +++ b/web/tests/e2e/simulation-cache-performance.spec.ts @@ -35,16 +35,26 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async }))); const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]).buffer; const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer); - const manifest = { - schemaVersion: 1 as const, + const binding = { graphId: "geometry-node-tree:simulation-performance", graphHash: fixedHash, sourceBlendSha256: await digest(sourceBlend), + sourceRevision: 1, inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer), - cacheSha256: await digest(payload), blenderVersion: "5.2.0", frameStart: 1, frameEnd: frameCount, + }; + const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([ + "blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash, + binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash, + binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd), + ])).buffer); + const manifest = { + schemaVersion: 2 as const, + ...binding, + revisionHash, + cacheSha256: await digest(payload), byteLength: payload.byteLength, frames, }; @@ -58,6 +68,7 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async const storedAt = performance.now(); const reader = new StorageClient(); + await reader.prepareSimulationCachePlayback(projectId, stored.cacheKey); const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const scene = { schemaVersion: 1 as const, @@ -77,7 +88,7 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async frameEnd: frameCount, readFrame: async (frame, signal) => { if (signal.aborted) throw new DOMException("Playback aborted", "AbortError"); - const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame); + const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame, signal); if (signal.aborted) throw new DOMException("Playback aborted", "AbortError"); return read.data; }, @@ -86,10 +97,12 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async lastTranslation = preview.nodes[0].transform.translation[0]; }); const playbackResult = await playback.play(); + await reader.releaseSimulationCachePlayback(projectId, stored.cacheKey); const finished = performance.now(); reader.terminate(); const readerPendingAfterTerminate = reader.getPendingRequestCount(); const recovery = new StorageClient(); + await recovery.prepareSimulationCachePlayback(projectId, stored.cacheKey); const recovered = await recovery.readSimulationCacheFrame(projectId, stored.cacheKey, frameCount); recovery.terminate(); const recoveryPendingAfterTerminate = recovery.getPendingRequestCount(); diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 37ac8137..ed4b5e25 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -87,8 +87,8 @@ test("migrates IndexedDB metadata and creates a validated OPFS project layout", worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ requestId: infoId, command: { type: "info" } }); })); - expect(result.schemaVersion).toBe(6); - expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"])); + expect(result.schemaVersion).toBe(7); + expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "migration"])); expect(result.projectPath).toBe("projects/layout-e2e/scene.blend"); }); @@ -454,8 +454,8 @@ test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", a await page.goto("/"); const result = await page.evaluate(async () => { const { createGreasePencilObject } = await import("/src/three-adapter/grease-pencil.ts"); - const point = (x: number) => ({ position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] }); - const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ cyclic: false, pointCount: 2, materialIndex: 0, points: [point(x), point(x + 1)] }] }); + const point = (drawingId: string, x: number, index: number) => ({ id: `grease-pencil-point:onion:${drawingId}:${index}`, position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] }); + const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ id: `grease-pencil-stroke:onion:${id}`, cyclic: false, pointCount: 2, materialIndex: 0, points: [point(id, x, 0), point(id, x + 1, 1)] }] }); const object = createGreasePencilObject({ id: "grease-pencil:onion", name: "Onion", @@ -464,7 +464,8 @@ test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", a frameCount: 3, strokeCount: 3, pointCount: 6, - layers: [{ id: "layer:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [ + activeLayerId: "grease-pencil-layer:onion:1", + layers: [{ id: "grease-pencil-layer:onion:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [ { frame: 1, drawing: drawing("drawing:1", -2) }, { frame: 5, drawing: drawing("drawing:5", 0) }, { frame: 9, drawing: drawing("drawing:9", 2) }, @@ -531,7 +532,7 @@ test("raycasts and highlights N-016 Grease Pencil points with stable drawing ide worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); - expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, strokeIndex: 0, pointIndex: 1 }); + expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, drawingId: "grease-pencil-drawing:Viewport", strokeId: "grease-pencil-stroke:Viewport:0:0", pointId: "grease-pencil-point:Viewport:0:0:1", strokeIndex: 0, pointIndex: 1 }); expect(result.selectedColor).toEqual([expect.closeTo(1), expect.closeTo(0.38), expect.closeTo(0.08)]); expect(result.preview).toEqual([[0.5, 2, -1], [0.5, 2, -1]]); expect(result.restored).toEqual([[0, 0, -0], [0, 0, -0]]); @@ -849,7 +850,7 @@ test("executes the bounded N-020 CPU compositor and preserves unsupported nodes expect(result.cache).toEqual([false, true, false, true, true, 0.25, 2, 256]); }); -test("executes the N-020 Exposure and Invert chain read from a real Blender 5.2 graph", async ({ page }) => { +test("blocks the N-020 Main graph when it also preserves an unsupported Blender node", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(compositorBlend)); const result = await page.evaluate(async (input) => { @@ -860,19 +861,24 @@ test("executes the N-020 Exposure and Invert chain read from a real Blender 5.2 const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "CompositorScene"); if (!scene?.compositorGraph) throw new Error("Real compositor graph is missing"); - const execution = executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 }); + const before = JSON.stringify(scene.compositorGraph); + let executionCode = ""; + try { executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 }); } + catch (error) { executionCode = (error as { code?: string }).code ?? String(error); } return { status: scene.compositorStatus, - pixel: Array.from(execution.composite.data), - evaluated: execution.evaluatedNodeIds.map((id) => scene.compositorGraph!.nodes.find((node) => node.id === id)?.name), + executionCode, + preserved: JSON.stringify(scene.compositorGraph) === before, + unsupported: scene.compositorGraph.nodes.find((node) => node.type === "UNSUPPORTED")?.blenderType, gate: gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code, }; } finally { client.terminate(); } }, new Uint8Array(bytes)); expect(result.status).toBe("AVAILABLE"); - expect(result.pixel).toEqual([0.75, 0.5, 0, 0.75]); - expect(result.evaluated).toEqual(["WebConstantColor", "WebExposure", "WebInvert", "WebComposite"]); + expect(result.executionCode).toBe("COMPOSITOR_NODE_UNSUPPORTED"); + expect(result.preserved).toBe(true); + expect(result.unsupported).toBe("CompositorNodeGlare"); expect(result.gate).toBe("COMPOSITOR_NODE_UNSUPPORTED"); }); @@ -1121,8 +1127,7 @@ for (const offscreen of [false, true]) test(`previews an N-015 Curve handle drag const { PerspectiveCamera, Vector3 } = await import("/src/vendor/three/three.module.js"); const bounds = canvas.getBoundingClientRect(); const camera = new PerspectiveCamera(45, bounds.width / bounds.height, 0.01, 1000); - if (input.offscreen) camera.position.set(7 * Math.cos(0.55) * Math.cos(-Math.PI / 4), 7 * Math.cos(0.55) * Math.sin(-Math.PI / 4), 7 * Math.sin(0.55)); - else camera.position.set(4.5, -4.5, 3.5); + camera.position.set(4.219781, -4.219781, 3.658811); camera.lookAt(0, 0, 0); camera.updateMatrixWorld(true); camera.updateProjectionMatrix(); @@ -1383,7 +1388,15 @@ test("recovers NanoVDB paging from network, Worker and WebGPU device faults", as expect(gpu.paging.pageCount).toBeGreaterThan(1); expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount); expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1); - expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.demandPaging.expectedWords.slice(0, 2)); + expect(gpu.deviceLoss.residentBeforeReplay).toEqual([]); + expect(gpu.deviceLoss.visiblePageIds).toEqual([0, 1, 2]); + expect(gpu.deviceLoss.replayedPageIds).toEqual([0, 1]); + expect(gpu.deviceLoss.skippedPageIds).toEqual([2]); + expect(gpu.deviceLoss.recoveredResidentPages).toEqual([0, 1]); + expect(gpu.deviceLoss.recoveredResidentPages).not.toContain(gpu.deviceLoss.oldNonVisiblePage); + expect(gpu.deviceLoss.recoveredPageTable.slice(0, 2)).toEqual([0, 1]); + expect(gpu.deviceLoss.recoveredPageTable.slice(2).every((slot: number) => slot === 0xffffffff)).toBe(true); + expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.deviceLoss.expectedDemandWords); expect(gpu.samplesStable).toBe(true); const interrupted = await page.evaluate(() => new Promise((resolve, reject) => { @@ -1549,7 +1562,7 @@ test("retains bounded snapshots and returns a validated operation replay plan", await client.appendOperation("op-r5", projectId, 5, { type: "setFrame", frame: 5 }); await client.appendOperation("op-r4", projectId, 4, { type: "setFrame", frame: 4 }); const db = await new Promise((resolve, reject) => { - const request = indexedDB.open("blender-web-metadata", 6); + const request = indexedDB.open("blender-web-metadata", 7); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); @@ -1559,18 +1572,50 @@ test("retains bounded snapshots and returns a validated operation replay plan", db.close(); const snapshots = await client.listSnapshots(projectId); const latest = await client.readSnapshot(projectId, snapshots.snapshots[0].revision); + const snapshotRows = await new Promise>((resolve, reject) => { + const request = indexedDB.open("blender-web-metadata", 7); + request.onsuccess = () => { + const snapshotDb = request.result; + const transaction = snapshotDb.transaction("snapshot", "readonly"); + const rows = transaction.objectStore("snapshot").getAll(); + rows.onsuccess = () => resolve((rows.result as Array<{ projectId: string; backend?: string; buffer?: ArrayBuffer }>).filter((row) => row.projectId === projectId)); + rows.onerror = () => reject(rows.error); + transaction.oncomplete = () => snapshotDb.close(); + }; + request.onerror = () => reject(request.error); + }); + const root = await navigator.storage.getDirectory(); + const projects = await root.getDirectoryHandle("projects"); + const project = await projects.getDirectoryHandle(projectId); + const snapshotDirectory = await project.getDirectoryHandle("snapshots"); + const snapshotFiles: string[] = []; + for await (const [name, handle] of (snapshotDirectory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries()) { + if (handle.kind === "file") snapshotFiles.push(name); + } const replay = await client.listOperations(projectId, 3); const pruned = await client.pruneOperations(projectId, 4); client.terminate(); return { snapshotRevisions: snapshots.snapshots.map((item) => item.revision), latest: Array.from(new Uint8Array(latest.buffer)), + snapshotBackends: snapshotRows.map((row) => row.backend).sort(), + inlineSnapshotBuffers: snapshotRows.filter((row) => row.buffer).length, + snapshotFiles: snapshotFiles.sort(), replayRevisions: replay.operations.map((item) => item.revision), quarantined: replay.quarantined, pruned: pruned.removed, }; }); - expect(result).toEqual({ snapshotRevisions: [4, 3], latest: [4, 5], replayRevisions: [4, 5], quarantined: 1, pruned: 2 }); + expect(result).toEqual({ + snapshotRevisions: [4, 3], + latest: [4, 5], + snapshotBackends: ["opfs", "opfs"], + inlineSnapshotBuffers: 0, + snapshotFiles: ["3.blend", "4.blend"], + replayRevisions: [4, 5], + quarantined: 1, + pruned: 2, + }); }); test("reports quota exhaustion without replacing the committed project", async ({ page }) => { @@ -1654,16 +1699,26 @@ test("persists and revalidates content-addressed Simulation caches across Worker payloadBytes.set(new Uint8Array(frameTwo), frameOne.byteLength); const payload = payloadBytes.buffer; const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer); - const manifest = { - schemaVersion: 1 as const, + const binding = { graphId: "geometry-node-tree:simulation-e2e", graphHash: fixedHash, sourceBlendSha256: await digest(sourceBlend), + sourceRevision: 1, inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer), - cacheSha256: await digest(payload), blenderVersion: "5.2.0", frameStart: 1, frameEnd: 2, + }; + const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([ + "blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash, + binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash, + binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd), + ])).buffer); + const manifest = { + schemaVersion: 2 as const, + ...binding, + revisionHash, + cacheSha256: await digest(payload), byteLength: payload.byteLength, frames: [ { frame: 1, byteOffset: 0, byteLength: frameOne.byteLength, sha256: await digest(frameOne) }, @@ -1683,7 +1738,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker const scene = { schemaVersion: 1 as const, revision: 1, sceneId: "scene:Cache", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:CacheTarget", frame: { current: 1, start: 1, end: 2 }, nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [] }; let publishedFrame = 0; let publishedTranslation: number[] = []; - const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; }); + const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame, signal)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; }); await playback.seek(2); let missingFrameCode = ""; try { @@ -1715,7 +1770,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker corruptCode, }; }); - expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/); + expect(result.cacheKey).toMatch(/^sim2-[a-f0-9]{64}$/); expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/); expect(result.listed).toContain(result.cacheKey); expect(result.bytes).toBe(176); diff --git a/web/tests/e2e/storage-budget.spec.ts b/web/tests/e2e/storage-budget.spec.ts new file mode 100644 index 00000000..5bc6359b --- /dev/null +++ b/web/tests/e2e/storage-budget.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); + +test("M7-11 reports project, snapshot, LOD, media and VDB byte categories", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const app = page.locator(".blender-app"); + await page.getByTestId("blend-file-input").setInputFiles(basicBlend); + await expect(page.getByText("Cube", { exact: true })).toBeVisible(); + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await download; + await expect(app).toHaveAttribute("data-project-id", "basic_scene"); + + const budget = await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const storage = new StorageClient(); + await storage.putAsset("basic_scene", new Uint8Array(7).buffer, "image/png", "media/test.png"); + await storage.putAsset("basic_scene", new Uint8Array(17).buffer, "application/x-blender-simulation-cache", "cache/simulation.bin"); + await storage.saveLOD("basic_scene", "budget-lod", new Uint8Array(13).buffer); + const result = await storage.getBudget("basic_scene"); + storage.terminate(); + return result; + }); + expect(budget.projectBytes).toBeGreaterThan(0); + expect(budget.snapshotBytes).toBeGreaterThan(0); + expect(budget.lodBytes).toBe(13); + expect(budget.mediaBytes).toBe(7); + expect(budget.vdbBytes).toBe(17); + expect(budget.totalBytes).toBe(budget.projectBytes + budget.snapshotBytes + 13 + 7 + 17); + + await page.reload(); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const panel = page.getByTestId("storage-budget-panel"); + await expect(panel).toHaveAttribute("data-project-id", "basic_scene"); + await expect(panel.locator('[data-category="LOD"]')).toHaveAttribute("data-bytes", "13"); + await expect(panel.locator('[data-category="媒体"]')).toHaveAttribute("data-bytes", "7"); + await expect(panel.locator('[data-category="VDB"]')).toHaveAttribute("data-bytes", "17"); + await expect(panel).toHaveAttribute("data-total-bytes", String(budget.totalBytes)); +}); diff --git a/web/tests/e2e/storage-cleanup.spec.ts b/web/tests/e2e/storage-cleanup.spec.ts new file mode 100644 index 00000000..1eda01ca --- /dev/null +++ b/web/tests/e2e/storage-cleanup.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); + +test("M7-12 removes only one project's unreferenced content-addressed assets", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await page.getByTestId("blend-file-input").setInputFiles(basicBlend); + await expect(page.getByText("Cube", { exact: true })).toBeVisible(); + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await download; + + const orphanA = "a".repeat(64); + const orphanB = "b".repeat(64); + const referenced = await page.evaluate(async ({ orphanA, orphanB }) => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const storage = new StorageClient(); + const keptA = await storage.putAsset("basic_scene", new Uint8Array(5).buffer, "image/png", "media/kept.png"); + await storage.putAsset("cleanup-other", new Uint8Array(6).buffer, "image/png", "media/other.png"); + const root = await navigator.storage.getDirectory(); + const writeOrphan = async (projectId: string, hash: string, size: number) => { + let current = root; + for (const segment of ["projects", projectId, "assets", "sha256", hash.slice(0, 2)]) current = await current.getDirectoryHandle(segment, { create: true }); + const handle = await current.getFileHandle(hash, { create: true }); + const writer = await handle.createWritable(); + await writer.write(new Uint8Array(size)); + await writer.close(); + }; + await writeOrphan("basic_scene", orphanA, 19); + await writeOrphan("cleanup-other", orphanB, 23); + storage.terminate(); + return { keptA: keptA.sha256, orphanA, orphanB }; + }, { orphanA, orphanB }); + + await page.reload(); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + await page.getByTestId("cleanup-project-assets").click(); + await expect(page.locator(".status-bar")).toContainText("removed 1 orphan asset(s), 19 bytes"); + const files = await page.evaluate(async ({ orphanA, orphanB, keptA }) => { + const root = await navigator.storage.getDirectory(); + const read = async (projectId: string, hash: string) => { + try { + let current = root; + for (const segment of ["projects", projectId, "assets", "sha256", hash.slice(0, 2)]) current = await current.getDirectoryHandle(segment); + await current.getFileHandle(hash); + return true; + } + catch { return false; } + }; + return { orphanA: await read("basic_scene", orphanA), orphanB: await read("cleanup-other", orphanB), keptA: await read("basic_scene", keptA) }; + }, referenced); + expect(files).toEqual({ orphanA: false, orphanB: true, keptA: true }); +}); diff --git a/web/tests/e2e/texture-paint-asset.spec.ts b/web/tests/e2e/texture-paint-asset.spec.ts new file mode 100644 index 00000000..23ce807f --- /dev/null +++ b/web/tests/e2e/texture-paint-asset.spec.ts @@ -0,0 +1,146 @@ +import { expect, test } from "@playwright/test"; + +test("M9-11 atomically publishes packed and UDIM dirty tiles after verified OPFS writes", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + const result = await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const projectId = "m9-texture-paint-atomic"; + const hash = async (bytes: Uint8Array | ArrayBuffer) => { + const data = bytes instanceof Uint8Array ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes; + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (value) => value.toString(16).padStart(2, "0")).join(""); + }; + const encode = async (pixels: Uint8Array) => { + const canvas = new OffscreenCanvas(2, 2); + const context = canvas.getContext("2d")!; + context.putImageData(new ImageData(new Uint8ClampedArray(pixels), 2, 2), 0, 0); + return (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer(); + }; + const decode = async (data: ArrayBuffer) => { + const bitmap = await createImageBitmap(new Blob([data], { type: "image/png" })); + const canvas = new OffscreenCanvas(2, 2); + const context = canvas.getContext("2d", { willReadFrequently: true })!; + context.drawImage(bitmap, 0, 0); + bitmap.close(); + return new Uint8Array(context.getImageData(0, 0, 2, 2).data); + }; + const commit = async ({ + client, + textureAssetId, + kind, + tile, + revision, + sourcePath, + baseAssetSha256, + basePixels, + offset, + dirty, + faultAt, + }: { + client: InstanceType; + textureAssetId: string; + kind: "PACKED" | "UDIM"; + tile: number; + revision: number; + sourcePath: string; + baseAssetSha256: string; + basePixels: Uint8Array; + offset: number; + dirty: number[]; + faultAt?: "after-asset-write"; + }) => { + const next = basePixels.slice(); + next.set(dirty, offset); + return client.commitTexturePaintTile({ + schemaVersion: 1, + target: { + schemaVersion: 1, + projectId, + imageId: "image:M9Paint", + textureAssetId, + kind, + tile, + revision, + width: 2, + height: 2, + mimeType: "image/png", + colorSpace: "SRGB", + sourcePath, + baseAssetSha256, + }, + patch: { + schemaVersion: 1, + textureAssetId, + tile, + revision, + width: 2, + height: 2, + format: "RGBA8", + colorSpace: "SRGB", + baseSha256: await hash(basePixels), + resultSha256: await hash(next), + byteOffset: offset, + bytes: new Uint8Array(dirty), + }, + faultAt, + }); + }; + + const opaqueBlack = new Uint8Array(Array.from({ length: 4 }, () => [0, 0, 0, 255]).flat()); + const client = new StorageClient(); + const packedBase = await client.putAsset(projectId, await encode(opaqueBlack), "image/png", "textures/packed.png"); + const packedId = "image:M9Paint:packed"; + const packedFirstPixels = opaqueBlack.slice(); + packedFirstPixels.set([255, 0, 0, 255], 0); + const packedFirst = await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 4, sourcePath: "textures/packed.png", baseAssetSha256: packedBase.sha256, basePixels: opaqueBlack, offset: 0, dirty: [255, 0, 0, 255] }); + const firstAsset = await client.readAsset(projectId, packedFirst.binding.assetSha256); + const firstPixels = await decode(firstAsset.data); + + let injected = ""; + try { + await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 5, sourcePath: "textures/packed.png", baseAssetSha256: packedFirst.binding.assetSha256, basePixels: packedFirstPixels, offset: 4, dirty: [0, 255, 0, 255], faultAt: "after-asset-write" }); + } + catch (error) { injected = error instanceof Error ? error.message : String(error); } + const afterFailure = await client.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: packedId, tile: 1001 }); + const packedSecondPixels = packedFirstPixels.slice(); + packedSecondPixels.set([0, 255, 0, 255], 4); + const packedSecond = await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 5, sourcePath: "textures/packed.png", baseAssetSha256: packedFirst.binding.assetSha256, basePixels: packedFirstPixels, offset: 4, dirty: [0, 255, 0, 255] }); + + const udimBase = await client.putAsset(projectId, await encode(opaqueBlack), "image/png", "textures/paint.1002.png"); + const udimId = "image:M9Paint:tile:1002"; + const udim = await commit({ client, textureAssetId: udimId, kind: "UDIM", tile: 1002, revision: 5, sourcePath: "textures/paint.1002.png", baseAssetSha256: udimBase.sha256, basePixels: opaqueBlack, offset: 12, dirty: [0, 0, 255, 255] }); + client.terminate(); + + const restarted = new StorageClient(); + const reopenedPacked = await restarted.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: packedId, tile: 1001 }); + const reopenedUdim = await restarted.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: udimId, tile: 1002 }); + const reopenedAsset = await restarted.readAsset(projectId, reopenedPacked.binding!.assetSha256); + const reopenedPixels = await decode(reopenedAsset.data); + restarted.terminate(); + return { + packedBase: packedBase.sha256, + packedFirst: packedFirst.binding, + firstPixels: Array.from(firstPixels), + injected, + afterFailure: afterFailure.binding, + packedSecond: packedSecond.binding, + udim: udim.binding, + reopenedPacked: reopenedPacked.binding, + reopenedUdim: reopenedUdim.binding, + reopenedPixels: Array.from(reopenedPixels), + expectedPackedPixels: Array.from(packedSecondPixels), + }; + }); + + expect(result.packedFirst).toMatchObject({ kind: "PACKED", tile: 1001, generation: 1, pixelSha256: expect.stringMatching(/^[a-f0-9]{64}$/) }); + expect(result.packedFirst.assetSha256).not.toBe(result.packedBase); + expect(result.firstPixels.slice(0, 4)).toEqual([255, 0, 0, 255]); + expect(result.injected).toContain("STORAGE_TRANSACTION"); + expect(result.afterFailure).toEqual(result.packedFirst); + expect(result.packedSecond).toMatchObject({ kind: "PACKED", tile: 1001, generation: 2 }); + expect(result.packedSecond.assetSha256).not.toBe(result.packedFirst.assetSha256); + expect(result.udim).toMatchObject({ kind: "UDIM", tile: 1002, generation: 1 }); + expect(result.reopenedPacked).toEqual(result.packedSecond); + expect(result.reopenedUdim).toEqual(result.udim); + expect(result.reopenedPixels).toEqual(result.expectedPackedPixels); +}); diff --git a/web/tests/e2e/ui-context.spec.ts b/web/tests/e2e/ui-context.spec.ts new file mode 100644 index 00000000..529ae850 --- /dev/null +++ b/web/tests/e2e/ui-context.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; + +test("M7-13 keeps menu, modal Escape and focus return consistent", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + + const searchTrigger = page.getByRole("button", { name: "操作搜索" }); + const fileMenu = page.getByRole("button", { name: "文件", exact: true }); + await page.keyboard.press("Tab"); + await expect(fileMenu).toBeFocused(); + await searchTrigger.focus(); + await page.keyboard.press("F3"); + const dialog = page.getByRole("dialog", { name: "Operator Search" }); + await expect(dialog).toBeVisible(); + await expect(page.getByRole("textbox", { name: "搜索操作" })).toBeFocused(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+Tab"); + await expect(page.getByRole("textbox", { name: "搜索操作" })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(searchTrigger).toBeFocused(); + + await fileMenu.click(); + const menu = page.getByRole("menu", { name: "文件" }); + await expect(menu).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitem", { name: "保存" })).toBeFocused(); + await page.keyboard.press("End"); + await expect(page.getByRole("menuitem", { name: "关闭" })).toBeFocused(); + await page.keyboard.press("Home"); + await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(fileMenu).toBeFocused(); + + await fileMenu.click(); + await page.keyboard.press("Tab"); + await expect(menu).toHaveCount(0); + await expect(fileMenu).toBeFocused(); + await fileMenu.click(); + await page.getByRole("button", { name: "操作搜索" }).click(); + await expect(page.getByRole("menu", { name: "文件" })).toHaveCount(0); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(searchTrigger).toBeFocused(); +}); diff --git a/web/tests/e2e/viewport-consistency.spec.ts b/web/tests/e2e/viewport-consistency.spec.ts new file mode 100644 index 00000000..1b2189e2 --- /dev/null +++ b/web/tests/e2e/viewport-consistency.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); + +for (const offscreen of [false, true]) { + test(`M7-15 ${offscreen ? "Offscreen" : "main-thread"} viewport uses the shared camera and selection contract`, async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto(offscreen ? "/?offscreen=1" : "/"); + await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); + await expect(page.getByText("BasicCube", { exact: true })).toBeVisible({ timeout: 20_000 }); + const canvas = page.locator("canvas.viewport-canvas"); + await expect(canvas).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr"); + if (offscreen) await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); + + const initial = await canvas.evaluate((element) => ({ + position: element.getAttribute("data-camera-position"), + target: element.getAttribute("data-camera-target"), + yaw: element.getAttribute("data-camera-yaw"), + pitch: element.getAttribute("data-camera-pitch"), + distance: element.getAttribute("data-camera-distance"), + })); + expect(initial.position).toBe("4.219781,-4.219781,3.658811"); + expect(initial.target).toBe("0,0,0"); + expect(initial.yaw).toBe("-0.785398"); + expect(initial.pitch).toBe("0.550000"); + expect(initial.distance).toBe("7.000000"); + + await canvas.evaluate((element, useOffscreen) => { + const bounds = element.getBoundingClientRect(); + const x = bounds.left + bounds.width / 2; + const y = bounds.top + bounds.height / 2; + element.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: x, clientY: y })); + if (useOffscreen) { + element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: x, clientY: y, pointerId: 11, buttons: 1 })); + element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, pointerId: 11, buttons: 0 })); + } + }, offscreen); + await expect(page.locator(".blender-app")).toHaveAttribute("data-selected-object-ids", /.+/); + + await canvas.evaluate((element) => element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 120 }))); + await expect.poll(async () => await canvas.getAttribute("data-camera-distance"), { timeout: 5_000 }).not.toBe(initial.distance); + const zoomed = await canvas.getAttribute("data-camera-distance"); + expect(Number(zoomed)).toBeCloseTo(7 * Math.exp(0.12), 4); + }); +} + +test("M7-15 main and Offscreen camera state stays identical for the same orbit input", async ({ browser }) => { + const states: Array> = []; + for (const offscreen of [false, true]) { + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + await page.goto(offscreen ? "/?offscreen=1" : "/"); + const canvas = page.locator("canvas.viewport-canvas"); + if (offscreen) await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); + await canvas.evaluate((element) => element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -80 }))); + await expect.poll(async () => await canvas.getAttribute("data-camera-distance"), { timeout: 5_000 }).not.toBe("7.000000"); + states.push(await canvas.evaluate((element) => ({ position: element.getAttribute("data-camera-position"), target: element.getAttribute("data-camera-target"), yaw: element.getAttribute("data-camera-yaw"), pitch: element.getAttribute("data-camera-pitch"), distance: element.getAttribute("data-camera-distance") }))); + await page.close(); + } + expect(states[0]).toEqual(states[1]); +}); diff --git a/web/tests/e2e/worker-crash-recovery.spec.ts b/web/tests/e2e/worker-crash-recovery.spec.ts new file mode 100644 index 00000000..5dc890c4 --- /dev/null +++ b/web/tests/e2e/worker-crash-recovery.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); + +for (const source of ["engine", "storage"] as const) { + test("M7-08 " + source + " Worker crash keeps the project visible and restores the operation log", async ({ page }) => { + await page.goto("/?worker-fault=" + source); + await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); + const app = page.locator(".blender-app"); + await page.getByTestId("blend-file-input").setInputFiles(basicBlend); + await expect(page.getByText("Cube", { exact: true })).toBeVisible(); + + const initialDownload = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + await initialDownload; + await expect(app).toHaveAttribute("data-dirty", "false"); + const committedRevision = await app.getAttribute("data-committed-main-revision"); + + await page.getByRole("button", { name: "添加立方体" }).click(); + await expect(app).toHaveAttribute("data-dirty", "true"); + const editedRevision = await app.getAttribute("data-current-main-revision"); + const editedStats = await page.getByTestId("scene-stats").textContent(); + await page.waitForTimeout(250); + + await page.getByTestId("inject-worker-crash").click(); + await expect(app).toHaveAttribute("data-worker-fault-source", source); + await expect(app).toHaveAttribute("data-worker-fault-code", "WORKER_TERMINATED"); + await expect(page.getByTestId("worker-fault-banner")).toContainText("current project list and scene are retained"); + await expect(app).toHaveAttribute("data-project-snapshot-revision", editedRevision ?? ""); + await expect(page.getByTestId("scene-stats")).toHaveText(editedStats ?? ""); + + await page.getByTestId("restart-and-recover").click(); + await expect(app).toHaveAttribute("data-worker-recovery-status", "SUCCEEDED", { timeout: 30_000 }); + await expect(page.getByTestId("worker-fault-banner")).toHaveCount(0); + await expect(app).toHaveAttribute("data-committed-main-revision", committedRevision ?? ""); + await expect(app).toHaveAttribute("data-dirty", "true"); + await expect(page.getByTestId("scene-stats")).toHaveText(editedStats ?? ""); + await expect(page.getByTestId("engine-status")).toContainText("project restored"); + }); +} diff --git a/web/tests/unit/compositor-unsupported-gate.test.mjs b/web/tests/unit/compositor-unsupported-gate.test.mjs new file mode 100644 index 00000000..98ae236a --- /dev/null +++ b/web/tests/unit/compositor-unsupported-gate.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "compositor-unsupported-unit-")); +const sourcePath = path.join(repoRoot, "web/protocol/compositor.ts"); +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, []); +fs.writeFileSync(path.join(temporary, "compositor.mjs"), transpiled.outputText.replaceAll('from "./capability-gates"', 'from "./capability-gates.mjs"')); +const gates = ts.transpileModule(fs.readFileSync(path.join(repoRoot, "web/protocol/capability-gates.ts"), "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: path.join(repoRoot, "web/protocol/capability-gates.ts"), + reportDiagnostics: true, +}); +assert.deepEqual(gates.diagnostics, []); +fs.writeFileSync(path.join(temporary, "capability-gates.mjs"), gates.outputText); +const compositor = await import(pathToFileURL(path.join(temporary, "compositor.mjs"))); + +const node = (id, type, properties = {}, blenderType) => ({ id, type, name: id, properties, ...(blenderType ? { blenderType } : {}) }); +const graph = () => ({ + schemaVersion: 1, + id: "compositor:unsupported", + name: "Unsupported", + outputNodeId: "out", + resources: [], + nodes: [ + node("color", "CONSTANT_COLOR", { color: [0.125, 0.25, 0.5, 0.75] }), + node("out", "COMPOSITE"), + node("glare", "UNSUPPORTED", {}, "CompositorNodeGlare"), + ], + links: [{ fromNodeId: "color", fromSocket: "Image", toNodeId: "out", toSocket: "Image" }], +}); + +test("M11-08 preserves the unsupported graph and blocks CPU execution before evaluation", async () => { + const value = graph(); + const before = structuredClone(value); + const gate = compositor.gateCompositorGraph(value, new Set()); + assert.equal(gate.status, "BLOCKED"); + assert.deepEqual(gate.issues.map((issue) => issue.code), ["COMPOSITOR_NODE_UNSUPPORTED"]); + let cancellationChecks = 0; + assert.throws( + () => compositor.executeCompositorGraph(value, new Map(), { width: 2, height: 2, cancelled: () => { cancellationChecks++; return false; } }), + { code: "COMPOSITOR_NODE_UNSUPPORTED" }, + ); + assert.equal(cancellationChecks, 0); + assert.deepEqual(value, before); +}); + +test("M11-08 blocks a cached unsupported graph before a cache hit", async () => { + const value = graph(); + const before = structuredClone(value); + const cache = new compositor.CompositorFrameCache(512); + const key = await compositor.compositorFrameCacheKey(value, new Map(), 1, 2, 2); + cache.set(key, { + composite: { width: 2, height: 2, data: new Float32Array(16), colorSpace: "LINEAR_SRGB" }, + viewers: new Map(), + evaluatedNodeIds: ["cached"], + }); + await assert.rejects( + () => compositor.executeCompositorGraphCached(value, new Map(), cache, { frame: 1, width: 2, height: 2 }), + { code: "COMPOSITOR_NODE_UNSUPPORTED" }, + ); + assert.equal(cache.size, 1); + assert.deepEqual(value, before); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/compositor-webgpu.test.mjs b/web/tests/unit/compositor-webgpu.test.mjs new file mode 100644 index 00000000..f12dbdb3 --- /dev/null +++ b/web/tests/unit/compositor-webgpu.test.mjs @@ -0,0 +1,89 @@ +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 { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "compositor-webgpu-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + 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 = replacements.reduce((value, [from, to]) => value.replaceAll(from, to), transpiled.outputText); + fs.writeFileSync(path.join(temporary, outputName), output); +} + +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("compositor.ts", "compositor.mjs", [ + ['from "./capability-gates"', 'from "./capability-gates.mjs"'], +]); +const compositor = await import(pathToFileURL(path.join(temporary, "compositor.mjs"))); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-07/compositor-node-golden.json"), "utf8")); +const sourceColor = [0.125, 0.25, 0.5, 0.75]; + +function graph(nodeTypes, extraNodes = [], resources = []) { + const nodes = nodeTypes.map((type, index) => ({ + id: `node:${index}`, + type, + name: `${type}:${index}`, + properties: type === "CONSTANT_COLOR" ? { color: sourceColor } : type === "EXPOSURE" ? { exposure: 1 } : {}, + })); + return { + schemaVersion: 1, + id: `graph:${nodeTypes.join("-")}`, + name: "M11-07 unit", + outputNodeId: nodes.at(-1).id, + nodes: [...nodes, ...extraNodes], + links: nodes.slice(1).map((node, index) => ({ fromNodeId: nodes[index].id, fromSocket: "Image", toNodeId: node.id, toSocket: "Image" })), + resources, + }; +} + +function repeatedPixel(pixel, count) { + const result = new Float32Array(count * 4); + for (let index = 0; index < count; index++) result.set(pixel, index * 4); + return result; +} + +test("M11-07 freezes only nodes with an independent CPU/WebGPU golden", () => { + assert.deepEqual(compositor.COMPOSITOR_WEBGPU_NODE_ALLOWLIST, golden.allowlist); + for (const candidate of golden.cases) { + const value = graph(candidate.nodeTypes); + const plan = compositor.compileCompositorWebGPUPlan(value); + assert.deepEqual(plan.instructions.map((instruction) => instruction.type), candidate.nodeTypes); + const cpu = compositor.executeCompositorGraph(value, new Map(), { width: golden.width, height: golden.height }); + const expected = repeatedPixel(candidate.pixel, golden.width * golden.height); + assert.deepEqual(cpu.composite.data, expected, candidate.scene); + assert.equal(crypto.createHash("sha256").update(new Uint8Array(cpu.composite.data.buffer)).digest("hex"), candidate.float32Sha256); + } +}); + +test("M11-07 rejects every CPU-only or undeclared node before WebGPU compilation", () => { + const properties = { ALPHA_OVER: {}, BLUR: { radius: 1 }, IMAGE: { resourceId: "resource:test" }, MIX: { factor: 0.5 }, RENDER_LAYER: { resourceId: "resource:test" }, TRANSFORM: {}, UNSUPPORTED: {}, VIEWER: {} }; + for (const type of golden.blockedNodeTypes) { + const node = { id: `blocked:${type}`, type, name: type, properties: properties[type], ...(type === "UNSUPPORTED" ? { blenderType: "CompositorNodeGlare" } : {}) }; + const resources = type === "IMAGE" || type === "RENDER_LAYER" ? [{ id: "resource:test", kind: type, sourceId: "image:test" }] : []; + assert.throws(() => compositor.compileCompositorWebGPUPlan(graph(["CONSTANT_COLOR", "COMPOSITE"], [node], resources)), { code: "COMPOSITOR_NODE_UNSUPPORTED" }); + } +}); + +test("M11-07 rejects disconnected allowlisted nodes and non-Image links", () => { + assert.throws( + () => compositor.compileCompositorWebGPUPlan(graph(["CONSTANT_COLOR", "COMPOSITE"], [{ id: "extra", type: "INVERT", name: "extra", properties: {} }])), + { code: "COMPOSITOR_GRAPH_INVALID" }, + ); + const malformed = graph(["CONSTANT_COLOR", "EXPOSURE", "COMPOSITE"]); + malformed.links[0].toSocket = "Value"; + assert.throws(() => compositor.compileCompositorWebGPUPlan(malformed), { code: "COMPOSITOR_GRAPH_INVALID" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/curve-topology-editor.test.mjs b/web/tests/unit/curve-topology-editor.test.mjs new file mode 100644 index 00000000..f83fd0db --- /dev/null +++ b/web/tests/unit/curve-topology-editor.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/curve-topology-editor.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "curve-topology-editor-unit-")); +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 modulePath = path.join(temporary, "curve-topology-editor.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const curve = await import(pathToFileURL(modulePath)); + +function claim(operator, selection) { + const base = { + schemaVersion: 1, + operator, + dataId: "curve:Curve", + baseRevision: 17, + inputSplineCount: 4, + inputPointCount: 12, + selectedSplineIndices: selection === "SPLINES" || selection === "POINTS_OR_SPLINES" ? [1] : [], + selectedPointIndices: selection === "POINTS" ? [2] : [], + addedSplineCount: 0, + addedPointCount: 0, + outputSplineCount: 4, + outputPointCount: 12, + payloadBytes: 256, + }; + if (operator === "ADD_SPLINE") return { ...base, addedSplineCount: 1, addedPointCount: 2, outputSplineCount: 5, outputPointCount: 14 }; + if (operator === "DUPLICATE") return { ...base, addedSplineCount: 1, addedPointCount: 2, outputSplineCount: 5, outputPointCount: 14 }; + if (operator === "EXTRUDE") return { ...base, addedPointCount: 1, outputPointCount: 13 }; + if (operator === "SPLIT") return { ...base, addedSplineCount: 1, outputSplineCount: 5 }; + if (operator === "SUBDIVIDE") return { ...base, addedPointCount: 2, outputPointCount: 14, subdivideCuts: 2 }; + return base; +} + +test("M9-04 freezes the Blender-sourced Curve topology allowlist and budgets", () => { + const manifest = curve.createCurveTopologyEditorManifest(); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.sourceAuthority, "blender-5.2.0/source/blender/editors/curve/curve_ops.cc"); + assert.equal(manifest.atomicMainTransaction, true); + assert.deepEqual(manifest.budget, { + maxSplines: 65_536, + maxPoints: 1_000_000, + maxSelectedElements: 100_000, + maxAddedSplinesPerOperation: 4_096, + maxAddedPointsPerOperation: 100_000, + maxPayloadBytes: 67_108_864, + maxSubdivideCuts: 64, + maxDataIdBytes: 256, + maxOperationsPerMainTransaction: 1, + }); + assert.deepEqual(manifest.operators.map((operator) => operator.id), [ + "ADD_SPLINE", "DECIMATE", "DELETE", "DISSOLVE_VERTICES", "DUPLICATE", "EXTRUDE", + "MAKE_SEGMENT", "SEPARATE", "SET_HANDLE_TYPE", "SET_SPLINE_TYPE", "SPLIT", "SUBDIVIDE", + "SWITCH_DIRECTION", "TOGGLE_CYCLIC", + ]); + assert.deepEqual(manifest.operators.filter((operator) => operator.gate.status === "READY").map((operator) => operator.id), ["TOGGLE_CYCLIC"]); + assert.ok(manifest.operators.filter((operator) => operator.id !== "TOGGLE_CYCLIC").every((operator) => operator.gate.status === "BLOCKED" && operator.gate.reasonCode === "CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED")); +}); + +test("M9-04 accepts one revision-bound budget claim for every allowlisted operator", () => { + for (const descriptor of curve.CURVE_TOPOLOGY_EDITOR_OPERATORS) { + const parsed = curve.parseCurveTopologyOperationClaim(claim(descriptor.id, descriptor.selection), 17); + assert.equal(parsed.operator, descriptor.id); + assert.equal(parsed.baseRevision, 17); + } +}); + +test("M9-04 rejects unknown, stale, ambiguous and over-budget Curve topology claims", () => { + const valid = claim("SUBDIVIDE", "POINTS_OR_SPLINES"); + const cases = [ + [{ ...valid, operator: "SPIN" }, "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED"], + [valid, "REVISION_CONFLICT", 18], + [{ ...valid, selectedSplineIndices: [1, 1] }, "NON_MESH_PROPERTY_INVALID"], + [{ ...valid, selectedPointIndices: [2] }, "NON_MESH_PROPERTY_INVALID"], + [{ ...valid, dataId: "data:Curve" }, "NON_MESH_PROPERTY_INVALID"], + [{ ...claim("DELETE", "POINTS_OR_SPLINES"), inputSplineCount: 0, selectedSplineIndices: [0] }, "NON_MESH_PROPERTY_INVALID"], + [{ ...valid, outputPointCount: 15 }, "NON_MESH_PROPERTY_INVALID"], + [{ ...valid, payloadBytes: curve.CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes + 1 }, "NON_MESH_DATA_BUDGET_EXCEEDED"], + [{ ...valid, subdivideCuts: 65 }, "NON_MESH_DATA_BUDGET_EXCEEDED"], + [{ ...claim("DELETE", "POINTS_OR_SPLINES"), subdivideCuts: 1 }, "NON_MESH_PROPERTY_INVALID"], + [{ ...valid, futureField: true }, "NON_MESH_PROPERTY_INVALID"], + ]; + for (const [value, code, revision = 17] of cases) { + assert.throws(() => curve.parseCurveTopologyOperationClaim(value, revision), (error) => error.code === code); + } +}); + +test("M9-05 builds one revision-bound TOGGLE_CYCLIC Main command", () => { + const operation = curve.buildCurveToggleCyclicOperation({ + schemaVersion: 1, + dataId: "curve:WebCurveData", + baseRevision: 23, + splineIndex: 1, + splineCount: 2, + pointCount: 7, + cyclicU: [false, true], + }, 23); + assert.deepEqual(operation.command, { + type: "setCurveTopology", + dataId: "curve:WebCurveData", + baseRevision: 23, + cyclicU: [false, false], + }); + assert.equal(operation.claim.operator, "TOGGLE_CYCLIC"); + assert.deepEqual(operation.claim.selectedSplineIndices, [1]); + assert.equal(operation.previousCyclic, true); + assert.equal(operation.nextCyclic, false); +}); + +test("M9-05 keeps stale and malformed TOGGLE_CYCLIC requests out of Main", () => { + const valid = { + schemaVersion: 1, + dataId: "curve:WebCurveData", + baseRevision: 23, + splineIndex: 0, + splineCount: 2, + pointCount: 7, + cyclicU: [false, false], + }; + assert.throws(() => curve.buildCurveToggleCyclicOperation(valid, 24), (error) => error.code === "REVISION_CONFLICT"); + for (const value of [ + { ...valid, splineIndex: 2 }, + { ...valid, splineCount: 0 }, + { ...valid, cyclicU: [false] }, + { ...valid, dataId: "surface:WebSurfaceData" }, + ]) { + assert.throws(() => curve.buildCurveToggleCyclicOperation(value, 23), (error) => error.code === "NON_MESH_PROPERTY_INVALID"); + } +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/diagnostic-report.test.mjs b/web/tests/unit/diagnostic-report.test.mjs new file mode 100644 index 00000000..06e5d567 --- /dev/null +++ b/web/tests/unit/diagnostic-report.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/diagnostic-report.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const { APP_DIAGNOSTIC_MESSAGES, appendAppDiagnostic, createAppDiagnosticEntry, createAppDiagnosticReport } = await import(moduleUrl); + +test("M7-17 keeps user summaries stable while retaining detailed causes", () => { + const cause = new Error("native allocator detail"); + const error = new Error("WORKER_CRASH_INJECTED: engine.worker.ts:91", { cause }); + error.code = "WORKER_TERMINATED"; + const entry = createAppDiagnosticEntry({ + sequence: 1, + occurredAt: "2026-08-15T20:00:00.000Z", + area: "ENGINE", + code: "ENGINE_WORKER_TERMINATED", + error, + context: { projectId: "basic_scene", revision: 9 }, + }); + assert.equal(entry.summary, APP_DIAGNOSTIC_MESSAGES.ENGINE_WORKER_TERMINATED); + assert.equal(entry.summary.includes("WORKER_CRASH_INJECTED"), false); + assert.match(entry.detail, /WORKER_CRASH_INJECTED/); + assert.equal(entry.sourceCode, "WORKER_TERMINATED"); + assert.equal(entry.cause, "native allocator detail"); +}); + +test("M7-17 bounds the ledger and exports a sequence-sorted schema v1 report", () => { + const entries = [3, 1, 2].map((sequence) => createAppDiagnosticEntry({ + sequence, + occurredAt: `2026-08-15T20:00:0${sequence}.000Z`, + area: "STORAGE", + code: "STORAGE_BUDGET_FAILED", + error: { code: "STORAGE_TRANSACTION", message: `detail-${sequence}` }, + })); + assert.deepEqual(appendAppDiagnostic(entries.slice(0, 2), entries[2], 2).map((entry) => entry.sequence), [1, 2]); + const report = createAppDiagnosticReport({ + generatedAt: "2026-08-15T20:01:00.000Z", + runtime: { url: "http://127.0.0.1:5173/", userAgent: "test", language: "zh-CN", crossOriginIsolated: true }, + project: { projectId: "basic_scene", revision: 9 }, + entries, + }); + assert.equal(report.schemaVersion, 1); + assert.equal(report.product, "Web Blender Modeler V1"); + assert.deepEqual(report.entries.map((entry) => entry.sequence), [1, 2, 3]); + assert.deepEqual(report.entries.map((entry) => entry.detail), ["detail-1", "detail-2", "detail-3"]); +}); + +test("M7-17 user-visible status setters never interpolate raw exception detail", () => { + const appSource = fs.readFileSync(path.join(repoRoot, "web/app/src/app/App.tsx"), "utf8"); + const statusLines = appSource.split("\n").filter((line) => /set(?:Engine|Storage|Wasm|Manifest)Status\(|setViewportError\(/.test(line)); + assert.ok(statusLines.length > 20, "expected to audit all user-visible status boundaries"); + for (const line of statusLines) { + assert.doesNotMatch(line, /error\.message|errorMessage\(|fault\.error\.message/, line.trim()); + } + assert.doesNotMatch(appSource, /\{fault\.error\.message\}/); +}); diff --git a/web/tests/unit/editing-domain-recovery.test.mjs b/web/tests/unit/editing-domain-recovery.test.mjs new file mode 100644 index 00000000..07f707a9 --- /dev/null +++ b/web/tests/unit/editing-domain-recovery.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/editing-domain-recovery.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const recovery = await import(moduleUrl); + +const hash = "a".repeat(64); +const base = (domain, dataId) => ({ + schemaVersion: 1, + domain, + baseline: { objectIds: [`object:${domain}`], dataIds: [dataId], objectCount: 1, revision: 4, identityHash: hash }, + workerRestart: { status: "RECOVERED", workerGeneration: 2, revisionBefore: 4, revisionAfter: 4, hashBefore: hash, hashAfter: hash, liveHandles: 1, temporaryResourcesAfter: 0 }, + oom: { status: "RECOVERED", faultPoint: "GPU_GEOMETRY_UPLOAD", code: "GPU_GEOMETRY_BUDGET_EXCEEDED", revisionBefore: 4, revisionAfter: 4, hashBefore: hash, hashAfter: hash, releasedBytes: 4096, temporaryResourcesAfter: 0 }, + gpuRelease: { status: "RECOVERED", backend: "WEBGL2", releaseCount: 1, reinitCount: 1, disposedResources: 6, visiblePixels: 12, pixelHashBefore: hash, pixelHashAfter: hash }, + smallScene: { status: "RECOVERED", revision: 4, identityHash: hash, objectCount: 1, dataIds: [dataId], visiblePixels: 12 }, +}); + +test("M9-14 parses all three editing domains and preserves recovery invariants", () => { + const reports = recovery.parseEditingDomainRecoverySuite([ + base("CURVE", "curve:Recovery"), + base("GREASE_PENCIL", "grease-pencil:Recovery"), + base("PAINT", "mesh:Recovery"), + ]); + assert.deepEqual(reports.map((report) => report.domain), ["CURVE", "GREASE_PENCIL", "PAINT"]); + assert.equal(reports.every((report) => report.workerRestart.hashAfter === report.baseline.identityHash), true); + assert.equal(reports.every((report) => report.oom.releasedBytes > 0 && report.gpuRelease.visiblePixels > 0), true); +}); + +test("M9-14 rejects stale identity, duplicate domains and GPU release drift", () => { + const curve = base("CURVE", "curve:Recovery"); + const stale = structuredClone(curve); + stale.smallScene.identityHash = "b".repeat(64); + assert.throws(() => recovery.parseEditingDomainRecoveryEvidence(stale), /smallScene identity/); + assert.throws(() => recovery.parseEditingDomainRecoverySuite([curve, curve, base("PAINT", "mesh:Recovery")]), /duplicate editing domain/); + const releaseDrift = structuredClone(curve); + releaseDrift.gpuRelease.releaseCount = 2; + assert.throws(() => recovery.parseEditingDomainRecoveryEvidence(releaseDrift), /release and reinitialize exactly once/); +}); + +test("M9-14 summarizes only visible objects in the requested editing domain", () => { + const snapshot = { + nodes: [ + { id: "object:curve", type: "CURVE", visible: true, dataId: "curve:one" }, + { id: "object:hidden", type: "CURVE", visible: false, dataId: "curve:two" }, + { id: "object:mesh", type: "MESH", visible: true, dataId: "mesh:one" }, + ], + }; + assert.deepEqual(recovery.summarizeEditingDomain(snapshot, "CURVE"), { objectIds: ["object:curve"], dataIds: ["curve:one"], objectCount: 1 }); + assert.deepEqual(recovery.summarizeEditingDomain(snapshot, "PAINT"), { objectIds: ["object:mesh"], dataIds: ["mesh:one"], objectCount: 1 }); + assert.throws(() => recovery.summarizeEditingDomain({ nodes: [] }, "GREASE_PENCIL"), /DOMAIN_MISSING/); +}); diff --git a/web/tests/unit/external-vfont.test.mjs b/web/tests/unit/external-vfont.test.mjs new file mode 100644 index 00000000..aa09f113 --- /dev/null +++ b/web/tests/unit/external-vfont.test.mjs @@ -0,0 +1,165 @@ +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 { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "external-vfont-unit-")); +for (const name of ["asset-path", "external-vfont"]) { + const sourcePath = path.join(repoRoot, `web/protocol/${name}.ts`); + 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, []); + fs.writeFileSync(path.join(temporary, `${name}.mjs`), transpiled.outputText.replace('"./asset-path"', '"./asset-path.mjs"')); +} +const font = await import(pathToFileURL(path.join(temporary, "external-vfont.mjs"))); +const importSourcePath = path.join(repoRoot, "web/app/src/fonts/external-vfont-import.ts"); +const importTranspiled = ts.transpileModule(fs.readFileSync(importSourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: importSourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(importTranspiled.diagnostics, []); +fs.writeFileSync(path.join(temporary, "external-vfont-import.mjs"), importTranspiled.outputText.replace('"../../../protocol/external-vfont"', '"./external-vfont.mjs"')); +const importer = await import(pathToFileURL(path.join(temporary, "external-vfont-import.mjs"))); +const pfb = fs.readFileSync(path.join(repoRoot, "blender-5.2.0/release/datafiles/bfont.pfb")); +const data = pfb.buffer.slice(pfb.byteOffset, pfb.byteOffset + pfb.byteLength); +const digest = crypto.createHash("sha256").update(pfb).digest("hex"); +const request = { sourcePath: "//fonts/bfont.pfb", mimeType: "application/x-font-type1", byteLength: pfb.byteLength, sha256: digest, data }; + +test("M9-01 validates a real project-relative PFB before storage or Main mutation", async () => { + const protocolSource = fs.readFileSync(path.join(repoRoot, "web/protocol/external-vfont.ts"), "utf8"); + assert.doesNotMatch(protocolSource, /StorageClient|WebEngineClient|storage\.worker|web-engine\.worker/); + const validated = await font.validateExternalVFontImport(request); + assert.deepEqual({ ...validated, data: undefined }, { + schemaVersion: 1, + sourcePath: "//fonts/bfont.pfb", + fileName: "bfont.pfb", + format: "PFB", + mimeType: "application/x-font-type1", + byteLength: 25181, + sha256: "a33954fdab9fb09b9d308cb7f970518293128922ffc523c0a22b3b314a9a56c6", + data: undefined, + }); + assert.notEqual(validated.data, request.data); + assert.deepEqual(new Uint8Array(validated.data), new Uint8Array(request.data)); +}); + +test("M9-01 blocks path escape, type spoofing, size overflow and hash drift", async () => { + const cases = [ + [{ ...request, sourcePath: "../../outside.pfb" }, "NON_MESH_RESOURCE_OUTSIDE_PROJECT"], + [{ ...request, sourcePath: "//assets/bfont.pfb" }, "NON_MESH_RESOURCE_OUTSIDE_PROJECT"], + [{ ...request, sourcePath: "//fonts/bfont.ttf", mimeType: "font/ttf" }, "NON_MESH_BINARY_INVALID"], + [{ ...request, mimeType: "font/otf" }, "NON_MESH_BINARY_INVALID"], + [{ ...request, byteLength: font.EXTERNAL_VFONT_MAX_BYTES + 1 }, "NON_MESH_DATA_BUDGET_EXCEEDED"], + [{ ...request, byteLength: request.byteLength - 1 }, "NON_MESH_BINARY_INVALID"], + [{ ...request, sha256: "0".repeat(64) }, "ASSET_SOURCE_HASH_MISMATCH"], + ]; + for (const [value, code] of cases) await assert.rejects(font.validateExternalVFontImport(value), (error) => error.code === code); +}); + +test("M9-02 accepts only a matching OPFS content-addressed receipt before Main import", async () => { + const validated = await font.validateExternalVFontImport(request); + const stored = { + assetId: `sha256:${digest}`, + projectId: "m9-vfont-unit", + sha256: digest, + bytes: pfb.byteLength, + mimeType: request.mimeType, + sourcePath: "fonts/bfont.pfb", + path: `projects/m9-vfont-unit/assets/sha256/${digest.slice(0, 2)}/${digest}`, + createdAt: "2026-08-16T00:00:00.000Z", + lastAccessAt: "2026-08-16T00:00:00.000Z", + persisted: true, + deduplicated: false, + }; + const mainImport = font.createExternalVFontMainImport(validated, stored); + assert.deepEqual({ ...mainImport, data: undefined }, { + schemaVersion: 1, + projectId: stored.projectId, + assetId: stored.assetId, + assetPath: stored.path, + sourcePath: "//fonts/bfont.pfb", + name: "bfont", + format: "PFB", + mimeType: request.mimeType, + byteLength: pfb.byteLength, + sha256: digest, + data: undefined, + }); + assert.notEqual(mainImport.data, validated.data); + for (const receipt of [ + { ...stored, persisted: false }, + { ...stored, sha256: "0".repeat(64) }, + { ...stored, bytes: stored.bytes - 1 }, + { ...stored, path: `indexeddb:${stored.projectId}:${digest}` }, + ]) { + assert.throws(() => font.createExternalVFontMainImport(validated, receipt), (error) => + error.code === "ASSET_SOURCE_HASH_MISMATCH" || error.code === "NON_MESH_RESOURCE_MISSING"); + } +}); + +test("M9-03 verifies the project asset before one Main font-style replacement", async () => { + const stored = { + asset: { + assetId: `sha256:${digest}`, + projectId: "m9-vfont-unit", + sha256: digest, + bytes: pfb.byteLength, + mimeType: request.mimeType, + sourcePath: "fonts/bfont.pfb", + path: `projects/m9-vfont-unit/assets/sha256/${digest.slice(0, 2)}/${digest}`, + createdAt: "2026-08-16T00:00:00.000Z", + lastAccessAt: "2026-08-16T00:00:00.000Z", + }, + data: data.slice(0), + }; + const vfont = { id: "vfont:bfont", name: "bfont", sourcePath: "//fonts/bfont.pfb", builtin: false, packed: true, packedByteLength: pfb.byteLength, sha256: digest }; + const original = { regular: "vfont:Bfont", bold: "vfont:Bfont", italic: "vfont:Bfont", boldItalic: "vfont:Bfont" }; + const snapshot = { nonMeshData: [{ id: "data:font", type: "FONT", fontLinks: original }], vfonts: [vfont] }; + const events = []; + const result = await importer.replaceExternalVFontStyleInMain({ + projectId: stored.asset.projectId, + sha256: digest, + dataId: "data:font", + vfontId: vfont.id, + style: "regular", + snapshot, + storage: { readAsset: async () => { events.push("storage:verified"); return stored; } }, + engine: { applyCommand: async (command) => { + events.push("main:committed"); + return { snapshot: { ...snapshot, nonMeshData: [{ ...snapshot.nonMeshData[0], fontLinks: command.links }] } }; + } }, + }); + assert.deepEqual(events, ["storage:verified", "main:committed"]); + assert.deepEqual(result.previousLinks, original); + assert.equal(result.links.regular, vfont.id); +}); + +test("M9-03 blocks a missing project asset before Main replacement", async () => { + let mainCalls = 0; + const snapshot = { + nonMeshData: [{ id: "data:font", type: "FONT", fontLinks: { regular: "vfont:Bfont", bold: "vfont:Bfont", italic: "vfont:Bfont", boldItalic: "vfont:Bfont" } }], + vfonts: [{ id: "vfont:bfont", name: "bfont", sourcePath: "//fonts/bfont.pfb", builtin: false, packed: true, packedByteLength: pfb.byteLength, sha256: digest }], + }; + await assert.rejects(importer.replaceExternalVFontStyleInMain({ + projectId: "m9-vfont-unit", + sha256: digest, + dataId: "data:font", + vfontId: "vfont:bfont", + style: "regular", + snapshot, + storage: { readAsset: async () => { const error = new Error("missing"); error.code = "NON_MESH_RESOURCE_MISSING"; throw error; } }, + engine: { applyCommand: async () => { mainCalls += 1; throw new Error("unexpected Main call"); } }, + }), (error) => error.code === "NON_MESH_RESOURCE_MISSING"); + assert.equal(mainCalls, 0); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/geometry-nodes.test.mjs b/web/tests/unit/geometry-nodes.test.mjs new file mode 100644 index 00000000..60f7096f --- /dev/null +++ b/web/tests/unit/geometry-nodes.test.mjs @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "geometry-nodes-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + const sourcePath = path.join(repoRoot, "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 = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText); + fs.writeFileSync(path.join(temporary, outputName), output); +} + +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("geometry-nodes.ts", "geometry-nodes.mjs", [ + ['from "./capability-gates"', 'from "./capability-gates.mjs"'], +]); +const geometryNodes = await import(pathToFileURL(path.join(temporary, "geometry-nodes.mjs"))); + +function graph() { + return { + schemaVersion: 1, + id: "node-group:Unit", + name: "Unit", + interfaceInputs: [{ id: "input:geometry", name: "Geometry", direction: "INPUT", dataType: "GEOMETRY" }], + interfaceOutputs: [{ id: "output:geometry", name: "Geometry", direction: "OUTPUT", dataType: "GEOMETRY" }], + nodes: [{ + id: "geometry-node:7", + type: "GeometryNodeTransform", + name: "Transform Geometry", + sockets: [ + { id: "input:mode", name: "Mode", direction: "INPUT", dataType: "MENU", defaultValue: 0 }, + { id: "input:rotation", name: "Rotation", direction: "INPUT", dataType: "ROTATION", defaultValue: [0, 0, 0] }, + { id: "input:matrix", name: "Transform", direction: "INPUT", dataType: "MATRIX" }, + { id: "output:geometry", name: "Geometry", direction: "OUTPUT", dataType: "GEOMETRY" }, + ], + }], + links: [], + groupReferences: [], + graphHash: "a".repeat(64), + }; +} + +test("M10-01 parses Blender Main socket types and stable graph identities", () => { + const parsed = geometryNodes.parseGeometryNodeGraph(graph()); + assert.equal(parsed.id, "node-group:Unit"); + assert.deepEqual(parsed.nodes[0].sockets.map((socket) => socket.dataType), ["MENU", "ROTATION", "MATRIX", "GEOMETRY"]); + assert.equal(geometryNodes.validateGeometryNodeGraph(parsed).status, "SUPPORTED"); + assert.deepEqual(geometryNodes.GEOMETRY_NODE_GRAPH_BUDGET, { + maxGraphs: 4_096, + maxNodesPerGraph: 4_096, + maxLinksPerGraph: 16_384, + maxSocketsPerGraph: 65_536, + maxInterfaceSocketsPerGraph: 4_096, + maxIdentifierBytes: 256, + maxNameBytes: 1_024, + }); +}); + +test("M10-01 rejects duplicate stable IDs and malformed graph hashes", () => { + const duplicateNode = graph(); + duplicateNode.nodes.push(structuredClone(duplicateNode.nodes[0])); + assert.throws(() => geometryNodes.parseGeometryNodeGraph(duplicateNode), { code: "GN_INVALID_GRAPH" }); + + const duplicateSocket = graph(); + duplicateSocket.nodes[0].sockets.push(structuredClone(duplicateSocket.nodes[0].sockets[0])); + assert.throws(() => geometryNodes.parseGeometryNodeGraph(duplicateSocket), { code: "GN_INVALID_GRAPH" }); + assert.throws(() => geometryNodes.parseGeometryNodeGraph({ ...graph(), graphHash: "A".repeat(64) }), { code: "GN_INVALID_GRAPH" }); +}); + +test("M10-01 fails closed before oversized graph topology enters SceneIR", () => { + const oversized = graph(); + oversized.nodes = Array.from({ length: geometryNodes.GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph + 1 }, + (_value, index) => ({ id: `node:${index}`, type: "NodeGroupInput", name: "Input", sockets: [] })); + assert.throws(() => geometryNodes.parseGeometryNodeGraph(oversized), { code: "GN_GRAPH_BUDGET_EXCEEDED" }); + const graphSet = Array.from({ length: geometryNodes.GEOMETRY_NODE_GRAPH_BUDGET.maxGraphs + 1 }, graph); + assert.deepEqual(geometryNodes.validateGeometryNodeGraphSet(graphSet).issues.map((issue) => issue.code), ["GN_GRAPH_BUDGET_EXCEEDED"]); +}); + +test("M10-02 freezes the allowlist and blocks unsupported nodes without rewriting the graph", () => { + const allowed = graph(); + const before = structuredClone(allowed); + assert.equal(geometryNodes.GEOMETRY_NODE_ALLOWLIST_SCHEMA, 1); + assert.deepEqual(geometryNodes.GEOMETRY_NODE_ALLOWLIST, [ + "NodeGroupInput", + "NodeGroupOutput", + "GeometryNodeTransform", + "GeometryNodeSetPosition", + "GeometryNodeJoinGeometry", + "GeometryNodeSeparateGeometry", + "GeometryNodeRealizeInstances", + "GeometryNodeStoreNamedAttribute", + "FunctionNodeInputInt", + "FunctionNodeInputVector", + "FunctionNodeCompare", + "ShaderNodeValue", + "ShaderNodeMath", + "GeometryNodeObjectInfo", + "GeometryNodeCollectionInfo", + "GeometryNodeImageInfo", + ]); + assert.equal(geometryNodes.gateGeometryNodeGraph(allowed).status, "READY"); + + allowed.nodes.push({ + id: "geometry-node:8", + type: "GeometryNodeSimulationOutput", + name: "Simulation Output", + sockets: [], + }); + const blocked = geometryNodes.gateGeometryNodeGraph(allowed); + assert.equal(blocked.status, "BLOCKED"); + assert.deepEqual(blocked.issues.map((issue) => issue.code), ["GN_NODE_UNSUPPORTED"]); + assert.deepEqual(before, graph()); + assert.equal(allowed.nodes.at(-1).type, "GeometryNodeSimulationOutput"); +}); + +function domainCardinality(overrides = {}) { + return { + POINT: 8, + EDGE: 12, + FACE: 6, + CORNER: 24, + CURVE: 0, + INSTANCE: 0, + LAYER: 0, + ...overrides, + }; +} + +function field(overrides = {}) { + return { + schemaVersion: 1, + graphId: "node-group:Unit", + graphHash: "a".repeat(64), + fieldId: "field:position", + revision: 7, + sourceDomain: "POINT", + targetDomain: "CORNER", + dataType: "FLOAT", + transport: "JSON", + domainCardinality: domainCardinality(), + ...overrides, + }; +} + +test("M10-04 binds field materialization to exact domain cardinality and byte budgets", () => { + assert.equal(geometryNodes.GEOMETRY_NODE_FIELD_SCHEMA, 1); + assert.deepEqual(geometryNodes.GEOMETRY_NODE_FIELD_DOMAIN_BUDGET, { + POINT: 1_000_000, + EDGE: 2_000_000, + FACE: 2_000_000, + CORNER: 4_000_000, + CURVE: 100_000, + INSTANCE: 100_000, + LAYER: 4_096, + }); + assert.deepEqual(geometryNodes.GEOMETRY_NODE_FIELD_BUDGET, { + maxFieldsPerBatch: 64, + maxDomainConversionsPerBatch: 32, + maxMaterializedElementsPerBatch: 4_000_000, + maxMaterializedBytesPerBatch: 64 * 1024 * 1024, + maxJsonScalarValuesPerField: 65_536, + maxIdentifierBytes: 256, + }); + + const batch = geometryNodes.parseGeometryNodeFieldMaterializationBatch([ + field(), + field({ + fieldId: "field:offset", + sourceDomain: "CONSTANT", + targetDomain: "POINT", + dataType: "VECTOR", + }), + ]); + assert.equal(batch.fieldCount, 2); + assert.equal(batch.domainConversionCount, 1); + assert.equal(batch.materializedElementCount, 32); + assert.equal(batch.materializedByteLength, 192); + assert.deepEqual(batch.fields.map((entry) => ({ + source: entry.sourceElementCount, + target: entry.targetElementCount, + scalars: entry.scalarValueCount, + bytes: entry.materializedByteLength, + conversion: entry.domainConversion, + })), [ + { source: 8, target: 24, scalars: 24, bytes: 96, conversion: true }, + { source: 1, target: 8, scalars: 24, bytes: 96, conversion: false }, + ]); +}); + +test("M10-04 blocks unbounded JSON fields, cardinality drift and aggregate overflow", () => { + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterializationBatch({}), { code: "GN_INVALID_GRAPH" }); + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization({ + ...field(), + values: Array(24).fill(0), + }), { code: "GN_FIELD_JSON_BUDGET_EXCEEDED" }); + + const largeCardinality = domainCardinality({ POINT: 100_000, CORNER: 300_000 }); + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({ + targetDomain: "POINT", + dataType: "VECTOR", + domainCardinality: largeCardinality, + })), { code: "GN_FIELD_JSON_BUDGET_EXCEEDED" }); + assert.equal(geometryNodes.parseGeometryNodeFieldMaterialization(field({ + targetDomain: "POINT", + dataType: "VECTOR", + transport: "BINARY", + domainCardinality: largeCardinality, + })).materializedByteLength, 1_200_000); + + const missingDomain = domainCardinality(); + delete missingDomain.LAYER; + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({ + domainCardinality: missingDomain, + })), { code: "GN_DOMAIN_CARDINALITY_MISMATCH" }); + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({ + domainCardinality: { ...domainCardinality(), VOXEL: 1 }, + })), { code: "GN_DOMAIN_CARDINALITY_MISMATCH" }); + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({ + dataType: "toString", + })), { code: "GN_INVALID_GRAPH" }); + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({ + domainCardinality: domainCardinality({ EDGE: 2_000_001 }), + })), { code: "GN_FIELD_BUDGET_EXCEEDED" }); + + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterializationBatch([ + field({ + fieldId: "field:large-a", + targetDomain: "CORNER", + dataType: "COLOR", + transport: "BINARY", + domainCardinality: domainCardinality({ CORNER: 2_500_000 }), + }), + field({ + fieldId: "field:large-b", + targetDomain: "CORNER", + dataType: "COLOR", + transport: "BINARY", + domainCardinality: domainCardinality({ CORNER: 2_500_000 }), + }), + ]), { code: "GN_FIELD_BUDGET_EXCEEDED" }); + + assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterializationBatch( + Array.from({ length: 33 }, (_value, index) => field({ fieldId: `field:conversion-${index}` })), + ), { code: "GN_FIELD_BUDGET_EXCEEDED" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/grease-pencil-marquee.test.mjs b/web/tests/unit/grease-pencil-marquee.test.mjs new file mode 100644 index 00000000..c664f99e --- /dev/null +++ b/web/tests/unit/grease-pencil-marquee.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/grease-pencil-marquee.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "grease-pencil-marquee-unit-")); +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 modulePath = path.join(temporary, "grease-pencil-marquee.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const marquee = await import(pathToFileURL(modulePath)); + +const drawing = { + dataId: "grease-pencil:Data", + layerId: "grease-pencil-layer:Data:Lines", + frame: 12, + drawingId: "grease-pencil-drawing:Data:4", +}; + +function point(strokeIndex, pointIndex, viewportPosition) { + return { + ...drawing, + strokeId: `grease-pencil-stroke:Data:4:${strokeIndex}`, + pointId: `grease-pencil-point:Data:4:${strokeIndex}:${pointIndex}`, + strokeIndex, + pointIndex, + viewportPosition, + }; +} + +function request(candidates) { + return { + schemaVersion: 1, + baseRevision: 27, + baseSelectionRevision: 4, + drawing, + box: { left: 0.2, top: 0.2, right: 0.8, bottom: 0.8 }, + candidates, + }; +} + +test("M9-06 selects stable point and stroke IDs only inside the current drawing marquee", () => { + const result = marquee.selectGreasePencilMarquee(request([ + point(1, 1, [0.5, 0.5]), + point(0, 2, [0.8, 0.2]), + point(0, 0, [0.1, 0.5]), + ]), 27); + assert.deepEqual(result.drawing, drawing); + assert.equal(result.baseSelectionRevision, 4); + assert.deepEqual(result.selectedStrokeIds, [ + "grease-pencil-stroke:Data:4:0", + "grease-pencil-stroke:Data:4:1", + ]); + assert.deepEqual(result.selectedPoints.map(({ pointId, strokeId, strokeIndex, pointIndex }) => ({ pointId, strokeId, strokeIndex, pointIndex })), [ + { pointId: "grease-pencil-point:Data:4:0:2", strokeId: "grease-pencil-stroke:Data:4:0", strokeIndex: 0, pointIndex: 2 }, + { pointId: "grease-pencil-point:Data:4:1:1", strokeId: "grease-pencil-stroke:Data:4:1", strokeIndex: 1, pointIndex: 1 }, + ]); +}); + +test("M9-06 rejects stale, foreign-drawing, duplicate and forged marquee candidates", () => { + assert.throws(() => marquee.selectGreasePencilMarquee(request([point(0, 0, [0.5, 0.5])]), 28), (error) => error.code === "REVISION_CONFLICT"); + assert.throws(() => marquee.selectGreasePencilMarquee(request([{ ...point(0, 0, [0.5, 0.5]), drawingId: "grease-pencil-drawing:Data:5" }]), 27), (error) => error.code === "GREASE_PENCIL_SELECTION_SCOPE_INVALID"); + const duplicate = point(0, 0, [0.5, 0.5]); + assert.throws(() => marquee.selectGreasePencilMarquee(request([duplicate, { ...duplicate }]), 27), (error) => error.code === "GREASE_PENCIL_SELECTION_INVALID"); + assert.throws(() => marquee.selectGreasePencilMarquee(request([{ ...point(0, 0, [0.5, 0.5]), pointId: "point:0" }]), 27), (error) => error.code === "GREASE_PENCIL_SELECTION_INVALID"); + assert.throws(() => marquee.selectGreasePencilMarquee({ ...request([]), box: { left: 0.8, top: 0.2, right: 0.2, bottom: 0.8 } }, 27), (error) => error.code === "GREASE_PENCIL_SELECTION_INVALID"); +}); + +test("M9-06 rejects a marquee candidate list over the bounded drawing point budget", () => { + const candidates = new Array(marquee.GREASE_PENCIL_MARQUEE_BUDGET.maxCandidates + 1).fill(null); + assert.throws(() => marquee.selectGreasePencilMarquee(request(candidates), 27), (error) => error.code === "GREASE_PENCIL_BUDGET_EXCEEDED"); +}); diff --git a/web/tests/unit/grease-pencil-reorder.test.mjs b/web/tests/unit/grease-pencil-reorder.test.mjs new file mode 100644 index 00000000..e7503f09 --- /dev/null +++ b/web/tests/unit/grease-pencil-reorder.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/grease-pencil-reorder.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "grease-pencil-reorder-unit-")); +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 modulePath = path.join(temporary, "grease-pencil-reorder.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const reorder = await import(pathToFileURL(modulePath)); + +const dataId = "grease-pencil:Data"; +const linesId = "grease-pencil-layer:Data:Lines"; +const draftsId = "grease-pencil-layer:Data:Web Drafts"; +const drawingId = "grease-pencil-drawing:Data:4"; +const drawing = { id: drawingId, strokeCount: 0, pointCount: 0, strokes: [] }; +const data = [{ + id: dataId, + name: "Data", + geometryStatus: "available", + layerCount: 2, + frameCount: 2, + strokeCount: 0, + pointCount: 0, + layers: [ + { id: linesId, name: "Lines", visible: true, locked: false, opacity: 1, frames: [{ frame: 1, drawing: { ...drawing, id: "grease-pencil-drawing:Data:0" } }] }, + { id: draftsId, name: "Web Drafts", visible: true, locked: false, opacity: 1, frames: [{ frame: 1, drawing }] }, + ], +}]; + +test("M9-08 binds layer and frame reorder commands to stable IDs and Main revision", () => { + const layer = reorder.validateGreasePencilReorderCommand({ + type: "moveGreasePencilLayer", + schemaVersion: 1, + dataId, + layerId: draftsId, + direction: "DOWN", + baseRevision: 27, + }, 27, data); + assert.deepEqual(layer, { + type: "moveGreasePencilLayer", + schemaVersion: 1, + dataId, + layerId: draftsId, + direction: "DOWN", + baseRevision: 27, + }); + + const frame = reorder.validateGreasePencilReorderCommand({ + type: "moveGreasePencilFrame", + schemaVersion: 1, + dataId, + layerId: draftsId, + frame: 1, + targetFrame: 12, + drawingId, + baseRevision: 27, + }, 27, data); + assert.equal(frame.drawingId, drawingId); + assert.equal(frame.targetFrame, 12); +}); + +test("M9-08 rejects stale, no-op, forged drawing and occupied-target reorders", () => { + const layerCommand = { type: "moveGreasePencilLayer", schemaVersion: 1, dataId, layerId: draftsId, direction: "DOWN", baseRevision: 27 }; + assert.throws(() => reorder.validateGreasePencilReorderCommand(layerCommand, 28, data), (error) => error.code === "REVISION_CONFLICT"); + assert.throws(() => reorder.validateGreasePencilReorderCommand({ ...layerCommand, layerId: linesId }, 27, data), (error) => error.code === "GREASE_PENCIL_SCHEMA_INVALID"); + + const frameCommand = { type: "moveGreasePencilFrame", schemaVersion: 1, dataId, layerId: draftsId, frame: 1, targetFrame: 12, drawingId, baseRevision: 27 }; + assert.throws(() => reorder.validateGreasePencilReorderCommand({ ...frameCommand, drawingId: "grease-pencil-drawing:Data:5" }, 27, data), (error) => error.code === "GREASE_PENCIL_SCHEMA_INVALID"); + assert.throws(() => reorder.validateGreasePencilReorderCommand({ ...frameCommand, targetFrame: 1 }, 27, data), (error) => error.code === "GREASE_PENCIL_SCHEMA_INVALID"); + const occupied = [{ ...data[0], layers: data[0].layers.map((layer) => layer.id === draftsId ? { ...layer, frames: [...layer.frames, { frame: 12, drawing: { ...drawing, id: "grease-pencil-drawing:Data:5" } }] } : layer) }]; + assert.throws(() => reorder.validateGreasePencilReorderCommand(frameCommand, 27, occupied), (error) => error.code === "GREASE_PENCIL_SCHEMA_INVALID"); +}); diff --git a/web/tests/unit/grease-pencil-selection.test.mjs b/web/tests/unit/grease-pencil-selection.test.mjs new file mode 100644 index 00000000..107183d6 --- /dev/null +++ b/web/tests/unit/grease-pencil-selection.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/grease-pencil-selection.ts"); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "grease-pencil-selection-unit-")); +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 modulePath = path.join(temporary, "grease-pencil-selection.mjs"); +fs.writeFileSync(modulePath, transpiled.outputText); +const selection = await import(pathToFileURL(modulePath)); + +const drawing = { + dataId: "grease-pencil:Data", + layerId: "grease-pencil-layer:Data:Lines", + frame: 12, + drawingId: "grease-pencil-drawing:Data:4", +}; + +function point(strokeIndex, pointIndex) { + return { + ...drawing, + strokeId: `grease-pencil-stroke:Data:4:${strokeIndex}`, + pointId: `grease-pencil-point:Data:4:${strokeIndex}:${pointIndex}`, + strokeIndex, + pointIndex, + }; +} + +function edit(baseSelectionRevision, source, operation, points) { + return { schemaVersion: 1, baseSelectionRevision, source, operation, points }; +} + +test("M9-07 serializes 2D canvas and 3D viewport edits through one selection revision", () => { + const initial = selection.createGreasePencilSelectionState(drawing); + const canvas = selection.applyGreasePencilSelectionEdit(initial, edit(0, "CANVAS_2D", "REPLACE", [point(0, 1)]), drawing); + assert.equal(canvas.revision, 1); + assert.equal(canvas.lastSource, "CANVAS_2D"); + assert.deepEqual(canvas.selectedPoints.map((item) => item.pointId), [point(0, 1).pointId]); + + const viewport = selection.applyGreasePencilSelectionEdit(canvas, edit(1, "VIEWPORT_3D", "ADD", [point(0, 0), point(1, 0)]), drawing); + assert.equal(viewport.revision, 2); + assert.equal(viewport.lastSource, "VIEWPORT_3D"); + assert.deepEqual(viewport.selectedPoints.map((item) => item.pointId), [point(0, 0).pointId, point(0, 1).pointId, point(1, 0).pointId]); + + const toggled = selection.applyGreasePencilSelectionEdit(viewport, edit(2, "CANVAS_2D", "TOGGLE", [point(0, 1)]), drawing); + assert.equal(toggled.revision, 3); + assert.deepEqual(toggled.selectedPoints.map((item) => item.pointId), [point(0, 0).pointId, point(1, 0).pointId]); +}); + +test("M9-07 rejects stale, foreign, duplicate and index-aliased selection edits", () => { + const initial = selection.createGreasePencilSelectionState(drawing); + assert.throws( + () => selection.applyGreasePencilSelectionEdit(initial, edit(1, "VIEWPORT_3D", "REPLACE", [point(0, 0)]), drawing), + (error) => error.code === "REVISION_CONFLICT", + ); + assert.throws( + () => selection.applyGreasePencilSelectionEdit(initial, edit(0, "CANVAS_2D", "REPLACE", [{ ...point(0, 0), drawingId: "grease-pencil-drawing:Data:5" }]), drawing), + (error) => error.code === "GREASE_PENCIL_SELECTION_SCOPE_INVALID", + ); + assert.throws( + () => selection.applyGreasePencilSelectionEdit(initial, edit(0, "CANVAS_2D", "REPLACE", [point(0, 0), point(0, 0)]), drawing), + (error) => error.code === "GREASE_PENCIL_SELECTION_INVALID", + ); + assert.throws( + () => selection.applyGreasePencilSelectionEdit(initial, edit(0, "CANVAS_2D", "REPLACE", [point(0, 0), { ...point(0, 1), pointIndex: 0 }]), drawing), + (error) => error.code === "GREASE_PENCIL_SELECTION_INVALID", + ); +}); diff --git a/web/tests/unit/nanovdb-device-recovery.test.mjs b/web/tests/unit/nanovdb-device-recovery.test.mjs new file mode 100644 index 00000000..8f858a11 --- /dev/null +++ b/web/tests/unit/nanovdb-device-recovery.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/nanovdb-device-recovery.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const recovery = await import(moduleUrl); + +test("M8-15 deterministically bounds visible-page replay by resident capacity", () => { + assert.deepEqual(recovery.planNanoVDBDeviceLossReplay([3, 1, 3, 2], 4, 2), { + schemaVersion: 1, + visiblePageIds: [1, 2, 3], + replayedPageIds: [1, 2], + skippedPageIds: [3], + pageCount: 4, + residentPageCapacity: 2, + }); +}); + +test("M8-15 rejects invalid replay bounds and visible page IDs", () => { + for (const args of [ + [[0], 0, 1], + [[0], 8193, 1], + [[0], 2, 0], + [[0], 2, 3], + [[-1], 2, 1], + [[2], 2, 1], + [[0.5], 2, 1], + ]) { + assert.throws(() => recovery.planNanoVDBDeviceLossReplay(...args), /NANOVDB_INVALID_ARGUMENT/); + } +}); diff --git a/web/tests/unit/nanovdb-page-feedback.test.mjs b/web/tests/unit/nanovdb-page-feedback.test.mjs new file mode 100644 index 00000000..31f19bf4 --- /dev/null +++ b/web/tests/unit/nanovdb-page-feedback.test.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/nanovdb-page-feedback.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const feedback = await import(moduleUrl); + +test("M8-01 freezes the bounded GPU feedback word layout", () => { + const buffer = feedback.createNanoVDBPageFeedbackBuffer(); + const words = new Uint32Array(buffer); + assert.equal(buffer.byteLength, (feedback.NANOVDB_PAGE_FEEDBACK_HEADER_WORDS + feedback.NANOVDB_PAGE_FEEDBACK_DEFAULT_CAPACITY) * 4); + assert.deepEqual([...words.slice(0, 4)], [1, 1024, 0, 0]); + assert.ok([...words.slice(4)].every((word) => word === 0xffffffff)); + assert.deepEqual(feedback.NANOVDB_PAGE_FEEDBACK_WORD, { schemaVersion: 0, capacity: 1, count: 2, overflow: 3, pageIds: 4 }); + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_WGSL, /count: atomic/); + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_WGSL, /overflow: atomic/); + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_WGSL, /page_ids: array>/); +}); + +test("M8-02 records unique page IDs with a physical-capacity bound", () => { + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /arrayLength\(&nanovdb_page_feedback\.page_ids\)/); + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /min\(nanovdb_page_feedback\.capacity, physical_capacity\)/); + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /slot < capacity/); + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /atomicCompareExchangeWeak/); + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /claim\.old_value == page_id/); + assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /atomicStore\(&nanovdb_page_feedback\.overflow, 1u\)/); +}); + +test("M8-03 sorts, deduplicates and binds CPU feedback to a render revision", () => { + const buffer = feedback.createNanoVDBPageFeedbackBuffer(4); + const words = new Uint32Array(buffer); + words[feedback.NANOVDB_PAGE_FEEDBACK_WORD.count] = 4; + words.set([7, 2, 7, 5], feedback.NANOVDB_PAGE_FEEDBACK_WORD.pageIds); + assert.deepEqual(feedback.parseNanoVDBPageFeedbackBatch(buffer, 8, 42), { + schemaVersion: 1, + renderRevision: 42, + attemptedCount: 4, + gpuStoredCount: 4, + uniqueCount: 3, + pageIds: [2, 5, 7], + status: "READY", + errorCode: null, + }); + assert.throws( + () => feedback.parseNanoVDBPageFeedbackBatch(buffer, 8, -1), + (error) => error.code === "INVALID_ARGUMENT", + ); + assert.throws( + () => feedback.parseNanoVDBPageFeedbackBatch(buffer, 8, 1.5), + (error) => error.code === "INVALID_ARGUMENT", + ); +}); + +test("M8-04 rejects stale frame feedback before page I/O", async () => { + const buffer = feedback.createNanoVDBPageFeedbackBuffer(4); + const words = new Uint32Array(buffer); + words[feedback.NANOVDB_PAGE_FEEDBACK_WORD.count] = 3; + words.set([7, 2, 5], feedback.NANOVDB_PAGE_FEEDBACK_WORD.pageIds); + const batch = feedback.parseNanoVDBPageFeedbackBatch(buffer, 8, 42); + const requests = []; + const requestPage = async (pageId, renderRevision) => { + requests.push({ pageId, renderRevision }); + }; + + assert.deepEqual(await feedback.dispatchNanoVDBPageFeedbackBatch(batch, 43, requestPage), { + schemaVersion: 1, + renderRevision: 42, + currentRenderRevision: 43, + status: "STALE", + requestedPageIds: [], + requestedCount: 0, + errorCode: "REVISION_CONFLICT", + }); + assert.deepEqual(await feedback.dispatchNanoVDBPageFeedbackBatch(batch, 41, requestPage), { + schemaVersion: 1, + renderRevision: 42, + currentRenderRevision: 41, + status: "STALE", + requestedPageIds: [], + requestedCount: 0, + errorCode: "REVISION_CONFLICT", + }); + assert.deepEqual(requests, []); + + assert.deepEqual(await feedback.dispatchNanoVDBPageFeedbackBatch(batch, 42, requestPage), { + schemaVersion: 1, + renderRevision: 42, + currentRenderRevision: 42, + status: "ACCEPTED", + requestedPageIds: [2, 5, 7], + requestedCount: 3, + errorCode: null, + }); + assert.deepEqual(requests, [ + { pageId: 2, renderRevision: 42 }, + { pageId: 5, renderRevision: 42 }, + { pageId: 7, renderRevision: 42 }, + ]); + await assert.rejects( + feedback.dispatchNanoVDBPageFeedbackBatch(batch, -1, requestPage), + (error) => error.code === "INVALID_ARGUMENT", + ); +}); + +test("M8-01 parses ready and overflow feedback with a stable overflow code", () => { + const readyBuffer = feedback.createNanoVDBPageFeedbackBuffer(4); + const readyWords = new Uint32Array(readyBuffer); + readyWords[feedback.NANOVDB_PAGE_FEEDBACK_WORD.count] = 3; + readyWords.set([7, 2, 5], feedback.NANOVDB_PAGE_FEEDBACK_WORD.pageIds); + assert.deepEqual(feedback.parseNanoVDBPageFeedbackBuffer(readyBuffer, 8), { + schemaVersion: 1, + capacity: 4, + attemptedCount: 3, + storedCount: 3, + pageIds: [7, 2, 5], + status: "READY", + errorCode: null, + }); + + const overflowBuffer = feedback.createNanoVDBPageFeedbackBuffer(2); + const overflowWords = new Uint32Array(overflowBuffer); + overflowWords[feedback.NANOVDB_PAGE_FEEDBACK_WORD.count] = 4; + overflowWords[feedback.NANOVDB_PAGE_FEEDBACK_WORD.overflow] = 1; + overflowWords.set([3, 4], feedback.NANOVDB_PAGE_FEEDBACK_WORD.pageIds); + assert.deepEqual(feedback.parseNanoVDBPageFeedbackBuffer(overflowBuffer, 8), { + schemaVersion: 1, + capacity: 2, + attemptedCount: 4, + storedCount: 2, + pageIds: [3, 4], + status: "OVERFLOW", + errorCode: "NANOVDB_PAGE_FEEDBACK_OVERFLOW", + }); +}); + +test("M8-01 rejects layout drift, invalid pages and inconsistent overflow", () => { + assert.throws(() => feedback.createNanoVDBPageFeedbackBuffer(0), (error) => error.code === "INVALID_ARGUMENT"); + assert.throws(() => feedback.createNanoVDBPageFeedbackBuffer(8193), (error) => error.code === "INVALID_ARGUMENT"); + + const wrongSchema = feedback.createNanoVDBPageFeedbackBuffer(2); + new Uint32Array(wrongSchema)[0] = 2; + assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(wrongSchema, 8), (error) => error.code === "PROTOCOL_MISMATCH"); + + const wrongCapacity = feedback.createNanoVDBPageFeedbackBuffer(2); + new Uint32Array(wrongCapacity)[1] = 8193; + assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(wrongCapacity, 8), (error) => error.code === "PROTOCOL_MISMATCH"); + + const wrongSize = feedback.createNanoVDBPageFeedbackBuffer(2).slice(0, 20); + assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(wrongSize, 8), (error) => error.code === "PROTOCOL_MISMATCH"); + + const inconsistent = feedback.createNanoVDBPageFeedbackBuffer(2); + const inconsistentWords = new Uint32Array(inconsistent); + inconsistentWords[2] = 3; + inconsistentWords.set([1, 2], 4); + assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(inconsistent, 8), (error) => error.code === "PROTOCOL_MISMATCH"); + + const invalidPage = feedback.createNanoVDBPageFeedbackBuffer(2); + const invalidPageWords = new Uint32Array(invalidPage); + invalidPageWords[2] = 1; + invalidPageWords[4] = 8; + assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(invalidPage, 8), (error) => error.code === "PROTOCOL_MISMATCH"); +}); + +test("M8-01 reset clears atomic words and stale page IDs", () => { + const buffer = feedback.createNanoVDBPageFeedbackBuffer(3); + const words = new Uint32Array(buffer); + words[2] = 5; + words[3] = 1; + words.set([1, 2, 3], 4); + feedback.resetNanoVDBPageFeedbackBuffer(buffer); + assert.deepEqual([...words], [1, 3, 0, 0, 0xffffffff, 0xffffffff, 0xffffffff]); +}); diff --git a/web/tests/unit/nanovdb-progressive-redraw.test.mjs b/web/tests/unit/nanovdb-progressive-redraw.test.mjs new file mode 100644 index 00000000..c1064395 --- /dev/null +++ b/web/tests/unit/nanovdb-progressive-redraw.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/nanovdb-progressive-redraw.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const redraw = await import(moduleUrl); + +test("M8-10 validates a bounded progressive redraw budget", () => { + assert.equal(redraw.NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES, 32); + assert.equal(redraw.validateNanoVDBProgressiveRedrawLimit(1), 1); + assert.equal(redraw.validateNanoVDBProgressiveRedrawLimit(1024), 1024); + for (const value of [0, 1.5, 1025, Number.POSITIVE_INFINITY]) { + assert.throws(() => redraw.validateNanoVDBProgressiveRedrawLimit(value), /NANOVDB_INVALID_ARGUMENT/); + } +}); + +test("M8-10 caps repeated successful uploads with a stable error code", () => { + assert.deepEqual(redraw.consumeNanoVDBProgressiveRedrawBudget(0, 2), { + allowed: true, + redrawCount: 1, + capped: false, + errorCode: null, + }); + assert.deepEqual(redraw.consumeNanoVDBProgressiveRedrawBudget(1, 2), { + allowed: true, + redrawCount: 2, + capped: true, + errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT", + }); + assert.deepEqual(redraw.consumeNanoVDBProgressiveRedrawBudget(2, 2), { + allowed: false, + redrawCount: 2, + capped: true, + errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT", + }); + assert.throws(() => redraw.consumeNanoVDBProgressiveRedrawBudget(-1, 2), /NANOVDB_INVALID_ARGUMENT/); +}); diff --git a/web/tests/unit/nanovdb-render-golden.test.mjs b/web/tests/unit/nanovdb-render-golden.test.mjs new file mode 100644 index 00000000..4cd634ed --- /dev/null +++ b/web/tests/unit/nanovdb-render-golden.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/nanovdb-render-golden.ts"); +const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""); +const transpiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const golden = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64")); +const thresholds = { maxChannelError: 2, meanAbsoluteError: 0.5, rmsError: 1, alphaCoverageDeltaRatio: 0.25 }; + +test("M8-19 accepts a bounded RGBA8 desktop/WebGPU difference", () => { + const reference = Uint8Array.from([0, 10, 20, 0, 100, 110, 120, 255]); + const actual = Uint8Array.from([0, 11, 18, 0, 101, 110, 120, 255]); + assert.deepEqual(golden.compareNanoVDBRenderGolden(reference, actual, thresholds), { + schemaVersion: 1, + status: "READY", + pixelCount: 2, + comparedChannels: 8, + maxChannelError: 2, + meanAbsoluteError: 0.5, + rmsError: Math.sqrt(6 / 8), + referenceAlphaPixels: 1, + actualAlphaPixels: 1, + alphaCoverageDeltaRatio: 0, + thresholds, + errorCode: null, + }); +}); + +test("M8-19 blocks channel and alpha coverage drift with a stable code", () => { + const reference = Uint8Array.from([0, 0, 0, 0, 10, 10, 10, 255]); + const actual = Uint8Array.from([9, 0, 0, 255, 10, 10, 10, 255]); + const result = golden.compareNanoVDBRenderGolden(reference, actual, thresholds); + assert.equal(result.status, "BLOCKED"); + assert.equal(result.errorCode, "NANOVDB_GOLDEN_MISMATCH"); + assert.equal(result.maxChannelError, 255); + assert.equal(result.alphaCoverageDeltaRatio, 0.5); + for (const args of [ + [new Uint8Array(), new Uint8Array(), thresholds], + [new Uint8Array(4), new Uint8Array(8), thresholds], + [new Uint8Array(3), new Uint8Array(3), thresholds], + [new Uint8Array(4), new Uint8Array(4), { ...thresholds, rmsError: -1 }], + ]) assert.throws(() => golden.compareNanoVDBRenderGolden(...args), /NANOVDB_INVALID_ARGUMENT/); +}); diff --git a/web/tests/unit/nla.test.mjs b/web/tests/unit/nla.test.mjs new file mode 100644 index 00000000..128a8c58 --- /dev/null +++ b/web/tests/unit/nla.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "nla-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + 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 = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText); + fs.writeFileSync(path.join(temporary, outputName), output); +} + +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("nla.ts", "nla.mjs", [['from "./capability-gates"', 'from "./capability-gates.mjs"']]); +const nla = await import(pathToFileURL(path.join(temporary, "nla.mjs"))); + +const actionId = "action:Move:object:Cube"; +const context = { + actionIds: new Set([actionId]), + actionChannelPaths: new Map([[actionId, new Set(["location[0]"])]]), + ownerId: "object:Cube", +}; + +function tracks() { + return [{ + schemaVersion: 1, + id: "track:Move", + ownerId: "object:Cube", + name: "Move", + muted: false, + solo: false, + selected: true, + strips: [ + { id: "strip:A", actionId, frameStart: 20, frameEnd: 40, actionFrameStart: 1, actionFrameEnd: 11, scale: 2, repeat: 1, blendIn: 0, blendOut: 0, influence: 1, blendMode: "REPLACE", extrapolation: "NOTHING", muted: false, selected: true, stripType: "CLIP" }, + { id: "strip:B", actionId, frameStart: 45, frameEnd: 65, actionFrameStart: 1, actionFrameEnd: 11, scale: 1, repeat: 2, blendIn: 0, blendOut: 0, influence: 1, blendMode: "REPLACE", extrapolation: "NOTHING", muted: false, selected: true, stripType: "CLIP" }, + ], + }]; +} + +test("M10-12 moves one NLA strip without mutating the source stack", () => { + const source = tracks(); + const before = structuredClone(source); + const moved = nla.moveNlaStrip(source, { + type: "moveNLAStrip", + objectId: "object:Cube", + trackId: "track:Move", + stripId: "strip:A", + frameStart: 5, + baseRevision: 7, + }, context); + assert.deepEqual(source, before); + assert.deepEqual(moved[0].strips.map((strip) => [strip.id, strip.frameStart, strip.frameEnd]), [ + ["strip:A", 5, 25], + ["strip:B", 45, 65], + ]); +}); + +test("M10-12 rejects overlap, unknown identities and invalid operator budgets", () => { + const command = { type: "moveNLAStrip", objectId: "object:Cube", trackId: "track:Move", stripId: "strip:A", frameStart: 30, baseRevision: 7 }; + assert.throws(() => nla.moveNlaStrip(tracks(), command, context), { code: "NLA_INVALID_STACK" }); + assert.throws(() => nla.moveNlaStrip(tracks(), { ...command, trackId: "track:missing" }, context), { code: "NLA_INVALID_STACK" }); + assert.throws(() => nla.moveNlaStrip(tracks(), { ...command, frameStart: 1_000_001 }, context), { code: "NLA_INVALID_STACK" }); +}); + +test("M10-15 rejects NLA topology that exceeds the browser memory budget", () => { + const oversized = new Array(nla.NLA_STACK_BUDGET.maxTracks + 1).fill(tracks()[0]); + assert.throws(() => nla.parseNlaTracks(oversized), { code: "NLA_BUDGET_EXCEEDED" }); + const tooManyStrips = [{ + ...tracks()[0], + strips: new Array(nla.NLA_STACK_BUDGET.maxStripsPerTrack + 1).fill(tracks()[0].strips[0]), + }]; + assert.throws(() => nla.parseNlaTracks(tooManyStrips), { code: "NLA_BUDGET_EXCEEDED" }); +}); + +test("M10-15 rejects undeclared NLA input and recovers on the next valid stack", () => { + const malicious = tracks(); + malicious[0].strips[0].proxySuccess = true; + assert.throws(() => nla.parseNlaTracks(malicious), { code: "NLA_INVALID_STACK" }); + assert.equal(nla.gateNlaTracks(tracks(), context).status, "READY"); +}); diff --git a/web/tests/unit/paint-depth-visibility.test.mjs b/web/tests/unit/paint-depth-visibility.test.mjs new file mode 100644 index 00000000..3c5e86b3 --- /dev/null +++ b/web/tests/unit/paint-depth-visibility.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "paint-depth-visibility-unit-")); + +function transpile(sourceName, outputName, transform = (source) => source) { + const sourcePath = path.join(repoRoot, "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, []); + fs.writeFileSync(path.join(temporary, outputName), transform(transpiled.outputText)); +} + +transpile("paint.ts", "paint.mjs"); +transpile("paint-depth-visibility.ts", "paint-depth-visibility.mjs", (source) => source.replace('from "./paint"', 'from "./paint.mjs"')); +const visibility = await import(pathToFileURL(path.join(temporary, "paint-depth-visibility.mjs"))); + +const request = { + schemaVersion: 1, + objectId: "object:Paint", + meshId: "mesh:Paint", + revision: 7, + vertexIndices: [6, 2, 4], +}; + +test("M9-09 binds GPU depth visibility to stable vertex identities and Main revision", () => { + const parsed = visibility.validatePaintDepthVisibilityRequest(request, 7); + assert.deepEqual(parsed.vertexIndices, [2, 4, 6]); + const result = visibility.validatePaintDepthVisibilityResult({ + ...parsed, + backend: "MAIN_THREAD_WEBGL2", + source: "GPU_RGBA_DEPTH_READBACK", + width: 64, + height: 32, + depthReadbackBytes: 64 * 32 * 4, + occluderPixelCount: 512, + visibleVertexIndices: [2, 6], + }, parsed); + assert.deepEqual(result.visibleVertexIndices, [2, 6]); + assert.equal(result.source, "GPU_RGBA_DEPTH_READBACK"); +}); + +test("M9-09 fails closed on stale, forged, duplicate and over-budget depth samples", () => { + assert.throws(() => visibility.validatePaintDepthVisibilityRequest(request, 8), (error) => error.code === "REVISION_CONFLICT"); + assert.throws(() => visibility.validatePaintDepthVisibilityRequest({ ...request, objectId: "mesh:Paint" }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID"); + assert.throws(() => visibility.validatePaintDepthVisibilityRequest({ ...request, vertexIndices: [2, 2] }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID"); + assert.throws(() => visibility.validatePaintDepthVisibilityRequest({ ...request, undeclared: true }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID"); + assert.throws( + () => visibility.validatePaintDepthVisibilityRequest({ ...request, vertexIndices: Array.from({ length: 100_001 }, (_, index) => index) }, 7), + (error) => error.code === "PAINT_BUDGET_EXCEEDED", + ); +}); + +test("M9-09 rejects result drift and accepts a verified empty visible set", () => { + const parsed = visibility.validatePaintDepthVisibilityRequest(request, 7); + const base = { + ...parsed, + backend: "OFFSCREEN_WEBGL2", + source: "GPU_RGBA_DEPTH_READBACK", + width: 32, + height: 32, + depthReadbackBytes: 4096, + occluderPixelCount: 128, + visibleVertexIndices: [], + }; + assert.deepEqual(visibility.validatePaintDepthVisibilityResult(base, parsed).visibleVertexIndices, []); + assert.throws(() => visibility.validatePaintDepthVisibilityResult({ ...base, visibleVertexIndices: [99] }, parsed), (error) => error.code === "PAINT_SCHEMA_INVALID"); + assert.throws(() => visibility.validatePaintDepthVisibilityResult({ ...base, depthReadbackBytes: 4095 }, parsed), (error) => error.code === "PAINT_BUDGET_EXCEEDED"); + assert.throws(() => visibility.validatePaintDepthVisibilityResult({ ...base, source: "CPU_RAYCAST" }, parsed), (error) => error.code === "PAINT_SCHEMA_INVALID"); +}); diff --git a/web/tests/unit/paint-pbvh-capability.test.mjs b/web/tests/unit/paint-pbvh-capability.test.mjs new file mode 100644 index 00000000..c0590a8d --- /dev/null +++ b/web/tests/unit/paint-pbvh-capability.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "paint-pbvh-capability-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + const sourcePath = path.join(repoRoot, "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 = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText); + fs.writeFileSync(path.join(temporary, outputName), output); +} + +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("paint-pbvh-capability.ts", "paint-pbvh-capability.mjs", [ + ['from "./capability-gates"', 'from "./capability-gates.mjs"'], +]); +const pbvh = await import(pathToFileURL(path.join(temporary, "paint-pbvh-capability.mjs"))); + +const base = { + schemaVersion: 1, + operation: "PBVH_BRUSH", + domain: "VERTEX_COLOR", + brush: "DRAW", + objectId: "object:Paint", + meshId: "mesh:Paint", + baseRevision: 7, +}; + +test("M9-13 freezes the active Blender 5.2 PBVH-related brush inventory", () => { + const inventory = pbvh.paintPBVHBrushInventory(); + const counts = Object.fromEntries(["SCULPT", "VERTEX_COLOR", "WEIGHT", "TEXTURE"].map((domain) => [ + domain, + inventory.filter((entry) => entry.domain === domain).length, + ])); + assert.deepEqual(counts, { SCULPT: 32, VERTEX_COLOR: 4, WEIGHT: 4, TEXTURE: 6 }); + assert.equal(new Set(inventory.map((entry) => `${entry.domain}:${entry.brush}`)).size, 46); + assert.ok(inventory.every((entry) => entry.source.endsWith("DNA_brush_enums.h"))); +}); + +test("M9-13 blocks every PBVH brush when the WASM entrypoint is absent", () => { + for (const entry of pbvh.paintPBVHBrushInventory()) { + const gate = pbvh.gatePaintPBVHCapability({ ...base, domain: entry.domain, brush: entry.brush }, { + nativeEntrypointPresent: false, + sessionContextReady: false, + verifiedBrushes: new Set(), + currentRevision: 7, + currentObjectId: base.objectId, + currentMeshId: base.meshId, + }); + assert.equal(gate.taskId, "N-017"); + assert.equal(gate.status, "BLOCKED"); + assert.deepEqual(gate.issues.map((issue) => issue.code), ["PAINT_PBVH_UNAVAILABLE"]); + assert.equal(gate.issues[0].recoverable, false); + } +}); + +test("M9-13 remains fail-closed after symbol discovery until context and a brush golden exist", () => { + const common = { nativeEntrypointPresent: true, verifiedBrushes: new Set(), currentRevision: 7 }; + assert.equal(pbvh.gatePaintPBVHCapability(base, { ...common, sessionContextReady: false }).issues[0].code, "PAINT_PBVH_CONTEXT_UNAVAILABLE"); + assert.equal(pbvh.gatePaintPBVHCapability(base, { ...common, sessionContextReady: true }).issues[0].code, "PAINT_PBVH_BRUSH_UNVERIFIED"); + assert.equal(pbvh.gatePaintPBVHCapability(base, { + ...common, + sessionContextReady: true, + verifiedBrushes: new Set(["VERTEX_COLOR:DRAW"]), + }).status, "READY"); +}); + +test("M9-13 validates identity and revision before reporting runtime availability", () => { + const context = { + nativeEntrypointPresent: false, + sessionContextReady: false, + verifiedBrushes: new Set(), + currentRevision: 7, + currentObjectId: base.objectId, + currentMeshId: base.meshId, + }; + assert.equal(pbvh.gatePaintPBVHCapability({ ...base, baseRevision: 6 }, context).issues[0].code, "REVISION_CONFLICT"); + assert.equal(pbvh.gatePaintPBVHCapability({ ...base, objectId: "object:Other" }, context).issues[0].code, "PAINT_SCHEMA_INVALID"); + assert.throws(() => pbvh.parsePaintPBVHCapabilityRequest({ ...base, brush: "FAKE" }), { code: "PAINT_PBVH_BRUSH_UNVERIFIED" }); + assert.throws(() => pbvh.parsePaintPBVHCapabilityRequest({ ...base, proxySuccess: true }), { code: "PAINT_SCHEMA_INVALID" }); +}); diff --git a/web/tests/unit/paint-stroke-session.test.mjs b/web/tests/unit/paint-stroke-session.test.mjs new file mode 100644 index 00000000..3f2847a6 --- /dev/null +++ b/web/tests/unit/paint-stroke-session.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "paint-stroke-session-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + const sourcePath = path.join(repoRoot, "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 = replacements.reduce((source, [from, to]) => source.replace(from, to), transpiled.outputText); + fs.writeFileSync(path.join(temporary, outputName), output); +} + +transpile("paint.ts", "paint.mjs"); +transpile("paint-stroke-session.ts", "paint-stroke-session.mjs", [['from "./paint"', 'from "./paint.mjs"']]); +const sessions = await import(pathToFileURL(path.join(temporary, "paint-stroke-session.mjs"))); + +const begin = { + schemaVersion: 1, + pointerSessionId: "paint-pointer:unit-1", + baseRevision: 7, + target: { mode: "VERTEX_COLOR", meshId: "mesh:Paint", attributeName: "StrokeColor", domain: "POINT" }, +}; + +test("M9-10 merges ordered pointer chunks into one deterministic Main command", () => { + const store = new sessions.PaintStrokeSessionStore(); + assert.equal(store.begin(begin, 7).state, "OPEN"); + store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 0, indices: [3, 1], values: [1, 0, 0, 1, 0, 1, 0, 1] }, 7); + const buffered = store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 1, indices: [1, 2], values: [0, 0, 1, 1, 1, 1, 0, 1] }, 7); + assert.deepEqual({ chunks: buffered.chunkCount, received: buffered.receivedEntryCount, unique: buffered.uniqueEntryCount, bytes: buffered.bufferedBytes }, { chunks: 2, received: 4, unique: 3, bytes: 80 }); + const committed = store.commit({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, expectedChunkCount: 2 }, 7); + assert.deepEqual(committed.command, { + type: "setVertexColors", + meshId: "mesh:Paint", + attributeName: "StrokeColor", + domain: "POINT", + indices: [1, 2, 3], + colors: [0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1], + }); + assert.equal(committed.receipt.state, "READY"); + assert.equal(store.activeCount, 0); +}); + +test("M9-10 cancels without a Main command and rejects stale or discontinuous chunks", () => { + const store = new sessions.PaintStrokeSessionStore(); + store.begin(begin, 7); + assert.throws(() => store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 1, indices: [0], values: [1, 1, 1, 1] }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID"); + assert.throws(() => store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 0, indices: [0], values: [1, 1, 1, 1] }, 8), (error) => error.code === "REVISION_CONFLICT"); + const cancelled = store.cancel({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7 }); + assert.equal(cancelled.state, "CANCELLED"); + assert.equal(store.activeCount, 0); +}); + +test("M9-10 merges weight chunks with their normalize policy into one Main command", () => { + const store = new sessions.PaintStrokeSessionStore(); + const weightBegin = { + schemaVersion: 1, + pointerSessionId: "paint-pointer:weight-unit", + baseRevision: 9, + target: { mode: "WEIGHT", objectId: "object:Paint", vertexGroup: "StrokeWeight", normalize: true, mirror: false }, + }; + store.begin(weightBegin, 9); + store.append({ schemaVersion: 1, pointerSessionId: weightBegin.pointerSessionId, baseRevision: 9, chunkIndex: 0, indices: [4, 2], values: [0.25, 0.75] }, 9); + store.append({ schemaVersion: 1, pointerSessionId: weightBegin.pointerSessionId, baseRevision: 9, chunkIndex: 1, indices: [4], values: [0.5] }, 9); + const committed = store.commit({ schemaVersion: 1, pointerSessionId: weightBegin.pointerSessionId, baseRevision: 9, expectedChunkCount: 2 }, 9); + assert.deepEqual(committed.command, { + type: "setVertexWeights", + objectId: "object:Paint", + vertexGroup: "StrokeWeight", + indices: [2, 4], + values: [0.75, 0.5], + normalize: true, + mirror: false, + }); +}); + +test("M9-10 bounds chunks, values and final chunk counts before Main", () => { + const store = new sessions.PaintStrokeSessionStore(); + store.begin(begin, 7); + assert.throws(() => store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 0, indices: [0], values: [2, 0, 0, 1] }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID"); + store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 0, indices: [0], values: [1, 0, 0, 1] }, 7); + assert.throws(() => store.commit({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, expectedChunkCount: 2 }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID"); + assert.equal(store.activeCount, 0); +}); diff --git a/web/tests/unit/physics-cache-family.test.mjs b/web/tests/unit/physics-cache-family.test.mjs new file mode 100644 index 00000000..26cacd08 --- /dev/null +++ b/web/tests/unit/physics-cache-family.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "physics-cache-family-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + 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 = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText); + fs.writeFileSync(path.join(temporary, outputName), output); +} + +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("physics-simulation.ts", "physics-simulation.mjs", [ + ['from "./capability-gates"', 'from "./capability-gates.mjs"'], +]); +const physics = await import(pathToFileURL(path.join(temporary, "physics-simulation.mjs"))); + +const digest = async (value) => Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", value)), + (byte) => byte.toString(16).padStart(2, "0"), +).join(""); + +async function cachedSystem(family, index = 0, cachePatch = {}) { + const source = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index]).buffer; + const payload = Uint8Array.from([index + 1, 2, index + 3, 4]).buffer; + const first = payload.slice(0, 2); + const second = payload.slice(2, 4); + const settingsHash = await digest(Uint8Array.from([index + 11]).buffer); + const cache = { + schemaVersion: 1, + cacheKey: `physics-${family.toLowerCase()}-1-2`, + family, + source: index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE", + blenderVersion: "5.2.0", + sourceBlendSha256: await digest(source), + settingsHash, + inputHash: await digest(Uint8Array.from([index + 21]).buffer), + cacheSha256: await digest(payload), + frameStart: 1, + frameEnd: 2, + byteLength: payload.byteLength, + frames: [ + { frame: 1, byteOffset: 0, byteLength: 2, sha256: await digest(first) }, + { frame: 2, byteOffset: 2, byteLength: 2, sha256: await digest(second) }, + ], + status: "COMPLETE", + ...cachePatch, + }; + const manifest = { + schemaVersion: 1, + systems: [{ + id: `physics:${family.toLowerCase()}`, + family, + ownerObjectId: `object:${family}`, + settingsHash, + settings: { enabled: true }, + dependencyIds: [], + cache, + }], + }; + return { manifest, source, payload }; +} + +test("M10-14 verifies source, payload and frame hashes for every Physics family", async () => { + for (const [index, family] of physics.PHYSICS_FAMILIES.entries()) { + const value = await cachedSystem(family, index); + const parsed = physics.parsePhysicsSimulationManifest(value.manifest); + const cache = await physics.verifyPhysicsCachePayload(parsed.systems[0], value.source, value.payload); + assert.equal(cache.family, family); + assert.equal(cache.source, index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE"); + assert.equal(cache.frames.length, 2); + assert.deepEqual(physics.selectPhysicsCacheFrame(parsed.systems[0], 2), { cacheKey: cache.cacheKey, frame: 2 }); + } +}); + +test("M10-14 rejects source and cache byte drift before playback", async () => { + const value = await cachedSystem("CLOTH"); + const parsed = physics.parsePhysicsSimulationManifest(value.manifest); + await assert.rejects( + physics.verifyPhysicsCachePayload(parsed.systems[0], Uint8Array.from([1]).buffer, value.payload), + { code: "PHYSICS_CACHE_SOURCE_MISMATCH" }, + ); + await assert.rejects( + physics.verifyPhysicsCachePayload(parsed.systems[0], value.source, Uint8Array.from([9, 9, 9, 9]).buffer), + { code: "PHYSICS_CACHE_HASH_MISMATCH" }, + ); +}); + +test("M10-14 rejects family, version, range, byte budget and undeclared cache fields", async () => { + const value = await cachedSystem("FLUID"); + const cache = value.manifest.systems[0].cache; + const invalid = [ + [{ ...cache, schemaVersion: 2 }, "PROTOCOL_MISMATCH"], + [{ ...cache, blenderVersion: "5.3.0" }, "PROTOCOL_MISMATCH"], + [{ ...cache, family: "CLOTH" }, "PHYSICS_MANIFEST_INVALID"], + [{ ...cache, byteLength: physics.PHYSICS_SIMULATION_BUDGET.maxCacheBytes + 1 }, "PHYSICS_BUDGET_EXCEEDED"], + [{ ...cache, frames: [{ ...cache.frames[0], byteOffset: 1 }, cache.frames[1]] }, "PHYSICS_CACHE_FRAME_MISMATCH"], + [{ ...cache, frames: [cache.frames[0]] }, "PHYSICS_CACHE_FRAME_MISMATCH"], + [{ ...cache, proxySuccess: true }, "PHYSICS_MANIFEST_INVALID"], + ]; + for (const [candidate, code] of invalid) { + assert.throws(() => physics.parsePhysicsSimulationManifest({ + ...value.manifest, + systems: [{ ...value.manifest.systems[0], cache: candidate }], + }), { code }); + } +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/physics-solver-probe.test.mjs b/web/tests/unit/physics-solver-probe.test.mjs new file mode 100644 index 00000000..055eed44 --- /dev/null +++ b/web/tests/unit/physics-solver-probe.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "physics-solver-probe-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + 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 = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText); + fs.writeFileSync(path.join(temporary, outputName), output); +} + +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("physics-simulation.ts", "physics-simulation.mjs", [ + ['from "./capability-gates"', 'from "./capability-gates.mjs"'], +]); +const physics = await import(pathToFileURL(path.join(temporary, "physics-simulation.mjs"))); + +const mib = 1024 * 1024; + +test("M10-13 defaults every Physics family to desktop/server bake", async () => { + const capabilities = await physics.probePhysicsSolverCapabilities(undefined, { + threadMode: "PTHREAD", + memoryLimitBytes: 2_048 * mib, + }); + assert.deepEqual(capabilities.map((entry) => entry.family), physics.PHYSICS_FAMILIES); + assert.ok(capabilities.every((entry) => entry.localSolver === "BLOCKED")); + assert.ok(capabilities.every((entry) => entry.solverProbe === "EXPORT_UNAVAILABLE")); + assert.ok(capabilities.every((entry) => entry.unsupportedRoute === "DESKTOP_SERVER_BAKE")); +}); + +test("M10-13 probes export, initialization, threads and memory per family", async () => { + const initialization = { + RIGID_BODY: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib }, + SOFT_BODY: { initialized: false, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib }, + CLOTH: { initialized: true, requiredThreadMode: "PTHREAD", requiredMemoryBytes: 128 * mib }, + FLUID: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 512 * mib }, + DYNAMIC_PAINT: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: Number.NaN }, + }; + const initialized = []; + const runtime = { + hasFamilyExport: (family) => family !== "HAIR", + initializeFamily: async (family) => { + initialized.push(family); + if (family === "PARTICLE") throw new Error("init failed"); + return initialization[family]; + }, + }; + const capabilities = await physics.probePhysicsSolverCapabilities(runtime, { + threadMode: "SINGLE", + memoryLimitBytes: 256 * mib, + }); + assert.deepEqual(Object.fromEntries(capabilities.map((entry) => [entry.family, entry.solverProbe])), { + RIGID_BODY: "READY", + SOFT_BODY: "INITIALIZATION_FAILED", + CLOTH: "THREADS_UNAVAILABLE", + FLUID: "MEMORY_UNAVAILABLE", + DYNAMIC_PAINT: "INVALID_RESULT", + PARTICLE: "INITIALIZATION_FAILED", + HAIR: "EXPORT_UNAVAILABLE", + }); + assert.deepEqual(initialized, ["RIGID_BODY", "SOFT_BODY", "CLOTH", "FLUID", "DYNAMIC_PAINT", "PARTICLE"]); + assert.deepEqual(physics.selectPhysicsExecutionRoute("RIGID_BODY", capabilities), { + family: "RIGID_BODY", mode: "LOCAL_SOLVER", probe: "READY", + }); + assert.deepEqual(physics.selectPhysicsExecutionRoute("CLOTH", capabilities), { + family: "CLOTH", mode: "DESKTOP_SERVER_BAKE", probe: "THREADS_UNAVAILABLE", + }); + assert.equal(physics.gatePhysicsExecution("RIGID_BODY", "LOCAL_SOLVER", capabilities).status, "READY"); + const blocked = physics.gatePhysicsExecution("CLOTH", "LOCAL_SOLVER", capabilities); + assert.equal(blocked.status, "BLOCKED"); + assert.equal(blocked.issues[0].code, "PHYSICS_SOLVER_UNAVAILABLE"); + assert.match(blocked.issues[0].message, /desktop\/server bake/); +}); + +test("M10-13 fails closed on an invalid environment or forged probe result", async () => { + await assert.rejects( + physics.probePhysicsSolverCapabilities(undefined, { threadMode: "SINGLE", memoryLimitBytes: 0 }), + { code: "PHYSICS_MANIFEST_INVALID" }, + ); + const capabilities = await physics.probePhysicsSolverCapabilities({ + hasFamilyExport: () => true, + initializeFamily: () => ({ initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: -1 }), + }, { threadMode: "PTHREAD", memoryLimitBytes: 2_048 * mib }); + assert.ok(capabilities.every((entry) => entry.solverProbe === "INVALID_RESULT")); + assert.ok(capabilities.every((entry) => entry.localSolver === "BLOCKED")); +}); diff --git a/web/tests/unit/recent-projects.test.mjs b/web/tests/unit/recent-projects.test.mjs new file mode 100644 index 00000000..caddfe28 --- /dev/null +++ b/web/tests/unit/recent-projects.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/recent-projects.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const { + normalizeRecentProjects, + parseRecentProjectIndex, + removeRecentProject, + upsertRecentProject, + classifyRecentProjectIdentity, +} = await import(moduleUrl); + +function project(projectId, overrides = {}) { + return { + schemaVersion: 1, + projectId, + displayName: `${projectId}.blend`, + revision: 7, + bytes: 1024, + sha256: (projectId.charCodeAt(0) % 16).toString(16).repeat(64), + updatedAt: "2026-08-15T12:00:00.000Z", + lastOpenedAt: "2026-08-15T12:00:00.000Z", + backend: "opfs", + ...overrides, + }; +} + +test("M7-09 recent projects sort and deduplicate deterministically", () => { + const older = project("alpha", { revision: 2, updatedAt: "2026-08-15T08:00:00-04:00", lastOpenedAt: "2026-08-15T08:00:00-04:00" }); + const newer = project("alpha", { revision: 3, updatedAt: "2026-08-15T12:01:00.000Z", lastOpenedAt: "2026-08-15T12:01:00.000Z" }); + const second = project("beta", { lastOpenedAt: "2026-08-15T11:59:00.000Z" }); + const forward = normalizeRecentProjects([older, second, newer]); + const reverse = normalizeRecentProjects([newer, second, older]); + + assert.deepEqual(forward, reverse); + assert.deepEqual(forward.index.projects.map(({ projectId, revision }) => [projectId, revision]), [["alpha", 3], ["beta", 7]]); + assert.equal(forward.index.projects[0].updatedAt, "2026-08-15T12:01:00.000Z"); +}); + +test("M7-09 malformed records are quarantined without hiding valid projects", () => { + const parsed = parseRecentProjectIndex({ + schemaVersion: 1, + projects: [project("valid"), { ...project("bad"), sha256: "not-a-digest" }, { ...project("blank"), displayName: " " }], + }); + assert.equal(parsed.quarantined, 2); + assert.deepEqual(parsed.index.projects.map((item) => item.projectId), ["valid"]); + assert.deepEqual(parseRecentProjectIndex({ schemaVersion: 99, projects: [] }), { + index: { schemaVersion: 1, projects: [] }, + quarantined: 1, + }); +}); + +test("M7-09 upsert and removal keep a bounded stable index", () => { + let index = { schemaVersion: 1, projects: [] }; + index = upsertRecentProject(index, project("alpha")); + index = upsertRecentProject(index, project("alpha", { revision: 9, lastOpenedAt: "2026-08-15T12:02:00Z" })); + index = upsertRecentProject(index, project("beta")); + assert.deepEqual(index.projects.map(({ projectId, revision }) => [projectId, revision]), [["alpha", 9], ["beta", 7]]); + assert.deepEqual(removeRecentProject(index, "alpha").projects.map((item) => item.projectId), ["beta"]); + assert.equal(normalizeRecentProjects(index.projects, 0).index.projects.length, 0); +}); + +test("M7-10 recent project integrity classifies missing and mismatched content", () => { + const expected = { revision: 7, bytes: 1024, sha256: "a".repeat(64) }; + assert.equal(classifyRecentProjectIdentity(expected, undefined), "MISSING"); + assert.equal(classifyRecentProjectIdentity(expected, { ...expected, sha256: "b".repeat(64) }), "HASH_MISMATCH"); + assert.equal(classifyRecentProjectIdentity(expected, { ...expected, revision: 8 }), "METADATA_MISMATCH"); + assert.equal(classifyRecentProjectIdentity(expected, expected), undefined); +}); diff --git a/web/tests/unit/render-budget.test.mjs b/web/tests/unit/render-budget.test.mjs new file mode 100644 index 00000000..2f4f22ba --- /dev/null +++ b/web/tests/unit/render-budget.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/render-budget.ts"); +const source = fs.readFileSync(sourcePath, "utf8") + .replace('import type { ErrorCode } from "./error";\n', "") + .replace('import { MAX_GPU_TEXTURE_ASSETS, MAX_GPU_TEXTURE_BYTES, MAX_GPU_TEXTURE_DIMENSION, type GPUTextureAsset } from "./render-assets";\n', "const MAX_GPU_TEXTURE_ASSETS = 256; const MAX_GPU_TEXTURE_BYTES = 64 * 1024 * 1024; const MAX_GPU_TEXTURE_DIMENSION = 16_384;\n") + .replace('import type { SceneSnapshotIR } from "./scene-ir";\n', ""); +const transpiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const budget = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64")); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-03/render-resource-budget.json"), "utf8")); + +function snapshot(lightCount) { + const lights = Array.from({ length: lightCount }, (_, index) => ({ + id: `light:${index}`, lightType: 2, castsShadow: true, + })); + const nodes = lights.map((light, index) => ({ + id: `object:light:${index}`, type: "LIGHT", visible: true, dataId: light.id, + })); + return { lights, nodes }; +} + +test("M11-03 freezes explicit Three WebGL2 and WebGPU product budgets", () => { + for (const [backend, expected] of [["THREE_WEBGL2", golden.webgl2], ["THREE_WEBGPU", golden.webgpu]]) { + const actual = budget.resolvePBRRenderBudget(backend); + for (const [field, value] of Object.entries(expected)) assert.equal(actual[field], value, `${backend}.${field}`); + } + const clamped = budget.resolvePBRRenderBudget("THREE_WEBGPU", { + maxLights: 32, maxShadowMaps: 4, maxShadowMapDimension: 1024, maxTextureDimension2D: 8192, + }); + assert.deepEqual([clamped.maxLights, clamped.maxShadowMaps, clamped.shadowMapDimension, clamped.maxTextureDimension], [32, 4, 1024, 8192]); + assert.throws(() => budget.resolvePBRRenderBudget("THREE_WEBGPU", { maxLights: 0 }), /GPU_TEXTURE_BUDGET_EXCEEDED/); +}); + +test("M11-03 deterministically bounds lights and shadow maps", () => { + const report = budget.planPBRLightingBudget(snapshot(golden.overflow.requestedLights)); + assert.equal(report.status, "BLOCKED"); + assert.equal(report.requestedLights, golden.overflow.requestedLights); + assert.equal(report.renderedLightNodeIds.length, golden.overflow.renderedLights); + assert.equal(report.droppedLightNodeIds.length, golden.overflow.droppedLights); + assert.equal(report.requestedShadowMaps, golden.overflow.requestedShadowMaps); + assert.equal(report.shadowLightNodeIds.length, golden.overflow.renderedShadowMaps); + assert.equal(report.shadowBlockedLightNodeIds.length, golden.overflow.blockedShadowMaps); + assert.deepEqual(report.issues.map((issue) => issue.code), golden.overflow.codes); + assert.equal(report.renderedLightNodeIds[0], "object:light:0"); + assert.equal(report.renderedLightNodeIds.at(-1), "object:light:13"); +}); + +test("M11-03 rejects aggregate texture allocation before decode", () => { + const small = { assetId: "asset:small", imageId: "image:small", usage: "BASE_COLOR", width: 4, height: 4, byteLength: 16 }; + assert.deepEqual(budget.planPBRTextureBudget([small]), { + schemaVersion: 1, + backend: "THREE_WEBGL2", + status: "READY", + budget: budget.resolvePBRRenderBudget("THREE_WEBGL2"), + requestedAssets: 1, + payloadBytes: 16, + decodedGPUBytes: 64, + maxRequestedDimension: 4, + issues: [], + }); + const tooMany = Array.from({ length: 257 }, (_, index) => ({ ...small, assetId: `asset:${index}`, imageId: `image:${index}` })); + const report = budget.planPBRTextureBudget(tooMany); + assert.equal(report.status, "BLOCKED"); + assert.equal(report.requestedAssets, 257); + assert.equal(report.issues[0].code, "GPU_TEXTURE_BUDGET_EXCEEDED"); + assert.equal(budget.planPBRTextureBudget([{ ...small, width: 16_384, height: 16_384 }]).status, "BLOCKED"); +}); diff --git a/web/tests/unit/render-image-comparison.test.mjs b/web/tests/unit/render-image-comparison.test.mjs new file mode 100644 index 00000000..33e7cfda --- /dev/null +++ b/web/tests/unit/render-image-comparison.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/render-image-comparison.ts"); +const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""); +const transpiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const comparison = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64")); + +const thresholds = { + maxMeanAbsoluteError: 8, + maxRootMeanSquaredError: 16, + maxP95ChannelError: 16, + maxBadPixelRatio: 0.1, + badPixelChannelError: 32, + foregroundDeltaFromReferenceBackground: 32, + minForegroundIntersectionOverUnion: 0.8, + maxAlphaCoverageDeltaRatio: 0, +}; + +function frame(width = 4, height = 4) { + const pixels = new Uint8Array(width * height * 4); + for (let index = 0; index < pixels.length; index += 4) pixels.set([64, 64, 64, 255], index); + for (const pixel of [5, 6, 9, 10]) pixels.set([0, 0, 0, 255], pixel * 4); + return pixels; +} + +test("M11-04 reports every explainable metric for identical SRGB8 frames", () => { + const reference = frame(); + const report = comparison.compareRenderImages(reference, reference.slice(), 4, 4, thresholds); + assert.equal(report.status, "READY"); + assert.deepEqual( + [report.meanAbsoluteError, report.rootMeanSquaredError, report.p95ChannelError, report.badPixelRatio], + [0, 0, 0, 0], + ); + assert.equal(report.foregroundIntersectionOverUnion, 1); + assert.equal(report.checks.length, 6); + assert.equal(report.errorCode, null); +}); + +test("M11-04 rejects a non-empty but compositionally wrong frame", () => { + const reference = frame(); + const wrong = frame(); + for (let index = 0; index < wrong.length; index += 4) wrong.set([64, 64, 64, 255], index); + wrong.set([255, 0, 0, 255], 0); + const report = comparison.compareRenderImages(reference, wrong, 4, 4, thresholds); + assert.equal(report.status, "BLOCKED"); + assert.equal(report.errorCode, "RENDER_REFERENCE_MISMATCH"); + assert.ok(report.foregroundIntersectionOverUnion < thresholds.minForegroundIntersectionOverUnion); + assert.ok(report.checks.some((item) => !item.passed)); +}); + +test("M11-04 rejects invalid dimensions, byte lengths and thresholds", () => { + assert.throws(() => comparison.compareRenderImages(frame(), frame(), 0, 4, thresholds), /INVALID_ARGUMENT/); + assert.throws(() => comparison.compareRenderImages(frame(), frame().subarray(1), 4, 4, thresholds), /INVALID_ARGUMENT/); + assert.throws(() => comparison.compareRenderImages(frame(), frame(), 4, 4, { ...thresholds, maxBadPixelRatio: 2 }), /INVALID_ARGUMENT/); +}); diff --git a/web/tests/unit/render-routing.test.mjs b/web/tests/unit/render-routing.test.mjs new file mode 100644 index 00000000..555b4912 --- /dev/null +++ b/web/tests/unit/render-routing.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/render-routing.ts"); +const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""); +const transpiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const routing = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64")); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-05/render-routing.json"), "utf8")); + +const request = (overrides = {}) => ({ schemaVersion: 1, renderEngine: "BLENDER_EEVEE", backend: "WEBGL2", complexity: "BOUNDED", ...overrides }); + +test("M11-05 keeps bounded Eevee on the declared local backend", () => { + const result = routing.routeRenderExecution(request()); + assert.deepEqual(result, { + schemaVersion: 1, target: "WEB_LOCAL_BOUNDED", status: "READY", capability: "WEB_REALTIME_BOUNDED", reason: "BOUNDED_EEVEE", issues: [], + }); + assert.deepEqual({ target: result.target, status: result.status, capability: result.capability, reason: result.reason }, golden.boundedEevee); + assert.equal(routing.routeRenderExecution(request({ backend: "WEBGPU" }), { webgpuAvailable: true, webgpuRendererBundled: true }).status, "READY"); +}); + +test("M11-05 routes Cycles, complex Eevee and hardware to SERVER_JOB without a local success", () => { + for (const [candidate, expected] of [ + [request({ renderEngine: "BLENDER_CYCLES", backend: "CYCLES" }), golden.cycles], + [request({ complexity: "COMPLEX", backend: "EEVEE_COMPLEX" }), golden.complexEevee], + [request({ hardwareBackend: "OPTIX" }), golden.hardware], + ]) { + const result = routing.routeRenderExecution(candidate); + assert.equal(result.target, expected.target); + assert.equal(result.status, expected.withoutEndpoint.status); + assert.equal(result.reason, expected.withoutEndpoint.reason); + assert.equal(result.issues[0].code, expected.withoutEndpoint.code); + } + const available = routing.routeRenderExecution(request({ renderEngine: "BLENDER_CYCLES", backend: "CYCLES" }), { serverRenderAvailable: true }); + assert.deepEqual([available.target, available.status, available.capability], [golden.cycles.target, golden.cycles.withEndpoint.status, golden.cycles.withEndpoint.capability]); +}); + +test("M11-05 blocks unavailable WebGPU and unknown engines and rejects malformed routes", () => { + const webgpu = routing.routeRenderExecution(request({ backend: "WEBGPU" })); + assert.deepEqual([webgpu.target, webgpu.status, webgpu.issues[0].code], [golden.webgpu.target, golden.webgpu.status, golden.webgpu.code]); + const unknown = routing.routeRenderExecution(request({ renderEngine: "UNKNOWN_ENGINE" })); + assert.deepEqual([unknown.target, unknown.status, unknown.capability, unknown.issues[0].code], [golden.unknownEngine.target, golden.unknownEngine.status, golden.unknownEngine.capability, golden.unknownEngine.code]); + assert.throws(() => routing.routeRenderExecution(request({ backend: "INVALID" })), /INVALID_ARGUMENT/); + assert.throws(() => routing.routeRenderExecution(request({ renderEngine: "invalid engine" })), /INVALID_ARGUMENT/); +}); diff --git a/web/tests/unit/sequencer-audio-recovery.test.mjs b/web/tests/unit/sequencer-audio-recovery.test.mjs new file mode 100644 index 00000000..6745efec --- /dev/null +++ b/web/tests/unit/sequencer-audio-recovery.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-audio-recovery-unit-")); +const protocolPath = path.join(root, "web/protocol/sequencer-audio-session.ts"); +const runtimePath = path.join(root, "web/app/src/sequencer/SequencerAudioSession.ts"); + +function transpile(source, fileName) { + const result = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName, + reportDiagnostics: true, + }); + assert.deepEqual(result.diagnostics, []); + return result.outputText; +} + +fs.writeFileSync( + path.join(temporary, "sequencer-audio-session.mjs"), + transpile(fs.readFileSync(protocolPath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""), protocolPath), +); +fs.writeFileSync( + path.join(temporary, "SequencerAudioSession.mjs"), + transpile( + fs.readFileSync(runtimePath, "utf8").replace( + 'from "../../../protocol/sequencer-audio-session";', + 'from "./sequencer-audio-session.mjs";', + ), + runtimePath, + ), +); + +const protocol = await import(pathToFileURL(path.join(temporary, "sequencer-audio-session.mjs"))); +const runtime = await import(pathToFileURL(path.join(temporary, "SequencerAudioSession.mjs"))); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-13/sequencer-audio-recovery.json"), "utf8")); + +class FakeAudioParam { + value = 1; + events = []; + cancelScheduledValues(time) { this.events.push(["cancel", time]); } + setValueAtTime(value, time) { this.value = value; this.events.push(["set", value, time]); } +} + +class FakeGainNode { + gain = new FakeAudioParam(); + connected = false; + disconnected = false; + connect() { this.connected = true; } + disconnect() { this.disconnected = true; } +} + +class FakeAudioContext { + static instances = []; + state = "suspended"; + currentTime = 1; + destination = {}; + output = new FakeGainNode(); + closeCount = 0; + constructor() { FakeAudioContext.instances.push(this); } + createGain() { return this.output; } + async resume() { this.state = "running"; } + async suspend() { this.state = "suspended"; } + async close() { this.closeCount += 1; this.state = "closed"; } +} + +const summary = (report) => [ + report.contextState, + report.outputState, + report.muted, + report.outputGain, + report.issueCode, +]; + +test("M11-13 restores output after real session suspend and mute transitions", async () => { + FakeAudioContext.instances.length = 0; + const session = new runtime.SequencerAudioSession({ + scope: { AudioContext: FakeAudioContext }, + outputGain: golden.outputGain, + }); + const reports = []; + reports.push(await session.initialize()); + reports.push(session.setMuted(true)); + reports.push(await session.resume()); + reports.push(session.setMuted(false)); + reports.push(await session.suspend()); + reports.push(await session.resume()); + reports.push(await session.close()); + + assert.deepEqual(reports.map(summary), golden.lifecycle); + assert.deepEqual(reports.map((report) => report.revision), [1, 2, 3, 4, 5, 6, 7]); + const context = FakeAudioContext.instances[0]; + assert.equal(context.output.gain.value, 0); + assert.equal(context.output.connected, true); + assert.equal(context.output.disconnected, true); + assert.equal(context.closeCount, 1); + assert.ok(context.output.gain.events.some((event) => event[0] === "set" && event[1] === 0)); + assert.ok(context.output.gain.events.some((event) => event[0] === "set" && event[1] === golden.outputGain)); +}); + +test("M11-13 blocks a missing audio device and can retry after the runtime becomes available", async () => { + FakeAudioContext.instances.length = 0; + const scope = {}; + const session = new runtime.SequencerAudioSession({ scope, outputGain: golden.outputGain }); + const missing = await session.initialize(); + assert.deepEqual( + [missing.contextState, missing.outputState, missing.issueCode], + golden.missingDevice, + ); + assert.deepEqual(session.snapshot(), missing); + + scope.AudioContext = FakeAudioContext; + const recovered = await session.recoverDevice(); + assert.deepEqual(summary(recovered), golden.lifecycle[0]); + const resumed = await session.resume(); + assert.deepEqual(summary(resumed), ["RUNNING", "ENABLED", false, golden.outputGain, null]); + await session.close(); +}); + +test("M11-13 keeps failed resume and malformed reports silent and structured", async () => { + class ResumeFailureContext extends FakeAudioContext { + async resume() { throw new Error("injected resume failure"); } + } + const failed = new runtime.SequencerAudioSession({ + contextFactory: () => new ResumeFailureContext(), + outputGain: golden.outputGain, + }); + await failed.initialize(); + const resumeFailure = await failed.resume(); + assert.deepEqual( + [resumeFailure.contextState, resumeFailure.outputState, resumeFailure.issueCode], + golden.resumeFailure, + ); + await failed.close(); + + const unavailable = new runtime.SequencerAudioSession({ + contextFactory: () => { throw new Error("no output device"); }, + }); + assert.deepEqual( + [ + (await unavailable.initialize()).contextState, + unavailable.snapshot().outputState, + unavailable.snapshot().issueCode, + ], + golden.missingDevice, + ); + assert.throws(() => new runtime.SequencerAudioSession({ outputGain: 0 }), { + code: "SEQUENCER_AUDIO_CONTEXT_INVALID", + }); + assert.throws(() => protocol.parseSequencerAudioSessionReport({ + schemaVersion: 1, + revision: 1, + contextState: "RUNNING", + outputState: "ENABLED", + muted: true, + outputGain: 1, + issueCode: null, + }), { code: "SEQUENCER_AUDIO_CONTEXT_INVALID" }); +}); diff --git a/web/tests/unit/sequencer-codec-probe.test.mjs b/web/tests/unit/sequencer-codec-probe.test.mjs new file mode 100644 index 00000000..bc64c48f --- /dev/null +++ b/web/tests/unit/sequencer-codec-probe.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-codec-probe-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + const sourcePath = path.join(repoRoot, "web/protocol", sourceName); + const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(result.diagnostics, []); + fs.writeFileSync(path.join(temporary, outputName), replacements.reduce((value, [from, to]) => value.replaceAll(from, to), result.outputText)); +} + +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("asset-path.ts", "asset-path.mjs"); +transpile("sequencer.ts", "sequencer.mjs", [ + ['from "./capability-gates"', 'from "./capability-gates.mjs"'], + ['from "./asset-path"', 'from "./asset-path.mjs"'], +]); +const sequencer = await import(pathToFileURL(path.join(temporary, "sequencer.mjs"))); + +const requests = [ + { schemaVersion: 1, stripType: "IMAGE", mimeType: "image/png", byteLength: 261, sourceSha256: "a".repeat(64) }, + { schemaVersion: 1, stripType: "SOUND", mimeType: "audio/wav", byteLength: 16_044, sourceSha256: "b".repeat(64) }, + { schemaVersion: 1, stripType: "MOVIE", mimeType: "video/mp4", byteLength: 1_484, sourceSha256: "c".repeat(64) }, +]; +const ready = [ + { backend: "IMAGE_BITMAP", decoded: { width: 8, height: 8 } }, + { backend: "WEB_AUDIO", decoded: { sampleRate: 8_000, channels: 1, durationFrames: 8_000 } }, + { backend: "HTML_MEDIA", decoded: { width: 16, height: 16, durationMicros: 1_000_000 } }, +]; + +test("M11-09 gates IMAGE, SOUND and MOVIE only with identity-bound ready probe receipts", () => { + for (const [index, request] of requests.entries()) { + assert.deepEqual(sequencer.parseSequencerCodecProbeRequest(request), request); + const result = { ...request, status: "READY", backend: ready[index].backend, reason: null, decoded: ready[index].decoded }; + assert.equal(sequencer.gateSequencerCodec(request, result).status, "READY"); + } +}); + +test("M11-09 rejects extension fields, family mismatch, forged backend and source drift", () => { + assert.throws(() => sequencer.parseSequencerCodecProbeRequest({ ...requests[0], sourcePath: "image.mp4" }), { code: "SEQUENCER_SCHEMA_INVALID" }); + assert.throws(() => sequencer.parseSequencerCodecProbeRequest({ ...requests[0], mimeType: "video/mp4" }), { code: "SEQUENCER_SCHEMA_INVALID" }); + const forged = { ...requests[0], status: "READY", backend: "HTML_MEDIA", reason: null, decoded: { width: 8, height: 8 } }; + assert.deepEqual(sequencer.gateSequencerCodec(requests[0], forged).issues.map((issue) => issue.code), ["SEQUENCER_CODEC_UNSUPPORTED"]); + const drift = { ...requests[0], sourceSha256: "d".repeat(64), status: "READY", backend: "IMAGE_BITMAP", reason: null, decoded: { width: 8, height: 8 } }; + assert.deepEqual(sequencer.gateSequencerCodec(requests[0], drift).issues.map((issue) => issue.code), ["SEQUENCER_CODEC_UNSUPPORTED"]); +}); + +test("M11-09 keeps runtime unavailability and decode failure blocked", () => { + for (const [index, request] of requests.entries()) { + const result = { ...request, status: "BLOCKED", backend: ready[index].backend, reason: "DECODE_FAILED", decoded: null }; + const gate = sequencer.gateSequencerCodec(request, result); + assert.equal(gate.status, "BLOCKED"); + assert.deepEqual(gate.issues.map((issue) => issue.code), ["SEQUENCER_CODEC_UNSUPPORTED"]); + } +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/sequencer-final-export.test.mjs b/web/tests/unit/sequencer-final-export.test.mjs new file mode 100644 index 00000000..09c445c8 --- /dev/null +++ b/web/tests/unit/sequencer-final-export.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/sequencer-export.ts"); +const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""); +const transpiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const finalExport = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64")); +const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-12/sequencer-final-export.json"), "utf8")); + +const request = (overrides = {}) => ({ + schemaVersion: 1, + timelineId: "sequencer:scene:SequencerScene", + timelineRevision: 1, + sourceBlendSha256: golden.sourceBlendSha256, + frameStart: 1, + frameEnd: 250, + fpsNumerator: 24000, + fpsDenominator: 1001, + width: 1920, + height: 1080, + container: "MPEG4", + videoCodec: "H264", + audioCodec: "AAC", + ...overrides, +}); + +test("M11-12 requires the declared server export capability", async () => { + const blocked = await finalExport.routeSequencerFinalExport(request(), { + serverExportAvailable: false, + browserVideoEncoderAvailable: false, + }); + assert.deepEqual(blocked, golden.withoutServer); + + const routed = await finalExport.routeSequencerFinalExport(request(), { + serverExportAvailable: true, + browserVideoEncoderAvailable: false, + }); + assert.deepEqual(routed, golden.withServer); +}); + +test("M11-12 never treats browser VideoEncoder detection as local final-export support", async () => { + for (const serverExportAvailable of [false, true]) { + const withoutEncoder = await finalExport.routeSequencerFinalExport(request(), { + serverExportAvailable, + browserVideoEncoderAvailable: false, + }); + const withEncoder = await finalExport.routeSequencerFinalExport(request(), { + serverExportAvailable, + browserVideoEncoderAvailable: true, + }); + assert.equal(withoutEncoder.route, "SERVER_EXPORT"); + assert.equal(withEncoder.route, "SERVER_EXPORT"); + assert.equal(withoutEncoder.localEncoding, "BLOCKED"); + assert.equal(withEncoder.localEncoding, "BLOCKED"); + assert.equal(withEncoder.browserVideoEncoderDetected, true); + assert.deepEqual( + { ...withEncoder, browserVideoEncoderDetected: false }, + withoutEncoder, + ); + } +}); + +test("M11-12 hashes settings and source revision into deterministic request identities", async () => { + const baseline = await finalExport.routeSequencerFinalExport(request(), { + serverExportAvailable: true, + browserVideoEncoderAvailable: false, + }); + assert.equal(baseline.settingsSha256, golden.settingsSha256); + assert.equal(baseline.requestSha256, golden.requestSha256); + assert.deepEqual( + await finalExport.routeSequencerFinalExport({ ...request(), container: "MPEG4" }, { + serverExportAvailable: true, + browserVideoEncoderAvailable: false, + }), + baseline, + ); + + const revisionDrift = await finalExport.routeSequencerFinalExport(request({ timelineRevision: 2 }), { + serverExportAvailable: true, + browserVideoEncoderAvailable: false, + }); + assert.equal(revisionDrift.settingsSha256, baseline.settingsSha256); + assert.notEqual(revisionDrift.requestSha256, baseline.requestSha256); + + const settingsDrift = await finalExport.routeSequencerFinalExport(request({ width: 1280 }), { + serverExportAvailable: true, + browserVideoEncoderAvailable: false, + }); + assert.notEqual(settingsDrift.settingsSha256, baseline.settingsSha256); + assert.notEqual(settingsDrift.requestSha256, baseline.requestSha256); +}); + +test("M11-12 rejects undeclared, malformed, and over-budget export requests", async () => { + await assert.rejects( + finalExport.routeSequencerFinalExport({ ...request(), localEncoding: "READY" }, { + serverExportAvailable: true, + browserVideoEncoderAvailable: true, + }), + { code: "SEQUENCER_EXPORT_REQUEST_INVALID" }, + ); + await assert.rejects( + finalExport.routeSequencerFinalExport(request({ container: "WEBM", videoCodec: "H264" }), { + serverExportAvailable: true, + browserVideoEncoderAvailable: true, + }), + { code: "SEQUENCER_EXPORT_REQUEST_INVALID" }, + ); + await assert.rejects( + finalExport.routeSequencerFinalExport(request({ frameStart: -1_000_000, frameEnd: 1_000_000 }), { + serverExportAvailable: true, + browserVideoEncoderAvailable: true, + }), + { code: "SEQUENCER_EXPORT_REQUEST_INVALID" }, + ); + await assert.rejects( + finalExport.routeSequencerFinalExport(request(), { + serverExportAvailable: true, + browserVideoEncoderAvailable: true, + codecName: "h264", + }), + { code: "SEQUENCER_EXPORT_REQUEST_INVALID" }, + ); +}); diff --git a/web/tests/unit/sequencer-media-cache.test.mjs b/web/tests/unit/sequencer-media-cache.test.mjs new file mode 100644 index 00000000..bdf0bc11 --- /dev/null +++ b/web/tests/unit/sequencer-media-cache.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-media-cache-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + const sourcePath = path.join(repoRoot, "web/protocol", sourceName); + const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, + }); + assert.deepEqual(result.diagnostics, []); + fs.writeFileSync(path.join(temporary, outputName), replacements.reduce((value, [from, to]) => value.replaceAll(from, to), result.outputText)); +} + +transpile("capability-gates.ts", "capability-gates.mjs"); +transpile("asset-path.ts", "asset-path.mjs"); +transpile("sequencer.ts", "sequencer.mjs", [ + ['from "./capability-gates"', 'from "./capability-gates.mjs"'], + ['from "./asset-path"', 'from "./asset-path.mjs"'], +]); +transpile("sequencer-media-cache.ts", "sequencer-media-cache.mjs", [ + ['from "./sequencer"', 'from "./sequencer.mjs"'], +]); +const mediaCache = await import(pathToFileURL(path.join(temporary, "sequencer-media-cache.mjs"))); + +const source = { + schemaVersion: 1, + stripType: "MOVIE", + mimeType: "video/mp4", + byteLength: 1_484, + sourceSha256: "a".repeat(64), +}; +const capability = { + ...source, + status: "READY", + backend: "HTML_MEDIA", + reason: null, + decoded: { width: 16, height: 16, durationMicros: 1_000_000 }, +}; +const profile = { + kind: "MOVIE_RGBA8_FRAME", + width: 2, + height: 2, + colorSpace: "SRGB8", + alphaMode: "STRAIGHT", +}; +const payload = new Uint8Array([ + 1, 2, 3, 255, 4, 5, 6, 255, + 7, 8, 9, 255, 10, 11, 12, 255, +]).buffer; + +test("M11-10 binds proxy identity to source, decode receipt, profile and source frame", async () => { + const manifest = await mediaCache.createSequencerMediaCacheManifest(source, capability, profile, 12, payload); + assert.equal(manifest.payloadByteLength, 16); + assert.match(manifest.identitySha256, /^[a-f0-9]{64}$/); + assert.match(manifest.payloadSha256, /^[a-f0-9]{64}$/); + assert.equal(mediaCache.sequencerMediaCacheKey(manifest), `sequencer-media-cache:v1:${manifest.identitySha256}`); + assert.deepEqual(await mediaCache.verifySequencerMediaCacheEntry(manifest, payload, source, capability), manifest); + + const reordered = { ...capability, decoded: { durationMicros: 1_000_000, height: 16, width: 16 } }; + assert.equal( + await mediaCache.computeSequencerMediaCacheIdentity(source, capability, profile, 12), + await mediaCache.computeSequencerMediaCacheIdentity(source, reordered, profile, 12), + ); + assert.notEqual( + await mediaCache.computeSequencerMediaCacheIdentity(source, capability, profile, 12), + await mediaCache.computeSequencerMediaCacheIdentity(source, capability, profile, 13), + ); +}); + +test("M11-10 rejects source, decode capability, identity and payload drift independently", async () => { + const manifest = await mediaCache.createSequencerMediaCacheManifest(source, capability, profile, 0, payload); + const changedSource = { ...source, sourceSha256: "b".repeat(64) }; + const changedSourceCapability = { ...capability, sourceSha256: changedSource.sourceSha256 }; + await assert.rejects( + mediaCache.verifySequencerMediaCacheEntry(manifest, payload, changedSource, changedSourceCapability), + { code: "SEQUENCER_CACHE_SOURCE_MISMATCH" }, + ); + const changedCapability = { ...capability, decoded: { ...capability.decoded, durationMicros: 999_999 } }; + await assert.rejects( + mediaCache.verifySequencerMediaCacheEntry(manifest, payload, source, changedCapability), + { code: "SEQUENCER_CACHE_CAPABILITY_MISMATCH" }, + ); + await assert.rejects( + mediaCache.verifySequencerMediaCacheEntry({ ...manifest, identitySha256: "c".repeat(64) }, payload, source, capability), + { code: "SEQUENCER_CACHE_IDENTITY_MISMATCH" }, + ); + const corrupted = payload.slice(0); + new Uint8Array(corrupted)[0] ^= 0xff; + await assert.rejects( + mediaCache.verifySequencerMediaCacheEntry(manifest, corrupted, source, capability), + { code: "SEQUENCER_CACHE_HASH_MISMATCH" }, + ); +}); + +test("M11-10 keeps blocked receipts, extension fields and oversized profiles out of cache", async () => { + await assert.rejects( + mediaCache.createSequencerMediaCacheManifest(source, { ...capability, status: "BLOCKED", backend: null, reason: "RUNTIME_UNAVAILABLE", decoded: null }, profile, 0, payload), + { code: "SEQUENCER_CODEC_UNSUPPORTED" }, + ); + assert.throws(() => mediaCache.parseSequencerMediaProxyProfile({ ...profile, codec: "h264" }), { code: "SEQUENCER_SCHEMA_INVALID" }); + assert.throws( + () => mediaCache.parseSequencerMediaProxyProfile({ ...profile, width: 4096, height: 4097 }), + { code: "SEQUENCER_BUDGET_EXCEEDED" }, + ); + await assert.rejects( + mediaCache.createSequencerMediaCacheManifest(source, capability, { ...profile, width: 17 }, 0, new ArrayBuffer(17 * 2 * 4)), + { code: "SEQUENCER_SCHEMA_INVALID" }, + ); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/sequencer-media-revision.test.mjs b/web/tests/unit/sequencer-media-revision.test.mjs new file mode 100644 index 00000000..ea809d88 --- /dev/null +++ b/web/tests/unit/sequencer-media-revision.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-media-revision-unit-")); +const sourcePath = path.join(repoRoot, "web/protocol/sequencer-media-revision.ts"); +const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(result.diagnostics, []); +fs.writeFileSync(path.join(temporary, "sequencer-media-revision.mjs"), result.outputText); +const revision = await import(pathToFileURL(path.join(temporary, "sequencer-media-revision.mjs"))); + +const request = (operation, requestRevision, frame = 24) => ({ + schemaVersion: 1, + requestId: `media:${requestRevision}`, + timelineId: "sequencer:main", + timelineRevision: 7, + requestRevision, + operation, + frame, +}); +const completed = (value) => ({ + ...value, + status: "COMPLETED", + sourceFrame: value.frame, + payloadSha256: "a".repeat(64), +}); +const state = (latestRequestRevision, timelineRevision = 7) => ({ + schemaVersion: 1, + timelineId: "sequencer:main", + timelineRevision, + latestRequestRevision, +}); + +test("M11-11 publishes matching latest SEEK, SCRUB and DECODE results", () => { + for (const [index, operation] of ["SEEK", "SCRUB", "DECODE"].entries()) { + const active = request(operation, index + 1); + assert.deepEqual(revision.gateSequencerMediaRevision(active, state(index + 1), completed(active)), { + status: "PUBLISH", + code: null, + operation, + requestRevision: index + 1, + }); + } +}); + +test("M11-11 marks superseded and timeline-replaced results stale", () => { + const oldSeek = request("SEEK", 3); + assert.deepEqual(revision.gateSequencerMediaRevision(oldSeek, state(4), completed(oldSeek)), { + status: "STALE", code: "REVISION_CONFLICT", operation: "SEEK", requestRevision: 3, + }); + const oldDecode = request("DECODE", 5); + assert.deepEqual(revision.gateSequencerMediaRevision(oldDecode, state(6, 8), completed(oldDecode)), { + status: "STALE", code: "REVISION_CONFLICT", operation: "DECODE", requestRevision: 5, + }); +}); + +test("M11-11 rejects result identity forgery without publishing it", () => { + const active = request("SCRUB", 9, 48); + for (const forged of [ + { ...completed(active), requestId: "media:forged" }, + { ...completed(active), timelineRevision: 8 }, + { ...completed(active), requestRevision: 10 }, + { ...completed(active), operation: "DECODE" }, + { ...completed(active), frame: 49 }, + ]) { + assert.equal(revision.gateSequencerMediaRevision(active, state(9), forged).status, "STALE"); + } +}); + +test("M11-11 rejects undeclared fields, invalid operations and fractional revisions", () => { + const active = request("SEEK", 1); + assert.throws(() => revision.parseSequencerMediaRevisionRequest({ ...active, signal: "late" }), { code: "SEQUENCER_SCHEMA_INVALID" }); + assert.throws(() => revision.parseSequencerMediaRevisionRequest({ ...active, operation: "PLAY" }), { code: "SEQUENCER_SCHEMA_INVALID" }); + assert.throws(() => revision.parseSequencerMediaRevisionState({ ...state(1), timelineRevision: 7.5 }), { code: "SEQUENCER_SCHEMA_INVALID" }); + assert.throws(() => revision.parseSequencerMediaRevisionResult({ ...completed(active), payloadSha256: "bad" }), { code: "SEQUENCER_SCHEMA_INVALID" }); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/server-render-job.test.mjs b/web/tests/unit/server-render-job.test.mjs new file mode 100644 index 00000000..de9579d0 --- /dev/null +++ b/web/tests/unit/server-render-job.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(root, "web/protocol/server-render-job.ts"); +const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""); +const transpiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const jobs = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64")); + +const sourceBytes = new TextEncoder().encode("server-render-source-v1").buffer; +const build = { version: "5.2.0", buildSha256: "a".repeat(64) }; +const settings = { + renderEngine: "BLENDER_CYCLES", + frameStart: 1, + frameEnd: 1, + resolutionX: 32, + resolutionY: 32, + resolutionPercentage: 100, + samples: 4, + outputMime: "image/png", + transparent: false, +}; + +test("M11-06 binds source bytes, Blender build and canonical render settings", async () => { + const request = await jobs.createServerRenderJobRequest(sourceBytes, build, settings, { jobId: "render:test", sourceRevision: 7 }); + assert.equal(request.sourceBlendByteLength, sourceBytes.byteLength); + assert.match(request.sourceBlendSha256, /^[a-f0-9]{64}$/); + assert.match(request.settingsSha256, /^[a-f0-9]{64}$/); + assert.match(request.requestSha256, /^[a-f0-9]{64}$/); + assert.deepEqual(await jobs.verifyServerRenderJobRequest(request, sourceBytes), request); + const reordered = await jobs.createServerRenderJobRequest(sourceBytes, build, { transparent: settings.transparent, ...settings }, { jobId: "render:test", sourceRevision: 7 }); + assert.equal(reordered.settingsSha256, request.settingsSha256); + assert.equal(await jobs.createServerRenderJobKey(request), await jobs.createServerRenderJobKey(reordered)); +}); + +test("M11-06 verifies output hash and rejects every provenance drift", async () => { + const request = await jobs.createServerRenderJobRequest(sourceBytes, build, settings, { jobId: "render:verify", sourceRevision: 2 }); + const output = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]).buffer; + const result = await jobs.createServerRenderJobResult(request, output); + assert.equal(result.status, "SUCCEEDED"); + assert.deepEqual(await jobs.verifyServerRenderJobResult(result, request, output), result); + await assert.rejects(jobs.verifyServerRenderJobRequest(request, Uint8Array.from([1, 2, 3]).buffer), { code: "SERVER_RENDER_SOURCE_HASH_MISMATCH" }); + await assert.rejects(jobs.verifyServerRenderJobResult(result, request, Uint8Array.from([9, 9]).buffer), { code: "SERVER_RENDER_OUTPUT_HASH_MISMATCH" }); + await assert.rejects(jobs.verifyServerRenderJobResult({ ...result, sourceBlendSha256: "b".repeat(64) }, request, output), { code: "SERVER_RENDER_BINDING_MISMATCH" }); + await assert.rejects(jobs.verifyServerRenderJobRequest({ ...request, blenderBuild: { ...build, buildSha256: "b".repeat(64) } }), { code: "SERVER_RENDER_REQUEST_HASH_MISMATCH" }); + await assert.rejects(jobs.createServerRenderJobRequest(sourceBytes, { ...build, version: "5.3.0" }, settings), { code: "SERVER_RENDER_BUILD_INVALID" }); + await assert.rejects(jobs.createServerRenderJobRequest(sourceBytes, build, { ...settings, samples: 0 }), { code: "SERVER_RENDER_SETTINGS_INVALID" }); +}); + +test("M11-06 rejects malformed result metadata and settings budget abuse", async () => { + await assert.rejects(jobs.createServerRenderJobRequest(sourceBytes, build, { ...settings, payload: "x".repeat(300_000) }), { code: "SERVER_RENDER_SETTINGS_INVALID" }); + const request = await jobs.createServerRenderJobRequest(sourceBytes, build, settings); + const result = await jobs.createServerRenderJobResult(request, Uint8Array.from([1]).buffer); + await assert.rejects(jobs.verifyServerRenderJobResult({ ...result, outputByteLength: result.outputByteLength + 1 }, request), { code: "SERVER_RENDER_RESULT_HASH_MISMATCH" }); + await assert.rejects(jobs.verifyServerRenderJobResult({ ...result, outputMime: "image/openexr" }, request), { code: "SERVER_RENDER_OUTPUT_INVALID" }); + await assert.rejects(jobs.verifyServerRenderJobResult({ ...result, errorCode: "SERVER_RENDER_FAILED" }, request), { code: "SERVER_RENDER_OUTPUT_INVALID" }); + await assert.rejects(jobs.verifyServerRenderJobRequest({ ...request, unexpected: true }), { code: "SERVER_RENDER_REQUEST_INVALID" }); +}); diff --git a/web/tests/unit/shader-compiler.test.mjs b/web/tests/unit/shader-compiler.test.mjs new file mode 100644 index 00000000..dbbcf6bf --- /dev/null +++ b/web/tests/unit/shader-compiler.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "shader-compiler-unit-")); +const sourcePath = path.join(root, "web/protocol/shader-compiler.ts"); +const outputPath = path.join(temporary, "shader-compiler.mjs"); +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, []); +fs.writeFileSync(outputPath, transpiled.outputText); +const compiler = await import(pathToFileURL(outputPath)); + +function material(overrides = {}) { + return { + id: "material:ShaderUnit", + name: "ShaderUnit", + baseColor: [0.2, 0.3, 0.4, 1], + roughness: 0.5, + metallic: 0.1, + emissionColor: [0, 0, 0, 1], + alpha: 1, + ior: 1.45, + shaderGraphHash: "a".repeat(64), + nodes: [ + { id: "rgb", type: "RGB", name: "RGB", defaultValue: [0.1, 0.2, 0.3, 1] }, + { id: "value-a", type: "VALUE", name: "A", defaultValue: [0.2] }, + { id: "value-b", type: "VALUE", name: "B", defaultValue: [0.22] }, + { id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } }, + { id: "image", type: "IMAGE_TEXTURE", name: "Image", imageId: "image:Normal" }, + { id: "normal", type: "NORMAL_MAP", name: "Normal" }, + { id: "principled", type: "PRINCIPLED", name: "Principled" }, + { id: "output", type: "OUTPUT", name: "Output" }, + ], + links: [ + { fromNodeId: "rgb", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" }, + { fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" }, + { fromNodeId: "value-b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" }, + { fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" }, + { fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" }, + { fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" }, + { fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }, + ], + ...overrides, + }; +} + +test("M10-07 compiles the declared Principled/Image/Normal/Math closure", () => { + const report = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]) }); + assert.equal(report.status, "COMPILED"); + assert.equal(report.taskId, "M10-07"); + assert.equal(report.backend, "WEBGL2_THREE_PHYSICAL"); + assert.equal(report.material.baseColor[2], 0.3); + assert.ok(Math.abs(report.material.roughness - 0.42) < 1e-8); + assert.deepEqual(report.textureBindings, [{ imageId: "image:Normal", usage: "NORMAL" }]); + assert.match(report.graphHash, /^[0-9a-f]{64}$/); + assert.ok(report.instructions.some((instruction) => instruction.operation === "ADD")); + assert.ok(report.nodeOrder.indexOf("value-a") < report.nodeOrder.indexOf("math")); + assert.ok(report.nodeOrder.indexOf("math") < report.nodeOrder.indexOf("principled")); + assert.ok(report.nodeOrder.indexOf("principled") < report.nodeOrder.indexOf("output")); +}); + +test("M10-07 blocks unknown nodes and preserves the graph metadata", () => { + const source = material(); + source.nodes.push({ id: "mix", type: "UNSUPPORTED", name: "ShaderNodeMix" }); + const report = compiler.compileMaterialGraph(source); + assert.equal(report.status, "BLOCKED"); + assert.equal(report.issues[0].code, "SHADER_NODE_UNSUPPORTED"); + assert.equal(source.nodes.at(-1).type, "UNSUPPORTED"); +}); + +test("M10-07 rejects cycles, duplicate links, and missing image resources", () => { + const cyclic = material({ + links: [ + { fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" }, + { fromNodeId: "math", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" }, + { fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" }, + { fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }, + { fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" }, + { fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" }, + ], + }); + const cyclicReport = compiler.compileMaterialGraph(cyclic); + assert.equal(cyclicReport.status, "BLOCKED"); + assert.ok(cyclicReport.issues.some((issue) => issue.code === "SHADER_GRAPH_CYCLE")); + const missingReport = compiler.compileMaterialGraph(material(), { imageIds: new Set() }); + assert.equal(missingReport.status, "BLOCKED"); + assert.ok(missingReport.issues.some((issue) => issue.code === "SHADER_EXTERNAL_RESOURCE_MISSING")); +}); + +test("M10-07 keeps the fallback graph fingerprint deterministic", () => { + const source = material(); + delete source.shaderGraphHash; + const first = compiler.compileMaterialGraph(source); + const second = compiler.compileMaterialGraph(source); + assert.equal(first.graphHash, second.graphHash); + assert.match(first.graphHash, /^[0-9a-f]{64}$/); + const canonical = JSON.stringify({ + schemaVersion: 1, + materialId: source.id, + nodes: source.nodes.map((node) => ({ + id: node.id, + type: node.type, + name: node.name, + imageId: node.imageId ?? null, + defaultValue: node.defaultValue ?? null, + properties: node.properties ?? null, + })), + links: source.links.map((link) => ({ + fromNodeId: link.fromNodeId, + fromSocket: link.fromSocket, + toNodeId: link.toNodeId, + toSocket: link.toSocket, + })), + }); + assert.equal(first.graphHash, createHash("sha256").update(canonical).digest("hex")); +}); + +test("M10-07 fails closed before oversized topology or forged graph hashes are compiled", () => { + const oversized = material({ + nodes: Array.from({ length: compiler.SHADER_COMPILE_BUDGET.maxNodes + 1 }, (_value, index) => ({ + id: `value:${index}`, + type: "VALUE", + name: `Value ${index}`, + defaultValue: [0], + })), + links: [], + }); + const oversizedReport = compiler.compileMaterialGraph(oversized); + assert.equal(oversizedReport.status, "BLOCKED"); + assert.equal(oversizedReport.issues[0].code, "SHADER_NODE_UNSUPPORTED"); + const forged = compiler.compileMaterialGraph(material({ shaderGraphHash: "A".repeat(64) })); + assert.equal(forged.status, "BLOCKED"); + assert.equal(forged.issues[0].code, "SHADER_INVALID_GRAPH"); +}); + +test("M10-08 binds graph, texture identity, color space and backend into compileKey", () => { + const textureIdentities = new Map([["image:Normal", { + assetId: "asset:normal-v1", + sha256: "b".repeat(64), + colorSpace: "NON_COLOR", + }]]); + const first = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities }); + const second = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities: new Map([["image:Normal", { ...textureIdentities.get("image:Normal"), sha256: "c".repeat(64) }]]) }); + const linear = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities: new Map([["image:Normal", { ...textureIdentities.get("image:Normal"), colorSpace: "LINEAR" }]]) }); + assert.equal(first.status, "COMPILED"); + assert.match(first.compileKey, /^[0-9a-f]{64}$/); + assert.notEqual(first.compileKey, second.compileKey); + assert.notEqual(first.compileKey, linear.compileKey); + assert.notEqual( + first.compileKey, + compiler.createShaderCompileKey({ + graphHash: first.graphHash, + rendererBackend: "WEBGPU", + textures: [{ imageId: "image:Normal", usage: "NORMAL", assetId: "asset:normal-v1", sha256: "b".repeat(64), colorSpace: "NON_COLOR" }], + }), + ); +}); + +test("M10-08 rejects an unknown renderer backend and malformed texture identity", () => { + const backend = compiler.compileMaterialGraph(material(), { rendererBackend: "WEBGPU" }); + assert.equal(backend.status, "BLOCKED"); + assert.equal(backend.issues[0].code, "CAPABILITY_MISSING"); + const malformed = compiler.compileMaterialGraph(material(), { + imageIds: new Set(["image:Normal"]), + textureIdentities: new Map([["image:Normal", { sha256: "not-a-digest" }]]), + }); + assert.equal(malformed.status, "BLOCKED"); + assert.equal(malformed.issues.at(-1).code, "SHADER_INVALID_GRAPH"); +}); diff --git a/web/tests/unit/simulation-cache.test.mjs b/web/tests/unit/simulation-cache.test.mjs new file mode 100644 index 00000000..e8339183 --- /dev/null +++ b/web/tests/unit/simulation-cache.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "simulation-cache-unit-")); +const sourcePath = path.join(root, "web/protocol/simulation-cache.ts"); +const outputPath = path.join(temporary, "simulation-cache.mjs"); +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, []); +fs.writeFileSync(outputPath, transpiled.outputText); +const simulation = await import(pathToFileURL(outputPath)); + +const digest = async (value) => Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", value)), + (byte) => byte.toString(16).padStart(2, "0"), +).join(""); + +async function manifest(overrides = {}) { + const payload = Uint8Array.from([1, 2, 3, 4]).buffer; + const binding = { + graphId: "node-group:SimulationUnit", + graphHash: await digest(Uint8Array.from([1]).buffer), + sourceBlendSha256: await digest(Uint8Array.from([2]).buffer), + sourceRevision: 7, + inputHash: await digest(Uint8Array.from([3]).buffer), + blenderVersion: "5.2.0", + frameStart: 1, + frameEnd: 1, + }; + return { + schemaVersion: 2, + ...binding, + revisionHash: await simulation.computeSimulationCacheRevisionHash(binding), + cacheSha256: await digest(payload), + byteLength: payload.byteLength, + frames: [{ frame: 1, byteOffset: 0, byteLength: payload.byteLength, sha256: await digest(payload) }], + ...overrides, + }; +} + +test("M10-05 binds a cache key to the full graph, source, revision and input identity", async () => { + const value = await manifest(); + const verified = await simulation.verifySimulationCacheRevisionBinding(value); + assert.equal(verified.sourceRevision, 7); + assert.equal(simulation.simulationCacheKey(verified), `sim2-${value.revisionHash}`); + assert.equal(simulation.simulationCacheKey(verified).length, 69); +}); + +test("M10-05 rejects graph, source, revision, input and range drift", async () => { + const value = await manifest(); + for (const patch of [ + { graphHash: "0".repeat(64) }, + { sourceBlendSha256: "1".repeat(64) }, + { sourceRevision: 8 }, + { inputHash: "2".repeat(64) }, + { frameStart: 2, frameEnd: 2, frames: [{ ...value.frames[0], frame: 2 }] }, + ]) { + await assert.rejects( + simulation.verifySimulationCacheRevisionBinding({ ...value, ...patch }), + { code: "SIMULATION_CACHE_REVISION_MISMATCH" }, + ); + } +}); + +test("M10-05 rejects legacy schemas, undeclared fields and invalid revisions", async () => { + const value = await manifest(); + assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, schemaVersion: 1 }), { code: "PROTOCOL_MISMATCH" }); + assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, sourceRevision: -1 }), { code: "SIMULATION_CACHE_INVALID" }); + assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, staleKey: "accepted" }), { code: "SIMULATION_CACHE_INVALID" }); + assert.throws(() => simulation.parseSimulationCacheManifest({ + ...value, + frames: [{ ...value.frames[0], payload: [1, 2, 3, 4] }], + }), { code: "SIMULATION_CACHE_INVALID" }); +}); + +test("M10-15 reports cache byte and frame budgets before allocating payload bytes", async () => { + const value = await manifest(); + assert.throws(() => simulation.parseSimulationCacheManifest({ + ...value, + byteLength: simulation.SIMULATION_CACHE_BUDGET.maxCacheBytes + 1, + }), { code: "SIMULATION_CACHE_BUDGET_EXCEEDED" }); + assert.throws(() => simulation.parseSimulationCacheManifest({ + ...value, + frameEnd: value.frameStart + simulation.SIMULATION_CACHE_BUDGET.maxFrames, + }), { code: "SIMULATION_CACHE_BUDGET_EXCEEDED" }); +}); + +test("M10-06 plans deterministic LRU eviction while retaining protected playback caches", () => { + const key = (digit) => `sim2-${digit.repeat(64)}`; + const candidates = [ + { cacheKey: key("a"), byteLength: 4, createdAt: "2026-08-16T12:00:00.000Z", lastAccessAt: "2026-08-16T12:00:00.000Z" }, + { cacheKey: key("b"), byteLength: 4, createdAt: "2026-08-16T12:00:01.000Z", lastAccessAt: "2026-08-16T12:00:01.000Z" }, + { cacheKey: key("c"), byteLength: 4, createdAt: "2026-08-16T12:00:02.000Z", lastAccessAt: "2026-08-16T12:00:02.000Z" }, + ]; + const plan = simulation.planSimulationCacheLRU(candidates, 4, [key("a")]); + assert.deepEqual(plan.cacheKeys, [key("b"), key("c")]); + assert.deepEqual(plan.protectedCacheKeys, [key("a")]); + assert.equal(plan.beforeBytes, 12); + assert.equal(plan.remainingBytes, 4); + assert.equal(plan.removedBytes, 8); + assert.equal(plan.budgetSatisfied, true); +}); + +test("M10-06 reports an unsatisfied LRU budget instead of evicting an active cache", () => { + const cacheKey = `sim2-${"d".repeat(64)}`; + const plan = simulation.planSimulationCacheLRU([ + { cacheKey, byteLength: 8, createdAt: "2026-08-16T12:00:00.000Z", lastAccessAt: "2026-08-16T12:00:00.000Z" }, + ], 0, [cacheKey]); + assert.deepEqual(plan.cacheKeys, []); + assert.equal(plan.remainingBytes, 8); + assert.equal(plan.budgetSatisfied, false); +}); + +test.after(() => fs.rmSync(temporary, { recursive: true, force: true })); diff --git a/web/tests/unit/storage-budget.test.mjs b/web/tests/unit/storage-budget.test.mjs new file mode 100644 index 00000000..5e53fd4f --- /dev/null +++ b/web/tests/unit/storage-budget.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/storage-budget.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const { createStorageBudget, formatStorageBytes } = await import(moduleUrl); + +test("M7-11 storage budget reports stable category totals", () => { + const budget = createStorageBudget("budget", { projectBytes: 10, snapshotBytes: 20, lodBytes: 30, mediaBytes: 40, vdbBytes: 50 }); + assert.deepEqual(budget, { schemaVersion: 1, projectId: "budget", projectBytes: 10, snapshotBytes: 20, lodBytes: 30, mediaBytes: 40, vdbBytes: 50, totalBytes: 150 }); + assert.equal(formatStorageBytes(1024), "1.0 KiB"); + assert.equal(formatStorageBytes(1024 * 1024), "1.0 MiB"); +}); + +test("M7-11 storage budget rejects invalid or overflowing categories", () => { + assert.throws(() => createStorageBudget("budget", { mediaBytes: -1 }), /STORAGE_BUDGET_INVALID/); + assert.throws(() => createStorageBudget("budget", { projectBytes: Number.MAX_SAFE_INTEGER, mediaBytes: Number.MAX_SAFE_INTEGER }), /STORAGE_BUDGET_INVALID/); + assert.throws(() => formatStorageBytes(-1), /STORAGE_BUDGET_INVALID/); +}); diff --git a/web/tests/unit/texture-paint-asset.test.mjs b/web/tests/unit/texture-paint-asset.test.mjs new file mode 100644 index 00000000..d630fb7b --- /dev/null +++ b/web/tests/unit/texture-paint-asset.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "texture-paint-asset-unit-")); + +function transpile(sourceName, outputName, replacements = []) { + const sourcePath = path.join(repoRoot, "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 = replacements.reduce((source, [from, to]) => source.replace(from, to), transpiled.outputText); + fs.writeFileSync(path.join(temporary, outputName), output); +} + +transpile("paint.ts", "paint.mjs"); +transpile("texture-paint-asset.ts", "texture-paint-asset.mjs", [['from "./paint"', 'from "./paint.mjs"']]); +const protocol = await import(pathToFileURL(path.join(temporary, "texture-paint-asset.mjs"))); +const hash = "1".repeat(64); +const target = { + schemaVersion: 1, + projectId: "texture-project", + imageId: "image:Paint", + textureAssetId: "image:Paint:tile:1001", + kind: "PACKED", + tile: 1001, + revision: 4, + width: 2, + height: 2, + mimeType: "image/png", + colorSpace: "SRGB", + sourcePath: "textures/paint.png", + baseAssetSha256: hash, +}; +const patch = { + schemaVersion: 1, + textureAssetId: target.textureAssetId, + tile: 1001, + revision: 4, + width: 2, + height: 2, + format: "RGBA8", + colorSpace: "SRGB", + baseSha256: "2".repeat(64), + resultSha256: "3".repeat(64), + byteOffset: 0, + bytes: new Uint8Array([1, 2, 3, 255]), +}; + +test("M9-11 binds a dirty range to one packed or UDIM tile identity", () => { + const parsed = protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target, patch }); + assert.equal(parsed.target.kind, "PACKED"); + assert.equal(parsed.patch.textureAssetId, target.textureAssetId); + assert.equal(protocol.texturePaintTileBindingKey({ schemaVersion: 1, projectId: target.projectId, textureAssetId: target.textureAssetId, tile: 1001 }), "texture-paint:v1:texture-project:image%3APaint%3Atile%3A1001:1001"); +}); + +test("M9-11 rejects target, tile, revision and path drift before storage", () => { + assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target, patch: { ...patch, tile: 1002 } }), /PAINT_SCHEMA_INVALID/); + assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target, patch: { ...patch, revision: 5 } }), /PAINT_SCHEMA_INVALID/); + assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target: { ...target, sourcePath: "../escape.png" }, patch }), /PAINT_SCHEMA_INVALID/); + assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target: { ...target, baseAssetSha256: "bad" }, patch }), /PAINT_SCHEMA_INVALID/); + assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target, patch, extra: true }), /PAINT_SCHEMA_INVALID/); +}); diff --git a/web/tests/unit/ui-schema.test.mjs b/web/tests/unit/ui-schema.test.mjs new file mode 100644 index 00000000..5ecdd515 --- /dev/null +++ b/web/tests/unit/ui-schema.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/ui-schema.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const { createDefaultWebWorkspaceState, reduceUICommand } = await import(moduleUrl); + +test("M7-13 menu and modal overlays are mutually exclusive and Escape-closeable", () => { + let state = createDefaultWebWorkspaceState(); + state = reduceUICommand(state, { type: "toggleMenu", menu: "文件" }); + assert.equal(state.openMenu, "文件"); + assert.equal(state.operatorSearchOpen, false); + state = reduceUICommand(state, { type: "toggleMenu", menu: "编辑" }); + assert.equal(state.openMenu, "编辑"); + assert.equal(state.operatorSearchOpen, false); + state = reduceUICommand(state, { type: "toggleOperatorSearch", open: true }); + assert.equal(state.operatorSearchOpen, true); + assert.equal(state.openMenu, null); + state = reduceUICommand(state, { type: "toggleOperatorSearch", open: false }); + assert.equal(state.operatorSearchOpen, false); + state = reduceUICommand(state, { type: "toggleMenu", menu: "窗口" }); + assert.equal(state.openMenu, "窗口"); + state = reduceUICommand(state, { type: "toggleMenu", menu: "窗口" }); + assert.equal(state.openMenu, null); +}); diff --git a/web/tests/unit/viewport-camera.test.mjs b/web/tests/unit/viewport-camera.test.mjs new file mode 100644 index 00000000..b8b41bca --- /dev/null +++ b/web/tests/unit/viewport-camera.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/viewport-camera.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const { VIEWPORT_DEFAULT_ORBIT, applyOrbitDelta, cameraState, orbitPosition, orbitStateFromPosition } = await import(moduleUrl); + +test("M7-15 main and Offscreen orbit camera contract is deterministic", () => { + const position = orbitPosition(VIEWPORT_DEFAULT_ORBIT); + assert.deepEqual(position.map((value) => Number(value.toFixed(6))), [4.219781, -4.219781, 3.658811]); + const restored = orbitStateFromPosition(position); + assert.ok(Math.abs(restored.yaw - VIEWPORT_DEFAULT_ORBIT.yaw) < 1e-12); + assert.ok(Math.abs(restored.pitch - VIEWPORT_DEFAULT_ORBIT.pitch) < 1e-12); + assert.ok(Math.abs(restored.distance - VIEWPORT_DEFAULT_ORBIT.distance) < 1e-12); + assert.deepEqual(restored.target, [0, 0, 0]); + const next = applyOrbitDelta({ ...VIEWPORT_DEFAULT_ORBIT, target: [0, 0, 0] }, 24, -12, 120); + assert.equal(next.yaw, VIEWPORT_DEFAULT_ORBIT.yaw - 24 * 0.008); + assert.equal(next.pitch, VIEWPORT_DEFAULT_ORBIT.pitch - 12 * 0.008); + assert.equal(next.distance, VIEWPORT_DEFAULT_ORBIT.distance * Math.exp(0.12)); + assert.deepEqual(cameraState(next).position, orbitPosition(next)); +}); diff --git a/web/tests/unit/weight-paint.test.mjs b/web/tests/unit/weight-paint.test.mjs new file mode 100644 index 00000000..1e5e364b --- /dev/null +++ b/web/tests/unit/weight-paint.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "weight-paint-unit-")); +const sourcePath = path.join(repoRoot, "web/protocol/weight-paint.ts"); +const outputPath = path.join(temporary, "weight-paint.mjs"); +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, []); +fs.writeFileSync(outputPath, transpiled.outputText); +const weightPaint = await import(pathToFileURL(outputPath)); + +const vertices = [ + { index: 0, position: [-1, 0, 0], influences: [{ group: "Root", weight: 1 }, { group: "Tip", weight: 0.25 }] }, + { index: 1, position: [1, 0, 0], influences: [{ group: "Root", weight: 1 }, { group: "Tip", weight: 0.25 }] }, + { index: 2, position: [0, 1, 0], influences: [{ group: "Root", weight: 0.25 }, { group: "Tip", weight: 1 }] }, +]; + +test("M9-12 normalizes after limiting influences", () => { + const result = weightPaint.applyWeightPaintPatch(vertices, { + schemaVersion: 1, + vertexGroup: "WebPaintGroup", + indices: [2], + values: [0.5], + normalize: true, + limit: 2, + }); + assert.deepEqual(result[2].influences, [ + { group: "Tip", weight: 2 / 3 }, + { group: "WebPaintGroup", weight: 1 / 3 }, + ]); +}); + +test("M9-12 mirrors onto a reciprocal verified coordinate map", () => { + const result = weightPaint.applyWeightPaintPatch(vertices, { + schemaVersion: 1, + vertexGroup: "WebPaintGroup", + indices: [0], + values: [0.8], + mirror: true, + mirrorAxis: 0, + mirrorTolerance: 1e-4, + }); + assert.equal(result[0].influences.at(-1).weight, 0.8); + assert.equal(result[1].influences.at(-1).weight, 0.8); +}); + +test("M9-12 rejects an unverified symmetry map and invalid limits", () => { + assert.throws(() => weightPaint.parseWeightPaintOptions({ limit: 33 }), /PAINT_SCHEMA_INVALID/); + assert.throws(() => weightPaint.applyWeightPaintPatch([ + ...vertices, + { index: 3, position: [4, 0, 0], influences: [] }, + ], { + schemaVersion: 1, + vertexGroup: "WebPaintGroup", + indices: [0], + values: [0.8], + mirror: true, + mirrorAxis: 0, + mirrorTolerance: 1e-4, + }), /WEIGHT_MIRROR_SYMMETRY_UNVERIFIED/); +}); diff --git a/web/tests/unit/worker-fault.test.mjs b/web/tests/unit/worker-fault.test.mjs new file mode 100644 index 00000000..b00cbd3d --- /dev/null +++ b/web/tests/unit/worker-fault.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/worker-fault.ts"); +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 moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const { createWorkerFault } = await import(moduleUrl); + +test("M7-08 maps engine and storage crashes to one recoverable fault contract", () => { + assert.deepEqual(createWorkerFault("engine", "engine crashed"), { + source: "engine", + error: { + code: "WORKER_TERMINATED", + severity: "error", + message: "engine crashed", + recoverable: true, + cause: "engine-worker-fault", + }, + }); + assert.deepEqual(createWorkerFault("storage", "storage crashed"), { + source: "storage", + error: { + code: "WORKER_TERMINATED", + severity: "error", + message: "storage crashed", + recoverable: true, + cause: "storage-worker-fault", + }, + }); +}); diff --git a/后续工作.txt b/后续工作.txt index d3bdc981..0f168a55 100644 --- a/后续工作.txt +++ b/后续工作.txt @@ -1,17 +1,22 @@ -Web Blender M6/M7 接续执行记录 -更新时间:2026-08-15(America/New_York) +Web Blender M6/M7/M8/M9/M10/M11 接续执行记录 +更新时间:2026-08-17(America/New_York) 执行规则 -1. 本文件是当前接续入口;每轮先读本文件,再按“唯一下一领取点”直接执行。 +1. 本文件是当前接续入口;每轮先读本文件,再直接领取机器队列返回的最新 `nextTask`。 2. 严格按当前里程碑原子任务顺序推进,前一项未通过时不把后一项标为完成。 -3. 每完成一段,写回实际命令、结果、hash、当前计数和下一领取点。 +3. 每完成一段,写回实际命令、结果、hash 和当前计数;后续任务名只从机器队列的最新 + `nextTask` 获取,不在本文手工维护或推导。 4. 不覆盖用户已有改动,不提交或改写 git 历史。 当前状态 - M6 正式完成:71/71;剩余:0。 -- M7 正式完成:7/18;剩余:11。 -- 已完成到:M7-07。 -- 唯一下一领取点:M7-08(Worker 崩溃时展示可恢复错误,不清空当前项目列表)。 +- M7 正式完成:18/18;剩余:0。 +- M8 当前完成:20/20;剩余:0。 +- M9 当前完成:14/14;剩余:0。 +- M10 正式完成:15/15;剩余:0。 +- M11 当前完成:13/14;剩余:1。 +- 已完成到:M11-13。 +- 下一领取点:机器队列的最新 `nextTask`(本文不缓存任务名称)。 - quick lane:READY,7/7 命令通过,总命令耗时 11,455 ms,报告 release/ci-reports/quick.json。 - Chromium lane:READY,9/9 命令通过;每个 browser server 使用独立动态端口,报告 @@ -19,6 +24,79 @@ Web Blender M6/M7 接续执行记录 - release lane:READY,25/25 命令通过,总命令耗时 1,334,766 ms,报告 release/ci-reports/release.json。 +当前执行中(2026-08-16 America/New_York) +- M10-07 已完成:新增有界 `WEBGL2_THREE_PHYSICAL` material compile report;Worker 在 + `setShaderGraph` Main transaction 前编译并失败关闭,主线程/Offscreen 共用同一 + `createPBRMaterial` 结果。`docs/status/M10-07.md`、`tests/golden/M10-07/` 和 + `npm --prefix web run test:shader-compile` 证据已补齐。 +- M10-08 已完成:`ShaderCompileReport.compileKey` 对 graph hash、排序后的纹理 image/usage/ + asset/SHA-256、解析色彩空间和 renderer backend 做 SHA-256 绑定;Engine Worker 和 PBR + adapter 传递同一纹理身份。M10-09 已完成:`PBRMaterialPipeline` 在失败编译时保留旧材质, + 成功编译才原子替换并释放旧材质。 +- M10-10 已完成:`ARBITRARY_SHADER` capability query 接受任意字符串节点类型,未知类型去重并 + 排序后返回稳定 `PBR-012/ARBITRARY_SHADER`、`SHADER_NODE_UNSUPPORTED`、recoverable block; + ShaderGraph/Worker 仍在 Main transaction 前保留原图并阻断。 +- M10-11 已完成:桌面 Blender 5.2 fixture 的 1 个 Track、2 个 Action Clip 以 scale=2、 + reverse 和 repeat=2 覆盖 12 个边界/周期帧;Node WASM 与生产 Chromium Worker 的 + world matrix 最大误差均为 0,逐帧求值前后 NLA/Action identity 不变。本任务未调用 + `setNLAStack`,NLA operator、Physics solver 和 cache fault 门不提前计入完成。 +- M10-12 已完成有限单个 NLA operator:`moveNLAStrip` 要求 `baseRevision`,Worker 纯函数 + 保持 source stack 不变并保留 strip duration,native 以一次 Main transaction 提交; + stale revision 返回 `REVISION_CONFLICT`,成功后 revision/delta 单调,undo/redo 和 save/reopen + 均恢复/保留移动结果。create/remove/resize/active 与多轨混合不在本项声明内。 +- M10-13 已完成逐 family Physics solver probe 门:七类分别检查 export、初始化、线程和内存, + 任一失败均保持 `DESKTOP_SERVER_BAKE` 路由;当前生产 inventory 没有 solver adapter,七类都不 + 声明本地 solver。desktop family decoder、浏览器 bake 和 server job 仍保持阻断。 +- M10-14 已完成逐 family Physics cache 门:schema/family/desktop-server source/Blender 5.2、 + source/settings/input/cache SHA-256、frame range、总/逐帧 byte range/hash 全部版本化;消费前对 + 实际 source/payload/frame bytes 复算 hash。family decoder/playback 仍不误报 READY。 +- M10-15 已完成四域隔离 Chromium Worker 门:GN/Shader/NLA/Simulation 分别通过代表性性能、 + 超预算/OOM-prevention、恶意输入和同会话小输入恢复。NLA 新增显式 topology/text 预算, + Simulation cache 超限使用专用 budget code。M10 15/15 已收口并进入持续回归。 +- M11-01 已完成 Camera/Light/World/Scene color-management 字段级 parity 表:TypeScript AST + 自动核对 60 个叶字段零漏项/重复,24 COMPLETE、17 PARTIAL、19 BLOCKED;表分别记录 reader、 + writer、主线程 viewport 和 Offscreen viewport,不把 metadata-only/近似映射计为完整 parity。 +- M11-02 已完成支持字段闭环:Camera/Light/World 通过 Blender Main edit、逐域 undo/redo、保存、 + 新 Worker 重开与共享 PBR 映射;水平/垂直 sensor fit、色温线性 RGB、shadow、spot/area 和 + World 背景均有 Chromium 字段断言。 +- M11-03 已完成显式渲染资源预算:主线程/Offscreen 共用 light/shadow/texture planner,超限 + 只保留确定性前缀并公开稳定 code;纹理 aggregate 预算在 decode 前原子阻断且保留旧资源。 + WebGPU 档只冻结 product/device-limit 合同,不误报未安装 renderer。 +- M11-04 已完成有界实时渲染 reference 指标:Blender 5.2 Eevee 256×256 fixture 固定相机、 + 黑色 rough material、World、单样本 TAA 和零 dither;schema 1 固定 SRGB8/STRAIGHT RGBA, + 输出 MAE、RMS、P95 channel、max channel、坏像素比例、前景 IoU 和 alpha 覆盖率。主线程与 + Offscreen 生产 viewport 使用同一 reference 和阈值,错误构图稳定返回 + `RENDER_REFERENCE_MISMATCH`,不把“非空像素”当作通过。 +- M11-05 已完成最终渲染 fail-closed 路由:只有 bounded Eevee WebGL2/通过双门的 WebGPU 可走 + `WEB_LOCAL_BOUNDED`;Cycles、complex Eevee、Workbench 和 CUDA/OptiX/HIP/Metal/oneAPI + 固定走 `SERVER_JOB`。默认 endpoint 缺失返回 `SERVER_JOB_UNAVAILABLE`,不伪造本地等价。 +- M11-06 已完成 server render job provenance:schema 1 请求绑定 source `.blend` bytes/hash/ + revision、Blender 5.2 build hash、严格白名单 settings hash 和 request hash;成功结果绑定同一 + identity、output MIME/bytes/hash 和 result hash。loopback server 用真实 Blender 5.2 headless + 执行 fixture still render,source/build/settings/output 四类篡改均 fail-closed。 +- M11-07 已完成有限 Compositor WebGPU allowlist golden:真实 Blender 5.2 Main fixture 覆盖 + Constant Color、Exposure、Invert、Composite,生产 CPU 与 Chromium WebGPU compute 的 + `LINEAR_SRGB` Float32 输出逐字节 hash 相同;资源输入、分支和其他节点继续阻断。 +- M11-08 已完成 Unsupported Compositor 全图执行门:CPU 与 cached 入口在求值/cache hit 前 + 阻断任意 Unsupported 节点,包括未连接节点;真实 Main graph、Blender type metadata 和 + revision 保持不变。 +- M11-09 已完成 Sequencer runtime codec probe:IMAGE/SOUND/MOVIE receipt 绑定 family、MIME、 + byte length 和 source hash,Chromium 分别实际解码 PNG/WAV/H.264 MP4;请求不接受扩展名, + 损坏内容、identity drift 和伪造 receipt 均阻断。 +- M11-10 已完成 long-media proxy/cache identity:MOVIE source identity、M11-09 READY receipt、 + SRGB8/STRAIGHT RGBA8 profile 和 source frame 共同生成 SHA-256 cache identity;payload 独立 + 绑定长度/hash。Chromium 真实生成首帧 proxy,并通过一帧预算 LRU、Storage Worker 重开和 + source/capability/payload 漂移阻断。 +- M11-11 已完成 media revision gate:SEEK/SCRUB/DECODE 共享 timeline/request revision;旧 + request、timeline 换代期间完成的真实 decode 和伪造 result 均在 publish/cache callback 前 + 返回 `STALE/REVISION_CONFLICT`,只有最新且匹配当前 timeline 的结果可以发布。 +- M11-12 已完成最终编码 fail-closed 路由:请求绑定 Main timeline/source/settings identity; + server endpoint 缺失时稳定阻断,存在时只返回 `SERVER_EXPORT_REQUIRED`。实际或注入的 + `VideoEncoder` 都只记录探测事实,`localEncoding` 始终保持 `BLOCKED`。 +- M11-13 已完成实时音频恢复门:schema 1 区分 context/output/mute 状态;真实 Chromium + `AudioContext` 通过 suspend/resume、muted resume、unmute gain 恢复和 close。缺 API/ + 构造失败与 resume failure 均保持结构化静音阻断。 + 本轮新增并已验证 - M6-09A-C:binary archive 解包到 mktemp 后由包内 deployment contract 启动;新浏览器 context 阻断所有非 loopback 请求,manifest、WASM、CSS、3 个 Worker、字体和基础 UI 冷启动通过。 @@ -116,11 +194,123 @@ Web Blender M6/M7 接续执行记录 - M7-07:undo/redo 成功后序列化当前 Main 并与最近成功 save 的 blend SHA-256 比较;逻辑 revision 始终单调增加,内容 hash 相同即 clean、不同即 dirty。save -> edit -> undo clean -> redo dirty -> undo clean 的 Chromium 路径通过。联合 unit 18/18、Chromium 10/10、build 通过。 +- M7-08:Engine/Storage Worker 的 `error`/`messageerror` 统一映射为可恢复 + `WORKER_TERMINATED` fault;pending request 只结算一次,客户端支持重启。App 在 fault 后保留 + 当前 snapshot、项目 ID、dirty/committed identity 和项目列表,显示显式“重启并恢复”入口;恢复 + 先重建两个 Worker,再从当前项目 OPFS commit 与 operation log 重建 SceneIR。Engine/Storage + crash 注入和 retry 均不清空当前场景,恢复后 operation log 变更仍在。unit 19/19、Chromium + 12/12、typecheck、production build 全部通过。 +- M7-09:新增 recent projects schema v1 纯合同,记录只接受可验证的 project ID、revision、 + bytes、SHA-256、canonical timestamp 和 backend;排序/去重在输入顺序变化时仍确定,最多保留 + 50 项。索引存入 IndexedDB `setting`,同 Worker 队列与 `navigator.locks` 共同避免并发丢更新; + Worker 重启、页面刷新和新 StorageClient 均可恢复列表。打开但未提交的文件不进入索引;恢复 + 成功前不切换 project identity。损坏索引被清洗隔离时保留当前 recent 条目和 quarantine 计数, + OPFS buffer transfer 前冻结 bytes,索引写回完成后才报告恢复成功。Worker 重启后 selection、 + frame、workspace 和逻辑 project revision 均保持;联合 unit 22/22、Chromium 14/14、typecheck + 和 production build 全部通过。 +- M7-10:recent project 启动列举和选择恢复前核对 IndexedDB project metadata 与 OPFS commit 的 + revision、bytes、SHA-256;`MISSING`、`HASH_MISMATCH`、`METADATA_MISMATCH` 失效项只从可打开 + 列表隔离并保留在索引中,`unknown` backend 记录兼容存储不可用场景。UI 显示稳定的缺失/校验失败/ + 元数据不一致提示,并按 projectId 提供只移除失效引用的操作;移除不会删除其他项目或当前场景。 + 完成 missing、hash mismatch、修复和当前项目保留的 unit/Chromium 正负例,完整用户动作回归、 + typecheck、production build 全部通过。 +- M7-11:新增 `StorageBudgetBreakdown` v1 和 Storage Worker `getBudget`,按当前 project 精确汇总 + committed blend、snapshots、LOD manifest/cache、普通媒体 asset 及 Simulation/NanoVDB VDB + payload,计算稳定 total;界面新增五项存储预算面板和分项 bytes data attributes,保存、刷新、 + Worker 重启后重新读取同一结果。unit/Chromium budget 正例、完整用户动作回归、typecheck 和 + production build 全部通过。 +- M7-12:新增 project-scoped orphan content asset cleanup,扫描 `assets/sha256/` 只移除 + 没有该 project IndexedDB asset metadata 引用的 OPFS 文件,保留已引用文件和其他 project 的 + 同名/孤儿文件;预算面板提供“清理孤儿资源”入口并报告 removed count/bytes。跨项目正例验证 + A 项清理不影响 B 项,完整用户动作回归、typecheck、production build 全部通过。 +- M7-13:统一键盘焦点、菜单和 modal 上下文:菜单与 Operator Search 互斥,打开 overlay 后焦点 + 进入首个可操作控件,Escape 关闭并恢复触发器焦点;菜单支持 Arrow/Home/End、Tab 关闭,点击 + 外部关闭;全局快捷键不再劫持交互控件的 Tab/输入,打开文件期间 Escape 复用真实取消路径。 + 追加 focus-visible 样式和稳定 ARIA role;ui-schema unit 5/5、Chromium ui-context 1/1、 + 大文件 Escape 取消、完整用户动作回归 unit 26/26 + Chromium 19/19、typecheck、lint、 + production build 全部通过。 +- M7-14:新增响应式布局验收,覆盖 1440x900、1280x720、360x640 和 320x568;检查各布局带的 + 垂直边界、可见控件文本宽度和页面根 scrollWidth,移动端 topbar/workspace/storage/status + 横向内容使用局部滚动而不扩大页面或产生重叠。`test:user-actions` 已纳入该 spec,4/4 + Chromium 尺寸通过,完整用户动作回归 26/26 unit + 23/23 Chromium、typecheck、lint、build + 全部通过。 +- M7-15:新增共享 `viewport-camera` orbit 合同,统一主线程与 Offscreen 的 yaw/pitch/distance、 + target、旋转/缩放灵敏度、距离边界和场景相机投影;主线程 OrbitControls 关闭阻尼/平移并按 + 共享灵敏度配置,Offscreen frame 回传相机状态,两个 backend 的选择回调和 gizmo 投影沿同一 + SceneIR/相机合同运行。unit 1/1、双 backend camera/selection/orbit Chromium 3/3、既有 + Curve/Gizmo 2/2、完整用户动作回归 27/27 unit + 26/26 Chromium、typecheck、lint、build + 全部通过。 +- M7-16:P0 用户闭环增加无鼠标键盘路径和 Axe WCAG A/AA 门,关键流程、焦点可见性和 + 空项目/恢复项目均通过;完整用户动作回归 27/27 unit + 27/27 Chromium 通过。 +- M7-17:用户可见故障统一为稳定短消息,原始 Worker/文件异常只进入 200 条有界诊断 ledger + 和可导出 schema v1 报告;完整用户动作回归 30/30 unit + 29/29 Chromium 通过。 +- M7-18:30 分钟正式 editing soak 完成 352 个自动保存周期和 30 次整页重开,revision 从 1 + 单调推进到 705;首次正式运行发现 IndexedDB 快照写删导致 33,761,087 bytes 物理增长,改为 + OPFS payload + IndexedDB metadata 并保留旧内联快照兼容后,正式报告 `FORMAL/READY`,堆增长 + -310,276 bytes、存储增长 1,044,121 bytes、页面错误 0、快照保持 5 个。 +- M8-01:新增 `NanoVDBPageFeedback` v1 GPU feedback buffer 合同,固定 4-word header、默认 + 1,024/最大 8,192 个 page ID 容量、u32 原子 count/overflow、`NANOVDB_PAGE_FEEDBACK_OVERFLOW` + 稳定错误码和 `0xffffffff` 空页哨兵;创建、解析、overflow 一致性、页 ID 范围、schema/容量/长度 + 漂移拒绝以及 reset 清理均为确定性结果。M8-01 unit 4/4、typecheck、lint、production build + (64 modules)全部通过。 +- M8-02:实际 NanoVDB paged word/Float32 sampling shader 已接入 feedback storage binding;未驻留页 + 通过 `atomic` 槽、`atomicCompareExchangeWeak` 和同槽重试并发去重,物理扫描上界固定为 + `min(header capacity, arrayLength(page_ids))`,容量耗尽只设置 overflow 并递增尝试计数。真实 active + voxel 缺失 leaf page 的 256 个并发采样只记录 1 个 page ID;容量 2 下三种缺页稳定 overflow,两个 + guard word 不变。全量 unit 83/83、专项/既有 WebGPU 3/3、typecheck、lint、build 全部通过。 +- M8-03:新增 CPU feedback readback 和 schema v1 revision-bound batch;只复制声明容量对应的 GPU + buffer 前缀,解析后按数值升序排序并再次去重,保留 attempted/gpuStored/unique 三项计数、overflow + 状态和错误码,并拒绝负数/小数 render revision。真实 WebGPU leaf/overflow 两批分别绑定 revision + 17/18,page IDs 均升序且 guard 不变;全量 unit 84/84、专项 Chromium 1/1、typecheck、lint、build + 全部通过。 +- M8-04:新增唯一 feedback dispatch revision gate;batch revision 与当前 render revision 不同 + 时在页请求适配器前返回 `STALE/REVISION_CONFLICT`,请求列表和计数均为零;相同 revision 才按 + 已排序 page ID 调用适配器。真实 WebGPU revision 17 readback 在 frame 推进到 18 后触发零次页 I/O, + 切回匹配 revision 的正例只调用一次;全量 unit 85/85、专项及既有 VDB Chromium 3/3、typecheck、 + lint、build 全部通过。 +- M8-05:新增 `loadNanoVDBFeedbackPages`,只组合 revision gate 与现有 + `loadNanoVDBGridPage`;page ID 使用 manifest 的 `gpu.pageByteLength`,底层仍请求 manifest 原始 + chunk `index/start/end/hash` 并调用 `verifyNanoVDBChunk`,未新增 offset/hash 表。真实 GPU 生成 + manifest 页 0 feedback 后,stale 路径 range I/O 为 0,匹配路径加载页与原 density payload + 逐字节一致;全量 unit 85/85、专项及既有 fault Chromium 2/2、typecheck、lint、build 全部通过。 +- M8-06:新增 manifest/grid/source-scoped `NanoVDBGridPageRequestCoordinator`;同页并发订阅共享 + 一个 loader 与内部 AbortController,成功数据按订阅者复制独立 ArrayBuffer。取消一个订阅者只 + 结算自身,最后订阅者取消才从 pending 表移除并 abort 底层 range;dispose 同样有界清理。真实 + Chromium 两组并发门均只调用一次 source,单取消底层 abort=0、最后取消 abort=1,所有 pending/ + subscriber 最终归零;全量 unit 85/85、专项/fault 2/2、typecheck、lint、build 全部通过。 +- M8-07:复用 M8-05 已有的 manifest chunk SHA-256 验证先于 coordinator resolve/consumer 的 + 顺序;真实 chunk 保持声明长度但翻转 1 byte 后稳定返回 `NANOVDB_HASH_MISMATCH`,GPU + `uploadPage` consumer 调用为 0,resident page/bytes/virtual IDs 与 page table 均保持空,pending + 也归零。专项/fault Chromium 2/2、全量 unit 85/85、typecheck、lint、build 全部通过。 +- M8-08:`NanoVDBWebGPUGrid` 新增显式 `beginFrame/pinPage/endFrame`;paged LRU 只从非 pin + resident page 选择最旧项,全槽被当前 frame pin 时稳定返回预算错误且不改 resident/page table, + 下一 frame 自动清除旧 pin。真实 GPU 容量 2/虚拟页 4 门验证 pin 页 0 后先淘汰页 1、全 pin + 阻断、下一 frame 淘汰页 0,最终 page table `[unmapped,unmapped,slot1,slot0]`;专项/fault/OOM + Chromium 3/3、全量 unit 85/85、typecheck、lint、build 全部通过。 +- M8-09:共享 renderer 新增 `NanoVDBProgressiveRedrawScheduler` 和 + `NanoVDBProgressivePageUploader`;只有 `grid.uploadPage` 成功后才安排 frame,同一待执行 frame + 内多个成功页合并为一次 redraw,失败页不安排,回调执行后才允许下一轮。真实 WebGPU 验证 + 三个成功页分两批只产生 2 次 frame/redraw,越界失败页不增加计数;专项/fault/OOM Chromium + 3/3、全量 unit 85/85、typecheck、lint、build 全部通过。 +- M8-14:两个独立 Worker 验证 OPFS restart;旧 Worker 的 resident `[0]` 即使作为声明传入, + 新建 GPU grid 初始仍为空。新 Worker 只从 OPFS 恢复并校验 manifest,页 0 经声明长度和 + chunk SHA-256 复验后才写入 resident;翻转 OPFS chunk 首字节后稳定返回 + `NANOVDB_HASH_MISMATCH`,第二个新 grid 的 resident/page table 保持空。专项、既有 OPFS、 + VDB fault、87/87 unit、typecheck、lint 和 build 全部通过。 +- M8-15:新增 device-loss replay schema v1 和生产恢复入口;可见页先数值排序、去重并按新 grid + resident capacity 截断,新 page table 从全 unmapped 开始,页经 manifest chunk 复验后才上传。 + 真实 device loss 后 generation 增加 1,可见 `[2,1,0,2]` 只回放 `[0,1]`,旧非可见末页和 + 超限页均不驻留;page table 为 `[0,1,unmapped...]`。专项/分页/OPFS restart/OOM Chromium、 + 89/89 unit、typecheck、lint 和 build 全部通过。 +- M8-16:paged/direct grid dispose 与三资源组改为幂等;feedback 初始化写入失败会就地销毁已创建 + buffer。OOM 门保留 page-table 失败证据,并新增 feedback 初始化失败:resident/page table/ + feedback 峰值 3、三者 destroy 次数各 1、released bytes 131,112、live resources 0;同 device + 重新创建资源组、LRU 和重复 dispose 通过。专项/分页/device-loss/双视口 Chromium、89/89 unit、 + typecheck、lint 和 build 全部通过。 -待完成任务(严格顺序) -- M7-08:Worker 崩溃时展示可恢复错误,不清空当前项目列表。 -- M7-09 至 M7-18:Worker/recent projects 恢复、 - 存储预算与清理、输入/响应式/双 viewport/键盘可访问性、诊断报告和 30 分钟 soak。 +后续任务领取 +- 任务名称、顺序和完成状态以机器队列返回的最新 `nextTask` 为唯一依据。 +- 本文不手工维护待办任务名;领取后只追加已完成事实、验证证据和当前计数。 M6-15/M6-16 实际验证命令 - BLENDER_BIN= BLENDER_ARCHIVE_SHA256=96f6c181...1c48 @@ -198,11 +388,1086 @@ M7-07 实际验证 1e5c6ddeb29ca110b90a01f40a8ceca26e18d7787455dd13aeb8a0da76adb602 / 9b884c1e975b4ae4b55f6bc80ed191444790d75eccb09718863f24a54fd4aa18。 -M7-08 立即执行 -- WebEngine/Storage Worker error 统一映射为 recoverable worker fault,不在 error callback 中清空 snapshot、 - recent project 或 committed/dirty identity;所有 pending request 只结算一次。 -- 提供显式“重启并恢复”入口,先重建 Worker,再从当前项目 OPFS commit + operation log 恢复。 -- 添加 Worker 崩溃注入,验证可恢复短错误、项目列表/当前 UI 保留和恢复成功;完成后推进 M7-09。 +M7-08 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `node --test web/tests/unit/worker-fault.test.mjs`:fault contract 1/1 通过;完整 unit + 回归 19/19 通过。 +- `WEB_TEST_PORT=5230 npx playwright test --config playwright.config.ts + tests/e2e/worker-crash-recovery.spec.ts`:Engine/Storage crash recovery 2/2 通过。 +- `WEB_TEST_PORT=5233 npx playwright test --config playwright.config.ts --workers=1 + tests/e2e/user-action-state.spec.ts tests/e2e/project-action-mutex.spec.ts + tests/e2e/file-import-progress.spec.ts tests/e2e/save-interruption.spec.ts + tests/e2e/dirty-state.spec.ts tests/e2e/worker-crash-recovery.spec.ts`:既有 M7 与新用例 + 12/12 通过;并行首轮曾出现 M7-04 时序抖动,单 worker 重跑通过。 +- `npm --prefix web run build`:通过,60 modules transformed。 +- `git diff --check -- `:通过。 +- Worker fault/client/unit/e2e SHA-256: + `e95c5a64811abc237b601974ac6fd88bccd6ca51160f9eb4e0a2e1a563d46764` / + `0dae0d958398b3e1363a13a0694b1b4785bc49af09124e157b8df72b24ce455b` / + `839b3e52b69dd9a5fee7e1c604945c1cb0bde44bf9dea2221d13fc77ef417b68` / + `85853dc439e61618f7fb2f2ccfb1619f44909808f535784ddb7aeed5d4b102ba` / + `01bfe9221500e10d0abf75dac6149c174627a540c210eb54c041242de7f3805d`。 + +M7-09 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `WEB_TEST_PORT=5242 npm --prefix web run test:user-actions`:联合 unit 22/22、单 Worker + Chromium 14/14 通过;并行首轮仅复现已知 autosave 时序抖动,已把单 Worker 约束固化到脚本。 +- `npm --prefix web run build`:通过,61 modules transformed。 +- `git diff --check -- `:通过。 +- recent contract/unit/Chromium SHA-256: + `bc5cdbc52815f2f4540ff18e1f4f90ec731b1c2a9ccc81cd9a8132f9d9e8d1cc` / + `34df83e9a9b27aea6e65667427cda2c5a9216284abb2766b5fe43899df5c712e` / + `35c5d54cd13796b7d126313be625bc360a8d1e90c77c78e090a37e4854bcb595`。 +- App/StorageWorker SHA-256: + `7eddccde9402cb08e03da7412c98b9df868ea3a91ff9fb0524c6cf1bfc2a114a` / + `d730a87c4d73d0b844228d1d25a981a30ed2781250f2d125a3b9fb3a116a45f8`。 + +M7-10 立即执行 +- 已完成:启动列举和选择恢复均验证 IndexedDB metadata/OPFS commit 的 revision、bytes、SHA-256; + `unknown` backend 保留兼容语义,真实 `opfs/indexeddb` 缺失或损坏项返回稳定 issue code 并从 + 可打开列表隔离,当前场景和其他 recent 项目不变。 +- 已完成:修复提示显示稳定中文短消息和对应 projectId 的“移除引用”按钮;移除只更新 + `recent-projects:v1` 索引,不删除项目 metadata、OPFS commit、snapshot 或当前场景。 +- `npm --prefix web run typecheck`:通过。 +- `node --test web/tests/unit/recent-projects.test.mjs`:unit 4/4 通过。 +- `WEB_TEST_PORT=5253 npx --prefix web playwright test --config web/playwright.config.ts + tests/e2e/recent-projects-recovery.spec.ts -g "M7-10"`:Chromium 2/2 通过。 +- `WEB_TEST_PORT=5252 npx --prefix web playwright test --config web/playwright.config.ts + tests/e2e/recent-projects-recovery.spec.ts`:M7-09/M7-10 Chromium 4/4 通过。 +- `WEB_TEST_PORT=5254 npm --prefix web run test:user-actions`:联合 unit 23/23、单 Worker + Chromium 16/16 通过。 +- `npm --prefix web run build`:通过,61 modules transformed。 +- `git diff --check -- web/protocol/recent-projects.ts web/protocol/storage.ts + web/app/src/workers/storage.worker.ts web/app/src/app/App.tsx web/app/src/app/app-shell.css + web/tests/unit/recent-projects.test.mjs web/tests/e2e/recent-projects-recovery.spec.ts`:通过。 +- M7-10 文件 SHA-256: + `6a5fb9b080c648e0d1673afd4363db705355d57bd685ac718a875ef1d18e0e29` / + `8ed45a45abcf21afda9d8871219f238f54dfeceba3414b92cd9a1d5205516489` / + `6e3e5584114523cc49bb970f360c832f4acd3ab34994ab102730f344f288e108` / + `375fa0a992b1d7f53641b18e4ed48e7aabe56c70b4c875b0acdc0f58ad0714ce` / + `b04947d4825a8338854dccd012cef16d7e7d058955a9b5cb3f1b0f58a5b08b0e` / + `6ac5ec70528ea95b06f729f4b11e0a225d37af22467eba2b7c46c6fd8ff6074d` / + `4234833a8b6cfba5606dfde24772ff688822334abbaf558945a912947bf954e2`。 + +M7-11 实际验证 +- `node --test web/tests/unit/storage-budget.test.mjs`:unit 2/2 通过。 +- `WEB_TEST_PORT=5255 npx --prefix web playwright test --config web/playwright.config.ts + tests/e2e/storage-budget.spec.ts`:Chromium 1/1 通过;真实项目保存、snapshot、LOD、媒体 + asset、simulation/VDB asset 的五项 bytes 与 reload 后面板一致。 +- `WEB_TEST_PORT=5261 npm --prefix web run test:user-actions`:联合 unit 25/25、单 Worker + Chromium 18/18 通过;其中 M7-04 首轮竞态已由可取消 native 阶段事件窗口修正并通过。 +- `npm --prefix web run build`:通过,62 modules transformed。 +- `git diff --check -- web/protocol/storage-budget.ts web/protocol/storage.ts + web/app/src/storage/opfs-files.ts web/app/src/storage/StorageClient.ts + web/app/src/workers/storage.worker.ts web/app/src/app/App.tsx web/app/src/app/app-shell.css + web/tests/unit/storage-budget.test.mjs web/tests/e2e/storage-budget.spec.ts web/package.json`:通过。 +- M7-11 文件 SHA-256: + `151ed8fc658322351c73c48c0d524c154789bba2e8dd7dce4000fe6fcd488efb` / + `225cbcde7696d2481a2db6ba8700fe55c011898478dac648f28d95a58aed79e7` / + `2eeeb11c543c3fdf0bdbadcfee4ff96893946679d1227b9314110246e4def5ee` / + `7be78fa594e218471af165e95ff0a4640d4879a840f1fe2a891c64d4f37d8de9` / + `a35082c8443089c92400ed0ee6e36e30638bfe657f3cd2126f0cfd29a8717167` / + `1b6feb627a82b8fb5e513bc7d0dd34883e1f3d7d9a6e5c9a03e365315c723392` / + `c754d5f384fdf5081b556c1ba87ddc3e4bf5e7e52fb7342f55119b1ef4993f51` / + `339b57f9453ea766e6e34144742c91217d45691192e20ee106adaf5d55d6c162` / + `d05cdb7b7d7afd13d42330885604a310005eb2c544806d598911e081ec105ebf`。 + +M7-12 实际验证 +- `WEB_TEST_PORT=5260 npx --prefix web playwright test --config web/playwright.config.ts + tests/e2e/storage-cleanup.spec.ts`:Chromium 1/1 通过;A 项 removed=1、bytes=19,A 已引用 + asset 保留,B 项 orphan 保留。 +- cleanup API 通过 project transaction 扫描 `assets/sha256/`,只删除没有该 project + asset metadata 引用的文件,并返回 paths/count/bytes;预算面板“清理孤儿资源”入口报告结果。 +- M7-12 与 M7-11 联合回归、typecheck、production build 全部通过;StorageClient/Worker、OPFS + cleanup 和跨项目 spec 已包含在上方 SHA-256 与 `test:user-actions` 证据中。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M7-13 实际验证 +- `npm --prefix web run typecheck`:通过。 +- `npm --prefix web run lint`:通过;同时修复 storage Worker catch 链的 preserve-caught-error + 诊断,保留 rollback 原始 cause。 +- `node --test web/tests/unit/ui-schema.test.mjs`:5/5 断言通过(菜单切换、overlay 互斥、关闭)。 +- `WEB_TEST_PORT=5271 npx --prefix web playwright test --config web/playwright.config.ts + web/tests/e2e/ui-context.spec.ts`:Chromium 1/1 通过;覆盖普通 Tab、modal Tab 环、菜单 + Arrow/Home/End/Tab、Escape 和触发器焦点恢复。 +- `WEB_TEST_PORT=5274 npx --prefix web playwright test --config web/playwright.config.ts + web/tests/e2e/file-import-progress.spec.ts -g "cancels a large streamed open"`:真实 40 MiB + 输入在中途同步派发 Escape 后进入 `CANCELLED/OPEN_CANCELLED`,旧 scene/project 保留。 +- `WEB_TEST_PORT=5275 npm --prefix web run test:user-actions`:unit 26/26、Chromium 19/19 + 通过(单 worker);期间 Worker crash 注入日志为预期测试输出。 +- `npm --prefix web run build`:通过,62 modules transformed。 +- `git diff --check -- web/protocol/ui-schema.ts web/app/src/app/App.tsx web/app/src/app/app-shell.css + web/tests/unit/ui-schema.test.mjs web/tests/e2e/ui-context.spec.ts web/tests/e2e/file-import-progress.spec.ts + web/app/src/workers/storage.worker.ts`:通过。 +- M7-13 文件 SHA-256:`8aa564cf8141ee42cb1f85a1e42ffaf2f4ae19e6f269c98a4b2e8e3362e224ad` / + `3a246c3ae6efcac4fa2ce11aa6f8f6af453a48482963754bd43eb4d7d5894e6b` / + `4933febb29004ae75f4a04e38f0a37ef1624cd412ccae89c727c8d7f7527f62e` / + `ce39f1d44db40f721869b2509a957277d775435b69b6ff8729df8723d9babc90` / + `0adc81a3f6d88565085c426fe220cfc7c826814d6b1c54de9c74292f5f100433` / + `4463430b07572d130b5b531fe3e46e443a282204bb91550d91ad5e0508c15860` / + `012cbb97df5a9e983e38721d8c167e7abb5beca9cbd98690343f0872c1ce64e6`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M7-14 实际验证 +- `WEB_TEST_PORT=5281 npx --prefix web playwright test --config web/playwright.config.ts + tests/e2e/responsive-layout.spec.ts`:Chromium 4/4 通过(1440x900、1280x720、360x640、 + 320x568);各带垂直边界无越界,文本无未处理 overflow,document 根宽度不超过 viewport。 +- 移动端 status bar 改为局部 `overflow-x: auto`,保留 Engine/Storage 完整诊断文本,不让页面 + 根节点横向扩张;topbar、workspace toolbar 和 storage budget 继续使用已有局部滚动约束。 +- `WEB_TEST_PORT=5282 npm --prefix web run test:user-actions`:unit 26/26、Chromium 23/23 + 通过(单 worker;含 M7-13 ui-context 和 M7-14 四尺寸回归)。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`:均通过, + production build 62 modules transformed。 +- `git diff --check -- web/app/src/app/app-shell.css web/tests/e2e/responsive-layout.spec.ts + web/package.json`:通过。 +- M7-14 文件 SHA-256:`e3919944825d88e6de367c6704ff120f0a8beb4dd10f68d21b226e728f07f5ff` / + `f5de57f570880f823224ebdcddb2be4d59ec414a32641effdaa68dc1f4102f28` / + `81480960eb36c7e22217e6b13c164a814de6552ab3935c6e369b47a35bc14a6d`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M7-15 实际验证 +- `node --test web/tests/unit/viewport-camera.test.mjs`:orbit 默认状态、反算、delta 和相机 + position 确定性通过(1/1)。 +- `WEB_TEST_PORT=5284 npx --prefix web playwright test --config web/playwright.config.ts + web/tests/e2e/viewport-consistency.spec.ts`:Chromium 3/3 通过;主线程/Offscreen 均核对 + 默认位置 `4.219781,-4.219781,3.658811`、target、yaw/pitch/distance、中心选择和滚轮距离, + 同一 zoom 输入的两 backend 状态完全一致。 +- `WEB_TEST_PORT=5287 npx --prefix web playwright test --config web/playwright.config.ts + web/tests/e2e/smoke.spec.ts -g "previews an N-015 Curve handle drag"`:主线程/Offscreen 2/2 + 通过;`WEB_TEST_PORT=5286 ... -g "previews N-016 Grease Pencil points"`:2/2 通过。 +- `WEB_TEST_PORT=5288 npm --prefix web run test:user-actions`:unit 27/27、Chromium 26/26 + 通过(单 worker;含 M7-13、M7-14、M7-15 新增回归)。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`:均通过, + production build 63 modules transformed。 +- `git diff --check -- web/protocol/viewport-camera.ts web/app/src/three-adapter/viewport.ts + web/app/src/three-adapter/offscreen-viewport.ts web/app/src/three-adapter/offscreen-viewport-protocol.ts + web/app/src/workers/viewport-render.worker.ts web/tests/unit/viewport-camera.test.mjs + web/tests/e2e/viewport-consistency.spec.ts web/tests/e2e/smoke.spec.ts web/package.json`:通过。 +- M7-15 文件 SHA-256:`b6369fc1145acd14f6099b7a014f39c0611799102ef299296d9cdd3e29d16ae6` / + `dec5f6ea8372360c290f9e288edf2cc0ea20e7eb1b1514ad98bf7ca7ed1b93af` / + `be910d83a4c3e1042aee708d99eaf04ed0e28e7c859786d4ad23acde7c7c66ad` / + `06bc9576081633ad42e5b3a68ec85086aa68afee0359b0c9981c98775b683c55` / + `bfadb23ceb943bcdb3aba4c525e19f4f99099b5a976d538003f99a75d0a31f42` / + `7c811a9b31a4196d53d80c25b26015a904aa5fe9c19c0b12eff2acce95fd77ae` / + `e591e8217b0ed3ea4399918b1ec56ca7160a22bf41f15e67b09c803426e50f4f`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M7-16 实际验证 +- 新增无指针 P0 Chromium 路径:全部用户操作只通过 Tab/Shift+Tab、Enter、F3、方向键和 + Ctrl+S 完成打开 `.blend`、Add Cube、保存下载、关闭、刷新后从最近项目重开和 GLB 导出; + FileChooser 只用于向键盘触发的原生文件选择器提供测试文件。 +- 空项目和恢复后的 4-object 项目各运行一次 Axe WCAG 2/2.1/2.2 A/AA 门,`critical` / `serious` + 违规均为 0;首轮发现并修正 timeline 刻度对比度、Outliner `treeitem` 父角色和 22px 显隐按钮。 +- Outliner 现使用 `tree -> treeitem/group` 语义;搜索框、Operator Search 和隐藏文件控件均有 + 可见键盘焦点轮廓,关键路径逐点断言焦点可见。 +- `WEB_TEST_PORT=5292 npx playwright test --config playwright.config.ts + tests/e2e/keyboard-accessibility.spec.ts`:1/1 通过。 +- `WEB_TEST_PORT=5293 npm --prefix web run test:user-actions`:unit 27/27、单 Worker + Chromium 27/27 通过;Worker crash 注入日志为预期测试输出。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`: + 均通过,production build 63 modules transformed。 +- `git diff --check -- web/app/src/app/App.tsx web/app/src/app/app-shell.css + web/tests/e2e/keyboard-accessibility.spec.ts web/package.json web/package-lock.json`:通过。 +- M7-16 文件 SHA-256:`e74a53a1f9e9bfea73be8da1de486938d0dcc4718e7585b57bee27a5ce78a978` / + `643831089f0ea60eaf36b725bfa18c2dedb83b06441002ed6626cc5b9359d8a0` / + `21758543408469e4fee9ecc1fdc1f5d9fa2922bc981bacb9bb947c41adb02315` / + `6788477e4336cce78ec6c04f3fc8d93c88a995e20f7cf3b73641317a329b8b56` / + `61f6d13e0148321d3c625a5baa212b5444296d111193c4d62a692685faebff1f`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M7-17 实际验证 +- 新增 schema v1 应用诊断合同和 200 条有界 ledger;每条记录包含稳定 area/code/summary、 + canonical timestamp、sequence、项目上下文以及只在报告中出现的 detail/sourceCode/stack/cause。 +- App 的可见 action、engine、storage、viewport、manifest、PBR、open/save/recovery/autosave/GLB + 失败统一显示固定短消息;源码门禁止 status setter 插入 `error.message` 或 Worker 原始消息。 +- 顶栏新增“导出诊断报告”,JSON 绑定无查询参数页面 URL、userAgent、language、 + crossOriginIsolated、当前 projectId/revision 和按 sequence 排序的诊断条目。 +- 真实 Engine Worker crash 正例确认页面不出现 `WORKER_CRASH_INJECTED`,导出 JSON 保留该细节和 + `WORKER_TERMINATED`;无效 blend/image 正例确认固定短消息与报告 detail 同时成立。 +- 首轮浏览器门发现 React 开发模式重挂载会把已淘汰 StorageClient 的终止误记为 recent-project + 故障;catch 现复核当前 client identity,报告不再混入旧 Worker 噪声。 +- `node --test web/tests/unit/diagnostic-report.test.mjs`:3/3 通过。 +- `WEB_TEST_PORT=5299 npx playwright test --config playwright.config.ts + tests/e2e/diagnostic-report.spec.ts`:2/2 通过;M7-16 accessibility 1/1、M7-08 Worker recovery + 2/2 定向回归通过。 +- `WEB_TEST_PORT=5300 npm --prefix web run test:user-actions`:unit 30/30、单 Worker + Chromium 29/29 通过;Worker crash 注入日志为预期测试输出。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`: + 均通过,production build 64 modules transformed。 +- `git diff --check -- web/protocol/diagnostic-report.ts web/tests/unit/diagnostic-report.test.mjs + web/tests/e2e/diagnostic-report.spec.ts web/app/src/app/App.tsx web/app/src/app/app-shell.css + web/package.json web/package-lock.json`:通过。 +- M7-17 文件 SHA-256:`e18b6e8c375cb0842d7fb521462fe7af5b63257affeea4a34d02dec74afd660f` / + `571f3c1d9df6ed905ed60da00efbdcc311c27147bb183db0d3e9d2e47293f28f` / + `245fc99bb18986a4e09fc2a66f56f5602dc2519f32a7ec8931741439c03aa5bf` / + `413fc12baeb247377825c53235d1198b5f628e23a8fb35c4ca68ebf34f65378a` / + `c183861009cdfcd3ac4b0c5a632bc3af2256c68b4ff22cc6ffbc10f16947f8b4` / + `61f6d13e0148321d3c625a5baa212b5444296d111193c4d62a692685faebff1f`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M7-18 实际验证 +- 新增正式/调试 soak 命令和 schema v1 报告校验器;正式门固定要求 1,800,000 ms、超过 100 个 + edit/autosave 周期、至少 20 次整页重开、revision/hash/dirty 一致、单次显式下载、快照上限、 + 页面错误为空以及 64 MiB heap / 16 MiB origin storage 增长上限。 +- 首次 `WEB_TEST_PORT=5318 npm --prefix web run test:editing-soak` 实跑 1,801,443 ms,350 个周期、 + 30 次重开、final revision 701;除存储外均通过,但 origin 增长 33,761,087 bytes,报告稳定为 + `FAILED`,未作为完成证据。根因是完整 `.blend` 快照在 IndexedDB 中反复写入并按 5 个保留数删除, + 逻辑字节有界但 Chromium backing store 产生线性写放大。 +- OPFS 可用时快照 payload 现写入 `projects//snapshots/.blend`,写后校验并按 + retention 删除;IndexedDB 只保留 revision/bytes/SHA-256/backend/path 元数据。旧的 IndexedDB + inline `buffer` 行仍可读取,StorageClient 协议、预算计数和恢复语义不变。 +- `WEB_TEST_PORT=5319 npx playwright test --config playwright.config.ts tests/e2e/smoke.spec.ts + tests/e2e/editing-soak.spec.ts -g "retains bounded snapshots|high-frequency retained snapshots"`: + 2/2 通过;128 次 256 KiB 快照压力保持最后 5 个 OPFS 文件且 origin 增长不超过 4 MiB。 +- `WEB_TEST_PORT=5321 npm --prefix web run test:editing-soak`:2/2 通过;正式长跑 1,804,650 ms, + cycles/autosaves 352/352、reopens 30、revision 1 -> 705、final bytes 201,631、snapshot 5、 + download 1、pageErrors 0、heap growth -310,276、storage growth 1,044,121;随后机器报告校验通过。 +- `WEB_TEST_PORT=5322 npm --prefix web run test:user-actions`:unit 30/30、单 Worker Chromium + 29/29 通过;`WEB_TEST_PORT=5323 npm --prefix web run test:v1-user-loop`:1/1 通过。 +- `WEB_TEST_PORT=5324 npx playwright test --config playwright.config.ts tests/e2e/smoke.spec.ts + -g "retains bounded snapshots|saves the opened blend|autosaves a dirty project|recovers verified atomic saves"`: + 4/4 通过;`typecheck`、`lint`、production build(64 modules)和 `git diff --check` 均通过。 +- OPFS/StorageWorker/soak spec/checker/report SHA-256: + `74c63f14bef3ddbf60d1106519b3c9cdf370702cb1427e13e59670175d16b840` / + `1cdfaf3f07a35276e5d169cafc6b153736c4e9ade20876e2a69bff81e92649dc` / + `cb6d49f4be14f65482ca376aaf02b8220b5f26ad8435c83e2966f85c48c3d447` / + `8ce82a135a5e857fabf0da87ea1b1609678d9e1738b5bccfd494622808893c41` / + `dde16899f21d22dc9b3d0cc41e113b0b8d56a05fd388391ee301bbdcfdc67e4c`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-01 实际验证 +- `npm --prefix web run test:nanovdb-page-feedback`:unit 4/4 通过。 +- `npm --prefix web run typecheck`:通过。 +- `npm --prefix web run lint`:通过。 +- `npm --prefix web run build`:通过,production build 64 modules transformed。 +- `git diff --check -- web/protocol/nanovdb-page-feedback.ts + web/tests/unit/nanovdb-page-feedback.test.mjs web/package.json web/protocol/error.ts`:通过。 +- `nanovdb-page-feedback.ts` SHA-256:`43027ff4114b895cd7730529023665d85024308641cc272c35f26ca496db6378`。 +- unit spec SHA-256:`9a7358902117eabae81a1438b7ab6fea1ca8bd6fe05348a8d89614cc78829cd6`。 +- `package.json` / `error.ts` SHA-256: + `aba2cfc12a97fb515122927f1a8de879ab0a50539d26d0d3bc42957bb69d9a4b` / + `8d6d1a3f49c5709f63b57ff01094002510665ddfff2a5a4a0f9df009d70aff42`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-02 实际验证 +- `npm --prefix web run test:nanovdb-page-feedback`:合同 unit 5/5 通过。 +- `WEB_TEST_PORT=5332 npm --prefix web run test:nanovdb-page-feedback-webgpu`:真实 WebGPU 1/1 + 通过;256 个并发 active-voxel 采样只记录一个未驻留 leaf page,overflow guard 未被改写。 +- `npm --prefix web test`:全量 unit 83/83 通过。 +- `WEB_TEST_PORT=5333 npm --prefix web run test:vdb-faults`:既有 network/Worker/device-loss/ + demand-paging 1/1 通过。 +- `WEB_TEST_PORT=5334 npm --prefix web run test:vdb-webgpu`:真实 Float32 native/CPU/GPU 数值与 + 确定性像素 1/1 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`:均通过, + production build 65 modules transformed。 +- 首轮 typecheck 发现仓库 WebGPU 声明不暴露 `GPUBuffer.size`,已移除该非合同属性读取并由 WGSL + `arrayLength` 执行物理边界;首轮浏览器断言错误假定缺页 sample 必须 invalid,已按既有零/非 active + fallback 语义修正,缺页事实只由 feedback 证明,随后完整重跑通过。 +- `git diff --check -- `:通过。 +- protocol/renderer/sampler/Worker SHA-256: + `d7c3f443d046f0c203c9041e44317830f6cb09f3738e5d34c188191bc044c4f2` / + `307a1693d5ffd87662cbf1cccfec8630d999ca2b9d6c845db16975b5ee9224fc` / + `141a32fc9866485f48b64c85fa7c905b367e48ec2e9e6869f90fbbf0f491864a` / + `b98e740225c752a129aaa3fa0203295150fadf5d043da4d9cef220532f15b8d3`。 +- unit/e2e/package SHA-256: + `0d76b6ffd0f6e994c6ba97498a44c0c8dc5d41db0e65ba5b9f80ed740fccbe48` / + `a740df22a86945df601fe87d0867bb5c2589d6bbfa7ff8ccfc00416f9f7a0d63` / + `b96f8434d152643411eb3b0ad70a8c25688b49ff051484a76828659efd5d0ea4`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-03 实际验证 +- `npm --prefix web run test:nanovdb-page-feedback`:合同 unit 6/6 通过;重复 GPU 槽输入规范化为 + `[2, 5, 7]`,并绑定指定 render revision,非法 revision 稳定拒绝。 +- `WEB_TEST_PORT=5336 npm --prefix web run test:nanovdb-page-feedback-webgpu`:真实 WebGPU 1/1 + 通过;leaf/overflow readback 分别绑定 revision 17/18,输出排序、去重和 guard 均通过。 +- `npm --prefix web test`:全量 unit 84/84 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`:均通过, + production build 65 modules transformed。 +- `git diff --check -- `:通过。 +- protocol/renderer/Worker SHA-256: + `358e70fc6aa3a1c36c191e04f8588c9829d54f4e6a2a4b00d54da2c54165bcd0` / + `653680a018d088d18cf978e061ddf649d2d7048de24c79d607cbc8ea3e363d26` / + `c81d19d20af0fab00111eb9f578fdf26cb757743a28bc4404aa53cfa0c2854bc`。 +- unit/e2e SHA-256: + `35aecc597acf4d40dde231f80b9aaacb8afe4e05be13b0d3373ae7837d6d0ef9` / + `b83422a61decee352e45779acd31e7decaede51cdb8b9ae1c99c40b796252eb9`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-04 实际验证 +- 新增 `dispatchNanoVDBPageFeedbackBatch` 作为 page request 的唯一 revision gate;旧/未来 frame + 都返回 `STALE/REVISION_CONFLICT`、`requestedCount=0` 和空 page ID 列表,且不调用 I/O 适配器; + 当前 frame 才按 CPU batch 的数值升序调用适配器。 +- `npm --prefix web run test:nanovdb-page-feedback`:合同 unit 7/7 通过;覆盖旧 revision、未来 + revision、匹配 revision 和非法当前 revision,两个 mismatch 正例合计 I/O 调用为 0。 +- `WEB_TEST_PORT=5325 npm --prefix web run test:nanovdb-page-feedback-webgpu`:真实 WebGPU 1/1 + 通过;revision 17 leaf readback 对当前 revision 18 返回 stale 且页 I/O 为 0,匹配正例只请求 + 唯一 leaf page 一次。 +- `WEB_TEST_PORT=5326 npm --prefix web run test:vdb-webgpu` 与 + `WEB_TEST_PORT=5327 npm --prefix web run test:vdb-faults`:既有 Float32 数值/像素及 + network/Worker/device-loss/demand-paging Chromium 回归各 1/1 通过。 +- `npm --prefix web test`:全量 unit 85/85 通过;`npm --prefix web run typecheck`、 + `npm --prefix web run lint`、`npm --prefix web run build` 均通过,production build + 65 modules transformed;M8-04 文件 `git diff --check` 通过。 +- protocol/unit/Worker/e2e SHA-256: + `0d0cd81175147a5a8dcc1eb272aeb13f44b5c694097a4c8926b12b4668fc918e` / + `d72db3d7b352a68327592306a4b5cea49bfa0513f52f006b8e8c3f6433554814` / + `baedb58bc84d9374701f2aecfa82da64c1caf95a51190757e1c6761c756ac638` / + `e622a913ef66186fc2fe0f095c7dc8c16671cbe1c001628732ca4be978afe9ce`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-05 实际验证 +- 新增 `loadNanoVDBFeedbackPages` 薄组合层:先执行 M8-04 revision gate,再复用既有 + `loadNanoVDBGridPage`;后者从已校验 manifest 计算页/chunk 交集,请求完整声明 chunk 并执行 + `verifyNanoVDBChunk`,没有第二份 page range 或 hash 地址模型。 +- 首轮 `WEB_TEST_PORT=5328` 失败为 `requested GPU page is outside the grid`:M8-02 合成 shader + 使用 256 KiB 页,而 fixture manifest 声明 4 MiB,证明两套 page ID 不可混用;统一 leaf 测试到 + 4 MiB 后 `WEB_TEST_PORT=5329` 又稳定暴露 root page 也缺失导致 sampler invalid。最终将 M8-02 + leaf-only/overflow 压力网格与 M8-05 manifest I/O 网格明确分离,两个失败均未作为完成证据。 +- `WEB_TEST_PORT=5337 npm --prefix web run test:nanovdb-page-feedback-webgpu`:真实 WebGPU 1/1 + 通过;manifest 4 MiB 页 0 feedback 绑定 revision 19,current=20 时 source 调用为 0,匹配时 + 请求 range 逐项等于 manifest chunk 的 `index/start/end/hash`,加载页与原 payload 逐字节一致。 +- `WEB_TEST_PORT=5338 npm --prefix web run test:vdb-faults`:既有 network/Worker/device-loss/ + demand-paging 1/1 通过;`npm --prefix web test` 全量 unit 85/85 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`: + 均通过,production build 65 modules transformed;M8-05 文件 `git diff --check` 通过。 +- viewport loader/Worker/e2e/protocol SHA-256: + `46847ee46ecce4483dc378e6c1f4daa474994d38cc0b30bbde9cf43fd4a78c91` / + `c342976575e45d14a798d4865fde673317d2054592779c87501db7f3e2037a97` / + `5ceaacb2c97d9e4dc3db86c6ecae7a0e3bf8620c5454d61f2cb478dd6b29222e` / + `0d0cd81175147a5a8dcc1eb272aeb13f44b5c694097a4c8926b12b4668fc918e`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-06 实际验证 +- 新增 `NanoVDBGridPageRequestCoordinator`,以绑定的 manifest/grid/source 和 page ID 为请求域; + 首个订阅者创建唯一 loader/内部 AbortController,后续同页订阅加入同一 pending entry。成功时 + 每个存活订阅者获得 `slice(0)` 独立 buffer,完成、失败、取消和 dispose 均移除 abort listener。 +- 真实并发正例让 source 在实际 HTTP range 前暂停:两个 `loadNanoVDBFeedbackPages` 同时请求页 0, + 观测 `pendingPages=1/subscribers=2/rangeCalls=1`;取消第一个后为 1/1 且底层 abort=0,释放 range + 后只有第二个 consumer 成功,完成统计归零。 +- 最后订阅者取消负例使用阻塞 range:两个调用者仍只产生一次 source;首次取消保持底层存活, + 第二次取消使底层 signal abort 恰好一次,两个调用者各自只结算一次 `AbortError`,pending 归零。 +- `WEB_TEST_PORT=5340 npm --prefix web run test:nanovdb-page-feedback-webgpu`:专项 Chromium + 1/1 通过;`WEB_TEST_PORT=5341 npm --prefix web run test:vdb-faults`:既有 fault/demand paging + 1/1 通过;`npm --prefix web test`:全量 unit 85/85 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`: + 均通过,production build 65 modules transformed;M8-06 文件 `git diff --check` 通过。 +- coordinator/Worker/e2e SHA-256: + `f7f2d02d7b946116b726e58051fafbb745f2eea110fa58409190c5af6e002a58` / + `ca459878899f7328406e8e3f571c432eba99fdc96a73cd58f929aee64ba458cf` / + `95b7fb8b59d45e7b5dd567675fbd7dc622c1baf1f9fba4fac5ccdcd0a2eac976`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-07 实际验证 +- 生产顺序保持 `source -> verifyNanoVDBChunk -> coordinator resolve -> feedback consumer`; + resident cache 写入只发生在 consumer,因此 hash 未通过时没有未验证 bytes 可进入 GPU page slot。 +- Chromium 负例从真实 HTTP source 读取声明 chunk 后翻转首字节,长度和 range 均保持不变; + `loadNanoVDBFeedbackPages` 稳定抛出 code/message `NANOVDB_HASH_MISMATCH`,rangeCalls=1、 + residentWrites=0、residentPageCount/residentBytes=0、residentVirtualPages=[],coordinator 统计归零。 +- `WEB_TEST_PORT=5342 npm --prefix web run test:nanovdb-page-feedback-webgpu`:专项 1/1 通过; + `WEB_TEST_PORT=5343 npm --prefix web run test:vdb-faults`:既有 fault/demand paging 1/1 通过; + `npm --prefix web test`:全量 unit 85/85 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`: + 均通过,production build 65 modules transformed;M8-07 文件 `git diff --check` 通过。 +- Worker/e2e/coordinator SHA-256: + `2e12112a8c855d578be70e869ea6cdc5575f410c11307ced9edd6f9bca1a03a6` / + `b84a2502d65a02e6d0d9c2c2adf919abdfc151dc1228492b230e13492bc25244` / + `f7f2d02d7b946116b726e58051fafbb745f2eea110fa58409190c5af6e002a58`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-08 实际验证 +- `NanoVDBWebGPUGrid` 增加 frame-scoped pin 合同;`beginFrame` 清理上一 frame pins, + `pinPage` 只接受当前 resident page,LRU replacement 过滤 pins,`endFrame` 和 dispose 清理集合; + 全槽 pin 时返回 `NANOVDB_GPU_BUDGET_EXCEEDED: all resident NanoVDB pages are pinned`。 +- 真实 GPU 容量 2/虚拟页 4 正例:页 0/1 resident 后 begin/pin(0),上传页 2 跳过更旧的页 0 并 + 淘汰页 1;随后 pin 0/2 时上传页 3 稳定失败,resident `[0,2]` 和 evictionCount=1 不变;下一 + frame 只 pin 2 后上传页 3,页 0 才被淘汰,最终 resident `[2,3]`、evictionCount=2。 +- 首轮 `WEB_TEST_PORT=5344` 仅 page-table readback 断言失败:原 buffer 没有 `COPY_SRC` usage, + 逻辑 resident 断言均已通过。direct/paged page table 增加诊断 readback usage 后, + `WEB_TEST_PORT=5345 npm --prefix web run test:nanovdb-page-feedback-webgpu` 1/1 通过,真实表为 + `[0xffffffff,0xffffffff,1,0]`。 +- `WEB_TEST_PORT=5346 npm --prefix web run test:vdb-faults`、`WEB_TEST_PORT=5347 npm --prefix web run + test:oom-recovery`:既有 demand-paging 与四域 OOM 各 1/1 通过;全量 unit 85/85 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`: + 均通过,production build 65 modules transformed;M8-08 文件 `git diff --check` 通过。 +- renderer/Worker/e2e SHA-256: + `8668e5ff81963c08d1064d32aebbd715d283892a71d2137514d8c87a2ae37747` / + `3b5fea1fd890057d63651a7ca5b78c3af612b800444cd78f19ace4698323976c` / + `3e4de849534edd6c6cb230dd62ae797c42be9295e8cf3163b1ec73b8a8e840b2`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-09 实际验证 +- `NanoVDBProgressivePageUploader.upload` 先调用真实 grid upload,成功后再交给共享 redraw + scheduler;pending frame 存在时后续成功 upload 返回 `redrawScheduled=false`,不会加入第二个 + frame。非法 page upload 先抛 `NANOVDB_STREAM_INCOMPLETE`,scheduler 计数保持不变。 +- 真实 WebGPU 容量 3/虚拟页 3:页 0/1 连续成功上传后只有 1 个 queued frame;执行回调后 + `scheduledCount/redrawCount=1/1`,页 2 再上传才安排第二个 frame,最终计数严格为 2/2。 +- `WEB_TEST_PORT=5350 npm --prefix web run test:nanovdb-page-feedback-webgpu`:M8-02 至 M8-09 + 专项 Chromium 1/1 通过;`WEB_TEST_PORT=5351 npm --prefix web run test:vdb-faults`、 + `WEB_TEST_PORT=5352 npm --prefix web run test:oom-recovery`:既有 demand-paging 与四域 OOM + 各 1/1 通过。 +- `npm --prefix web test`:全量 unit 85/85 通过;`npm --prefix web run typecheck`、 + `npm --prefix web run lint`、`npm --prefix web run build` 均通过,production build + 65 modules transformed;全工作树 `git diff --check` 通过。 +- renderer/Worker/e2e SHA-256: + `07225e5a7f07bc7af0e9bceeae77dd4f267ae6c56e08eb71120eec52f3e4acae` / + `8beed26a5bea315d412427b80c64e1071809104ae89aaea5e1d122919f3cd1ba` / + `61f26e7d86f741074c74496f256d15c4ec6804754e1f5d230cbb48d2bf15699f`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-10 实际验证 +- 新增纯 `nanovdb-progressive-redraw` 预算合同:默认每个 render epoch 最多 32 次重绘,调用方 + 可配置 1..1024 的整数上限;预算耗尽后稳定返回 `NANOVDB_PROGRESSIVE_REDRAW_LIMIT`,并保留 + `capped`/`errorCode` 可观测状态。`beginRender()` 清零当前 epoch 计数并使旧回调 generation + 失效,避免陈旧回调在新渲染周期重新触发循环;非法上限和计数返回 `NANOVDB_INVALID_ARGUMENT`。 +- 真实 WebGPU worker 用上限 2 重复成功上传同一页 4 次:只排入并执行 2 个 frame,后两次 + `redrawScheduled=false`,统计为 `scheduledCount/redrawCount=2/2`、`capped=true` 和稳定错误码; + reset 后统计清零且 errorCode 为 null。非法页仍在调度器之前失败,不消耗预算。 +- `WEB_TEST_PORT=5353 npm --prefix web run test:nanovdb-page-feedback-webgpu`:M8-02 至 M8-10 + 专项 Chromium 1/1 通过;`WEB_TEST_PORT=5354 npm --prefix web run test:vdb-faults`:既有 + network/Worker/WebGPU fault 1/1 通过;`WEB_TEST_PORT=5355 npm --prefix web run test:oom-recovery`: + WASM/OPFS/GPU/NanoVDB 四域 OOM 1/1 通过。 +- `npm --prefix web test`:全量 unit 87/87 通过(新增 M8-10 unit 2/2); + `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build` 均通过, + production build 66 modules transformed;`git diff --check` 通过。 +- M8-10 文件 SHA-256:renderer + `8b229173986fcca8c6d02ae1a0a15280c0f4e381de6eccb53c3b5e583d4d4116`、progressive protocol + `7f02e51cb47758b02bb1ea5d8d0fe6e0e8aebeceac5a6b3603f22647cf8ec913`、error contract + `f33540bbcc71add8784adb666d0bcc4f0a6141651a446a641573a98448debd77`、Worker + `e5f8d6e66387be61410431e12be31df587075dd2408305ba883ff5b624c06274`、e2e + `7fc1a630e921def8a216a9d16a196414fcb64523f5d6c908942ac1611664260b`、unit + `bf5c3836c4e6f02eea333d21dc315c8cc1b4706ae15921e24e0cc8d787dbe253`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-11 实际验证 +- 新增主线程 WebGPU 专项:直接在页面主线程创建真实 paged NanoVDB grid,按 256 KiB 页大小故意 + 不驻留 leaf page;Float32 sampler 读取缺页 feedback 后,从 fixture HTTP 精确 range 加载该页, + 通过 `NanoVDBProgressivePageUploader` 上传并排入一次 redraw。加载后采样恢复 valid,未使用 + CPU proxy 或 worker 结果代替主线程 GPU 路径。 +- 使用同一驻留表连续执行两次 64x64 `renderNanoVDBFloat32WebGPU`;两次输出均为 16,384 bytes, + 可见像素 4,096,SHA-256 完全一致,证明缺页加载后的像素结果确定且非空;scheduler 统计为 + pending=false、scheduledCount/redrawCount=1/1、capped=false。 +- `WEB_TEST_PORT=5359 npm --prefix web run test:nanovdb-main-thread-webgpu`:主线程 Chromium + 1/1 通过;此前 M8-10 专项、VDB fault 和四域 OOM 回归仍保持通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`git diff --check`:均通过; + M8-11 e2e SHA-256 `47fd18f653090398ba8d03a2663627f7c091f4fa4ad1a69d5b708f9db975af26`, + package script SHA-256 `b83be017b5dd1bb0b3b50d55057a47eb938ec4dcfe30b9818c4dc772702b42ad`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-12 实际验证 +- 新增 Offscreen Worker WebGPU 专项,使用与主线程相同的 256 KiB page layout、leaf 缺页反馈、 + 精确 HTTP range、page upload 和 progressive redraw 序列;Worker 端不是只验证状态,而是实际 + 执行 `sampleNanoVDBFloat32WebGPU` 与 64x64 `renderNanoVDBFloat32WebGPU`。 +- 主线程和 Offscreen Worker 均观察到同一 leaf page ID、唯一 page request、queued frame=1、 + redraws=1;最终像素 16,384 bytes 的 golden SHA-256 均为 + `87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c`,各路径重复渲染 hash 相同。 +- `WEB_TEST_PORT=5363 npm --prefix web run test:nanovdb-offscreen-page-feedback-webgpu`:Offscreen + Worker Chromium 1/1 通过;`WEB_TEST_PORT=5364 npm --prefix web run test:nanovdb-main-thread-webgpu`: + 主线程 golden 1/1 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`、 + `git diff --check`:均通过,production build 66 modules transformed。 +- M8-12 文件 SHA-256:Worker + `d8805ef02a44f086bbbab8c5206d22d8b525c650a052c50d60386e30f451b4bc`、Offscreen e2e + `e7ce2248933afd307b744282c8c91e29dab6a833be6a050eae2d6c327c359d54`、主线程 e2e + `dd16d8a491ae57725b2e9e9ce63839104ebb970febfff3747613e01e7bedf40b`、package scripts + `de6c1cc18a8792fc589da9f0c8d2937a5a78114cd2fa417fc3fe1d65ad635b81`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-13 实际验证 +- 新增 page feedback resume Worker:以 256 KiB page manifest 通过 + `loadNanoVDBFeedbackPages -> NanoVDBGridPageRequestCoordinator -> loadNanoVDBGridPage` 请求页 0, + 首个 chunk response body 在已交付 4096 bytes 后注入中断;resumable range source 的第二次请求 + 精确从 `Range.start + 4096` 续传,并发送首个响应的 `If-Range` ETag。 +- 恢复后的 page `consumedBytes == expectedBytes`、SHA-256 与直接 page range 完全一致;dispatch + 为 `ACCEPTED/renderRevision=17/requestedPageIds=[0]`,coordinator 最终 + `pendingPages/subscribers=0/0`。这验证的是缺页反馈消费链的 page-level resume,不仅是独立 chunk smoke。 +- 首轮 `WEB_TEST_PORT=5365` 仅断言固定 fixture page 跨 chunk 数量过严(实际 page 0 只跨一个 + chunk,仍发生两次请求且已从 +4096 续传);修正为 `>=2` 后, + `WEB_TEST_PORT=5366 npm --prefix web run test:nanovdb-page-resume` 1/1 通过。 +- `npm --prefix web run lint`、`npm --prefix web run build`、`git diff --check`:均通过,production + build 66 modules transformed。 +- M8-13 文件 SHA-256:Worker + `7095f447f92b79260d4a80d667e219845d74331ddbcc15fe2c915bd7c120efcb`、e2e + `d94b5f78f01fdde488a880c2c16cd896d71ed9098e1fdb9fdcef2547ba2959cc`、package scripts + `e18b887712585b19910332d3d7ffc7e3d012f5bd1d5e9d09d6911f0defeb4557`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-14 实际验证 +- 新增 Worker restart 专项:`prepare` Worker 将 128 KiB/两 chunk NanoVDB bundle 原子提交到 + OPFS,并在旧 GPU grid 驻留页 0;Worker 终止后由全新 `reopen` Worker 读取同一 manifest。 + 旧 Worker 传入的 `claimedResidentPages=[0]` 不参与恢复,新 grid 的初始 resident 严格为空。 +- 正例通过 `openNanoVDBFromOPFS -> loadNanoVDBGridPage` 重新读取声明 chunk,长度和 SHA-256 + 通过后才上传页 0,resident 变为 `[0]`。负例翻转 `00000.chunk` 首字节且保持长度不变,页加载 + 稳定返回 `NANOVDB_HASH_MISMATCH`,独立 verification grid 的 resident 保持 `[]`;随后恢复并 + 清理 OPFS fixture。 +- 首轮 `WEB_TEST_PORT=5368 npm --prefix web run test:nanovdb-opfs-restart` 暴露测试 manifest + 将源路径误写为 `.nvdb`,生产合同以 `NON_MESH_BINARY_INVALID` 正确拒绝;改为原始 `.vdb` + source 与 `.nvdb` bundle 后,`WEB_TEST_PORT=5369` 专项 Chromium 1/1 通过。 +- `WEB_TEST_PORT=5370 npm --prefix web run test:vdb-opfs`、`WEB_TEST_PORT=5371 npm --prefix web run + test:vdb-faults`:既有 OPFS hash-bound reopen 与 network/Worker/WebGPU fault 各 1/1 通过。 +- `npm --prefix web test`:全量 unit 87/87 通过;`npm --prefix web run typecheck`、 + `npm --prefix web run lint`、`npm --prefix web run build` 和全工作树 `git diff --check` 均通过, + production build 66 modules transformed。 +- M8-14 文件 SHA-256:Worker + `0c8eda762a9c7051b09bdb0feb8c30222775ac8e469bd9dfc98211f92ec63c50`、e2e + `4c8c5d27fc81b1f5c2bad8adb4947ee1865ef5ae395e7282282615c2341bb5fe`、package scripts + `c60ca8c69a459faef187b742a3b1c14152f2134ce1792d4987ae26aed8a448f9`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-15 实际验证 +- 新增纯 `nanovdb-device-recovery` schema v1:可见页 ID 数值排序、去重并验证在虚拟 grid + 范围内,`replayedPageIds` 最多等于 `residentPageCapacity`,其余明确进入 `skippedPageIds`; + page count、容量和页 ID 漂移统一返回 `NANOVDB_INVALID_ARGUMENT`。 +- 新增生产 `rebuildNanoVDBGridAfterDeviceLoss`:每次只在恢复后的新 device 上创建 page table, + 初始 resident 严格为空;仅对计划中的可见页调用现有 `loadNanoVDBGridPage` 复验 manifest chunk + 后上传。任何加载/校验失败都会 dispose 整个新 grid,不保留部分可信状态。 +- 真实 WebGPU device destroy/recover 后 generation 增加 1;输入可见集合 `[2,1,0,2]` 规范化为 + `[0,1,2]`,容量 2 只回放 `[0,1]`、跳过 `[2]`。旧 resident 的末页没有自动恢复,真实 page + table 前两项为 `[0,1]`、其余全为 `0xffffffff`,回放页的 GPU words 与已验证源页一致。 +- `node --test web/tests/unit/nanovdb-device-recovery.test.mjs`:2/2 通过; + `WEB_TEST_PORT=5372 npm --prefix web run test:vdb-faults`:专项 Chromium 1/1 通过。 +- `WEB_TEST_PORT=5373 npm --prefix web run test:nanovdb-page-feedback-webgpu`、 + `WEB_TEST_PORT=5374 npm --prefix web run test:nanovdb-opfs-restart`、 + `WEB_TEST_PORT=5375 npm --prefix web run test:oom-recovery`:分页、Worker restart、四域 OOM + 各 1/1 通过;全量 unit 89/89、typecheck、lint、production build(67 modules)和 + `git diff --check` 均通过。 +- M8-15 文件 SHA-256:recovery protocol/unit + `4eb9685efbf8a4cf7c47892ce2613a7377f3e24e07ffb8ac3056fc19959f6513` / + `271789b875c9d0f5c7ac3f8e283fb717c9846d913a37581a5d3f11a59bd68f91`;viewport/Worker/e2e + `97da1edd026bd522750182227031fa67f013397037bc2d417b69f832fa0d3b5c` / + `79ca3e0f53736d39b2a1f925012183a321e7fe4189eaa8c165cb923fa21af91a` / + `3e1b7900fbc4af7efe943311052f6f6abedd02810e39ddd79d15428c3ebe9347`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-16 实际验证 +- `NanoVDBWebGPUGrid` 的 direct/paged dispose 与新增 `NanoVDBPagedRenderResources` 统一为幂等; + 三资源组原子创建 resident buffer、page table 和 feedback buffer。feedback 初始化 + `queue.writeBuffer` 抛错时先由 feedback 创建器释放自身,再由资源组释放 grid 的另外两个 + buffer,重复 dispose 不再调用底层 destroy。 +- OOM suite 保留 `NANOVDB_PAGE_TABLE` 故障,证明 page table 未发布且已分配 resident 只释放一次; + 新增 `NANOVDB_FEEDBACK_BUFFER` 故障,在三个 buffer 均已创建后于 feedback 初始化注入。 + 受控真实 WebGPU proxy 观测三个 label 的 destroy count 各为 1,峰值资源 3、最终资源 0、总释放 + `131112 = 2*65536 + 8 + 32` bytes;随后同 device 上资源组、页上传、LRU 与双 dispose 均通过。 +- `WEB_TEST_PORT=5376 npm --prefix web run test:oom-recovery`:WASM/OPFS/GPU/NanoVDB 四域 + Chromium 1/1 通过,NanoVDB 报告包含两个稳定故障点且 schema 校验通过。 +- `WEB_TEST_PORT=5377 npm --prefix web run test:nanovdb-page-feedback-webgpu`、 + `WEB_TEST_PORT=5378 npm --prefix web run test:vdb-faults`、`WEB_TEST_PORT=5379 npm --prefix web run + test:nanovdb-main-thread-webgpu`、`WEB_TEST_PORT=5380 npm --prefix web run + test:nanovdb-offscreen-page-feedback-webgpu`:分页、device loss、主线程和 Offscreen 各 1/1 通过。 +- 全量 unit 89/89、typecheck、lint、production build(67 modules)和 `git diff --check` 均通过。 +- M8-16 文件 SHA-256:OOM protocol/renderer/scenario/e2e + `e93f4549dfb0e368fce41ed3c68461c1dbd6791601e37f832edfe4620fcd437e` / + `fa228b46dae1a1b9c6f04bb41b370b7f08d7e2c0b83a5174035e429861b36486` / + `616b25a6cbb3d03f599d9bb709c18f73d5df7c169fe33458fa4425ded4d26aa9` / + `714eee1d60c037c22c253caa78ead945eb4b0492f5f9418536015e5463531827`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-17 实际验证 +- 新增 Vite `__vdb_sparse_64m__` HTTP fixture:逻辑 bundle 严格 67,108,864 bytes,16 个 + 连续 4 MiB chunk,声明 SHA-256 与实际零填充响应一致;range 响应按 64 KiB 流式写出,支持 + 稳定 ETag/Content-Range 和可控 body 延迟,不把 64 MiB fixture 写入仓库。 +- 新增 sparse-performance Worker,使用生产 `validateNanoVDBBundleManifest`、 + `createResumableHttpNanoVDBRangeSource`、`NanoVDBGridPageRequestCoordinator` 和 + `loadNanoVDBFeedbackPages`。成功读取 page `[0,16,128,192]` 实际发起 4 次 range,传输 + 16,777,216 bytes,4 MiB 峰值 range 工作集,首 page 83--89 ms、成功阶段 339--345 ms; + coordinator 结束为 `pendingPages/subscribers/pageIds=0/0/[]`。 +- 同一 HTTP range 路径注入 2 ms body delay 后中途 Abort:实际收到 327,680--393,216 bytes, + consumer 调用 0,底层 request abort 1,取消延迟 12--13 ms,最终 coordinator 仍为 + `0/0/[]`,错误稳定为 `AbortError/CANCELLED`。该门只证明传输、分页和取消预算;真实 + NanoVDB traversal/pixel 仍由 M8-11/M8-12 专项覆盖。 +- `WEB_TEST_PORT=5381 npm --prefix web run test:nanovdb-sparse-performance`:专项 Chromium + 1/1 通过;独立复跑 `WEB_TEST_PORT=5386` 仍 1/1。回归 `WEB_TEST_PORT=5382` page feedback、 + `5383` page resume、`5384` VDB faults、`5385` OOM 各 1/1 通过。 +- `npm --prefix web test`:全量 unit 89/89 通过;`npm --prefix web run typecheck`、 + `npm --prefix web run lint`、`npm --prefix web run build` 均通过,production build 67 + modules transformed;相关 `git diff --check` 通过。 +- M8-17 文件 SHA-256:Vite sparse fixture + `fd160c685ed780738e2b37ce9a32e5d4718e11a2875e12d86ddad310213abaf3`、Worker + `56b5b10c0b138484b621822fca766f3dffad19692964f018e0d21d9a6df126de`、e2e + `de7230678f084bd51c7f2a845d22e3709435131e317c8504491c5a2c5a4d79ab`、package scripts + `7aecb80c67a7568b07f890be2d751a150532f0ae0f8ab66e92bce64419e73a7b`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M8-18 实际验证 +- Volume Main、NanoVDB asset binding 与双生产视口保存/重开已完成;主线程和 Offscreen + 均从同一 OPFS commit 恢复 Volume 数据、source path、density grid 与 asset hash,未把 + viewport 临时状态当作 Main 数据写回。 +- `WEB_TEST_PORT=5387 npm --prefix web run test:nanovdb-volume-roundtrip`:Chromium + 1/1 通过;既有 OPFS/hash-bound、VDB fault、分页、OOM 回归保持通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`、 + `git diff --check`:均通过,production build 67 modules transformed。 + +M8-19 实际验证 +- `tests/golden/M8-19/manifest.json` 固定 generated-smoke-vdb source SHA-256 + `586f7cdf4b3329fcb7ef00fc57b12a268baafdbaac0a2b3d1bfa142a22ccda33`、OpenVDB 13.0.0、 + `volume-wgsl-v1`、64x64 RGBA8 和三轴误差阈值;X/Y/Z 三份 desktop reference 均为 + 16,384 bytes,alpha coverage 3,552 pixels,SHA-256 均为 + `87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c`。 +- `node --test web/tests/unit/nanovdb-render-golden.test.mjs`:unit 2/2 通过; + `WEB_TEST_PORT=5390 npm --prefix web run test:nanovdb-render-golden`:Chromium 1/1 通过。 + 主线程、Offscreen 与 desktop 三轴比较均 `READY`,max/mean/RMS channel error 和 alpha + coverage delta 均为 0,backend 比较字节级一致。 +- M8-19 protocol/Worker/unit/e2e/manifest SHA-256: + `c4375cecc8c2ab4d0c2fd2081a92a8d1265a345f429353a0d92a9456df56f6a0` / + `9f3e2e31cfa4c4a0ca2ae7ef8175f37fbbe50aada628c7d0e40a338c4b0eba21` / + `5b4d64afa788cf97ee1978f05defc242c3e65de5a10e09abc1a067e4321d526a` / + `c23a2e2bb88ff3807b6fcac5cf7be526d08cf4b475f9ef7d8f720186af94397b` / + `2fb535d981820fff90bcb9fa353b237e513d0b52ffafaa6b63dc4c7c677e57da`。 + +M8-20 实际验证 +- 仅在 M8-01 至 M8-19 全部专项通过后更新 N-015 对应 VDB slice;slice 台账从 + 177/60 调整为 181/58,N-015 全域仍保持 `parity BLOCKED`,没有把有限 Volume/VDB + 能力推断成完整 Blender parity。 +- `docs/CURRENT_EXECUTION_PLAN.md`、`docs/PROJECT_STATUS_AND_NEXT_WORK.md`、 + `docs/status/N-015.md`、`docs/status/parity-ledger.json` 与 `docs/status/release-evidence.json` + 的 M8/ledger/evidence 联合校验通过;M8 正式计数为 20/20。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-01 实际验证 +- 新增纯 `external-vfont` schema v1 校验器:仅接受项目内 `//fonts/` 路径、TTF/OTF/PFB + 扩展名与 MIME 对、对应字体 magic bytes、32 MiB 大小上限和声明 SHA-256;验证成功前 + 只复制 ArrayBuffer,不调用 StorageClient、WebEngineClient 或任何 Main 写回。 +- 真实 Blender `bfont.pfb`(25,181 bytes,SHA-256 + `a33954fdab9fb09b9d308cb7f970518293128922ffc523c0a22b3b314a9a56c6`)通过;路径逃逸、 + 类型伪装、大小超限、长度漂移和 hash 漂移分别返回稳定错误码。 +- `node --test web/tests/unit/external-vfont.test.mjs`:unit 2/2; + `WEB_TEST_PORT=5391 npm --prefix web run test:external-vfont`:Chromium 1/1; + `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`、 + `git diff --check`:均通过,production build 67 modules transformed。 +- M9-01 protocol/Worker/unit/e2e SHA-256: + `f313e344b7708196c323f1ab25f1aff4bdaf8cbc277bc7ff25166e0015f29114` / + `f8ea3d199b996970da437cac5c1727d2b1f4d2b73ff346dee7ba20568768ed09` / + `cc4ed4fb724563e6df8692e8777e4a241ee41712605c6154d6fca238f52b138e` / + `d0bbdc47b71e0442b4744ccfebddb2a687d3520f13a49dfdbac98f1b10a70d13`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-02 实际验证 +- 新增外部字体导入事务:复用 M9-01 校验后,先调用 Storage Worker `putAsset`;只有 receipt + 的 project、hash、bytes、MIME、source path 和 + `projects//assets/sha256//` OPFS 路径全部一致,才构造 Main 命令。 +- WebEngine Worker 在进入 WASM 前重新验证 receipt、32 MiB 预算、base64 bytes、字体 magic 和 + SHA-256;Blender Main writer 从同一 bytes 创建 packed VFont,并以原 `//fonts/` 路径发布到 + SceneIR。存储失败注入确认 Main 调用为 0;Main 失败时已验证 OPFS 资产保留为可清理孤儿。 +- 首次 native 增量编译发现本分支 allocator 不暴露 `MEM_mallocN/MEM_freeN`;改用仓库既有 + `MEM_new_array_uninitialized/MEM_delete` 所有权后,`cmake --build build_web_blender6 --target + web_engine -- -j8` 通过,新 WASM 已安装到生产 Worker 的 src/public vendor 位置。 +- `node --test web/tests/unit/external-vfont.test.mjs`:M9-01/M9-02 unit 3/3; + `WEB_TEST_PORT=5395 npm --prefix web run test:external-vfont`:Chromium 2/2;真实 PFB 的事件顺序为 + `storage:start -> storage:committed -> main:start -> main:committed`,新增 VFont 为 packed。 +- `npm --prefix web run test:nonmesh-roundtrip`:既有 Font links、undo/redo、save/reopen 通过; + content-addressed asset Worker restart 定向 Chromium 1/1;全量 unit 94/94、typecheck、lint、 + production build(67 modules)、local-dependencies 和 `git diff --check` 全部通过。 +- M9-02 protocol/orchestrator/Worker/Main/API/unit/e2e/WASM SHA-256: + `44dea6d944f9ed891dd30f00bc4e03fdaf9e528ea97c911ea87036d131c6e0c8` / + `0f3801549dcffcf592e6f579cecf3fb97b78ccb1fc0b272a862ab11d062f77d8` / + `90dd37e3c94ec62b8f6dfc67acc3aa9c5fbe9a3114f0c553984e628419c3aaa4` / + `30f6d7edc394162fbdf9f8b404cc16f309fb2ad5cad9dada769b38221607d8ea` / + `6bfec5d5aca321c9c9694263648c763a295137cbfb03d7e13d4ad05907e3606e` / + `9ebd6f4628d59e919425ec4ddee8d3ca75445a69c5499402a3d3d3fd817e6e4a` / + `020b99560b4328f8593c2bc064867903b80229f2c36e7d7fa65d4f6a621e51e3` / + `a2e1f8bf1cd02c288538230bcc891d0208694fe97e0ea5dc32192580d671299e`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-03 实际验证 +- SceneIR VFont 资源新增真实 packed byte length 与 SHA-256;native reader 从保存后的 PackedFile + bytes 计算身份,协议对 packed/unpacked 字段组合失败关闭。外部字体导入只有在 Main 发布的 + length/hash 与 OPFS receipt 完全一致时才成功。 +- 新增生产字体样式替换协调器:先由 Storage Worker `readAsset` 复验 project、content-addressed + path、source path、MIME、长度和 SHA-256,再以单次 `setFontLinks` 修改 regular/bold/italic/ + boldItalic。missing metadata/bytes 稳定映射为 `NON_MESH_RESOURCE_MISSING`,Worker 等其他故障不被 + 错报为资源缺失;缺失路径确认 Main 调用为 0。 +- 真实 Chromium 完成 import -> regular replace -> undo -> redo -> save Project;删除 OPFS font + payload 后,新的替换请求稳定阻断,但新 WebEngine 从已提交 `.blend` 重开仍恢复同一 packed + VFont、length/hash 与四项 links,证明保存文件不依赖已删除源资产。 +- `source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8` + 通过;新 stable WASM 已安装到生产 Worker 的 src/public vendor 位置。 +- `node --test web/tests/unit/external-vfont.test.mjs`:M9-01 至 M9-03 unit 5/5; + `WEB_TEST_PORT=5399 npm --prefix web run test:external-vfont`:Chromium 3/3;全量 unit 96/96。 +- `npm --prefix web run test:nonmesh-roundtrip`:Font links、undo/redo、save/reopen 通过;content-addressed + asset Worker restart 与跨项目 orphan cleanup Chromium 各 1/1;typecheck、lint、production + build(67 modules)、local-deps 和 `git diff --check` 全部通过。 +- M9-03 protocol/orchestrator/SceneIR/Storage/native-reader/unit/e2e/WASM SHA-256: + `2a2f8a3c5a86227ca294c03c032de48b6e539ccc798aad2f56bdc642711b5748` / + `d6e35eb562fd577c54d554c53e9be0c658e382742ee4d6129180fa908691b7ef` / + `8bb885fa49717e7c6d30f61b2fd130833e491afba9a7ff8d0904d9485c08070a` / + `09e307d619081329a0fbdc9c2af6698c62ead409c99d045c4cf96a9d06ec236c` / + `d81907dc5084bc9e033059e94aa47632a3d192ed3f9e1fbe3f7ae853928f0095` / + `8e1594d6e9b2aa6b33576f4c750e5899c6902a4725c5fba962a0bd99108ba536` / + `f15863e38b0da66be0d59d6afabdf6cab407159adb34f691c65de4b8035c04a4` / + `f8e89fbb8aef657798d557afe987d461025c8b19d6bd193365500400ae063a37`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-04 实际验证 +- 新增纯 `curve-topology-editor` schema v1:以 Blender 5.2 + `source/blender/editors/curve/curve_ops.cc` 为算子来源,冻结 14 项 topology allowlist、selection + domain、参数名和每次 Main transaction 最多 1 个操作;未完成 Main/undo/save/golden 的算子全部 + 返回 `BLOCKED/CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED`,没有提前暴露到生产 UI 或 Main。 +- 冻结 65,536 splines、1,000,000 points、100,000 selected elements、单次新增 4,096 splines/ + 100,000 points、64 MiB payload 和 subdivide 64 cuts 预算;claim 绑定 base revision,未知算子、 + stale revision、重复/跨域 selection、未声明输出增长、非法额外字段和预算超限均稳定失败关闭。 +- 审计草稿时修正稳定 ID 合同:Curve Main/SceneIR 数据 ID 只接受 `curve:*`,拒绝误写的 `data:*`; + 空输入域不再把索引 0 当成有效选择。浏览器 Worker 明确报告 `CONTRACT_ONLY_NO_MAIN_OR_UI`。 +- `node --test web/tests/unit/curve-topology-editor.test.mjs`:3/3 通过; + `WEB_TEST_PORT=5400 npm --prefix web run test:curve-topology-contract`:Chromium 1/1 通过; + `npm --prefix web test`:全量 99/99 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build` 和相关 + `git diff --check` 均通过,production build 67 modules transformed。 +- M9-04 protocol/Worker/unit/e2e/Blender source SHA-256: + `2bf5bbda49ac96af55c5d9b4267773c7c7657c74bd6d23586b4d9fb1d8d5028c` / + `7b57c159788a660f2e3ae6b83b5332de325f8c857e2c1489f061463f49e43189` / + `ae8385cf698f899639b0037b0d9a529d4c9e9a01d3a91c4b4dca8c8a4dc7b420` / + `5f511706f18fa294fe385bbad6b24dab4e4376709a5d19f8ad54e52acc3b2d65` / + `b355f035482ce4e667bb08222cfc2d0967205b7e6a3b0fb5a63be81ff0c8cb8e`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-05 实际验证 +- 仅将 `TOGGLE_CYCLIC` 从 M9-04 的 BLOCKED gate 提升为唯一 READY operator;生产 helper 先生成 + revision-bound claim,再生成只含 `cyclicU` 的单一 `setCurveTopology` Main 命令,所有其他 13 项 + 算子仍没有 UI 或 Main writer 暴露。 +- 新增官方 Blender 5.2.0 golden 生成器 `tools/web/generate-curve-toggle-golden.py`:对真实 + `tests/files/web/nonmesh_scene.blend` 的 `WebCurveData` 第 0 条 POLY spline 翻转 cyclic U,另存 + 临时 `.blend` 后重开;golden 固定 fixture SHA-256 `ae8ef85d606aa120ce6b7611fc03407fd80bb60311e94a6ef73f6384d0b6c8b4`、类型 `[POLY, BEZIER]`、点数 `[4, 3]`、 + `false,false -> true,false`,生成器复跑与提交 golden 逐字节一致。 +- App Properties 只在 gate READY 时显示 `Cyclic U` checkbox;真实 UI 操作通过 WebEngine Worker/Main + 执行一次 revision 递增,随后 undo/redo 各自产生新 revision,保存到 OPFS 后关闭/恢复仍为 golden + 的 `true,false`。修复了 topology/undo/redo 返回时错误覆盖用户 active Curve 的 UI 状态;权威几何和 + revision 仍来自 Main snapshot。 +- `node --test web/tests/unit/curve-topology-editor.test.mjs`:5/5; + `WEB_TEST_PORT=5403 npm --prefix web run test:curve-topology-operator`:Chromium 2/2(contract + + Main/UI/undo/redo/save/reopen);`npm --prefix web run test:nonmesh-roundtrip`:既有非 Mesh 原生 + Curve/Surface/Font/MetaBall/Volume roundtrip、undo/redo/save/reopen 通过。 +- `npm --prefix web test`:全量 101/101;`npm --prefix web run typecheck`、`npm --prefix web run lint`、 + `npm --prefix web run build`、`git diff --check` 均退出 0,production build 68 modules transformed。 +- M9-05 protocol/App/Worker/unit/contract-e2e/operator-e2e/generator/golden/package SHA-256: + `41791ff197406b6b4b7579e55639b9770ec5fa3dc874d2401f1bf7c008f48978` / + `c04ebb59ec8092258e5df5832d6650d04f54d68ac7c72fa66c03827de7c4f070` / + `2c3cddc78339001d082333a9c7d8514fbe4ea15fb1d495402cf3e4f6837b1f16` / + `097590221adb362058de55efae4f2ea3af4855f20a581ecba92df85fb1e4a412` / + `ccde87fad3025c3e7525b80a59796403df38b6d9cbee88a3b464b3982c4597d5` / + `71677b2b59c5c1639f2c0fa4db66883b872b225eb77c43c7a70efabd522ad17a` / + `193b87f42977b6e0bc1c46f47d882c073070d797183777eafe123f24af1da6ec` / + `0a487bd73c40afdc969f5a90d3289e14f116b10b3bfeb0655fa312f7d2848655` / + `c90a759788b98adc5011cf68dd510838b0fe6bea4fc12f862af950851783eac6`。 +- 首次 Chromium 失败证据不计入 READY:第一次 toggle 后 Main 返回的 active object 覆盖了 UI Curve, + 第二次确认 toggle 成功后 undo 同样触发 active 丢失;修复后从 `WEB_TEST_PORT=5403` 全量 2/2 + 重跑通过,没有保留失败报告作为成功证据。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-06 实际验证 +- 新增 `grease-pencil-marquee` schema v1:请求绑定 Main `baseRevision`、当前 data/layer/frame/drawing、 + 归一化 viewport box 和最多 1,000,000 个候选;候选必须携带稳定 drawing/stroke/point ID,跨 + drawing、重复 point ID、同 stroke ID 映射多索引、伪造前缀、非法 box、stale revision 和超预算 + 分别返回稳定结构化错误,框外候选不会进入结果。 +- Blender Main reader 现发布 `activeLayerId`、必需 stroke ID 与 point ID;point ID 由 + data/drawing index/stroke index/point-local index 构成。文件未保存 active layer 时,SceneIR 只读 + 快照确定性使用首个实际 layer,不修改 Main;真实 fixture 的四个 point ID 经保存重开保持一致。 +- Three.js Grease Pencil proxy 同时保存 drawing/stroke/point stable ID;点击、高亮和多选去重改用 + stable ID,几何索引只保留给当前帧 writer 定位。marquee 只遍历活动、可见、未锁定 drawing 的 + point proxy,拒绝 onion、其他 layer/frame/drawing 候选。 +- App 新增真实框选工具和可见 drag rectangle;主线程直接投影候选,OffscreenCanvas 通过 Worker + 使用相同投影/helper/合同并回传相同 result。两条路径均选中真实 + `modifier_grease_pencil_scene.blend` 的 4 个 point ID 和 1 个 stroke ID,选择前后 Main revision + 不变。 +- `source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8`: + 增量 C++/WASM 构建通过;生成 JS/WASM 已同步到 `web/app/src` 与 `web/app/public` stable vendor, + 两处逐字节一致。 +- `npm --prefix web run test:grease-pencil`:native layer/frame/stroke transaction、undo/redo、 + save/reopen 及 stable ID 通过。 +- `WEB_TEST_PORT=5404 npm --prefix web run test:grease-pencil-marquee`:unit 3/3;真实 Chromium + 主线程/OffscreenCanvas 2/2。 +- `WEB_TEST_PORT=5406 npm --prefix web run test:e2e -- --workers=1 --grep "N-016 Grease Pencil"`: + 既有 schema/onion/双视口/点击/gizmo/layer/frame/point/Worker restart 12/12。 +- `npm --prefix web test`:全量 104/104;`npm --prefix web run typecheck`、`npm --prefix web run + lint`、`npm --prefix web run build`、`npm --prefix web run check:local-deps` 和 `git diff --check` + 均退出 0;production build 69 modules transformed。 +- `npm --prefix web run test:status-consistency` 与 `npm --prefix web run test:release-evidence`: + 12 parity BLOCKED、0 release blocked、17 evidence records、0 missing、release `READY`。release + evidence 首跑发现前序 M8-20 将 `C3-volume-combined-asset-viewport-reopen` 与 + `E2-vdb-large-stream-device-loss-oom` 移入 completed 后仍残留于 N-015 `v1ExcludedSlices`;只移除 + 两个过期 excluded 引用并同步 evidence family/hash,不改变 completed/blocked、evidence record 或 + release claim,随后两项检查均重跑通过。ledger/evidence SHA-256: + `4d0c3cd7b53103f37a5cc604b507307618d591338620c2beff972d556654cf58` / + `f65a7e43ccb2b71ba6309d6b44d0f0e5deb01e12f85cd2bb8de7bf787e710107`。 +- 未计入完成的首跑失败:marquee unit 初次因候选复用 drawing-only 精确字段解析为 1/3,投影 + drawing 字段后 3/3 重跑;native roundtrip 初次发现 fixture 无 active-layer 标记,增加只读有效 + layer fallback 后重编译通过;N-016 聚合初次 11/12 是 blocked 摘要夹具清空 layers 后仍继承 + activeLayerId,修正负例并从头重跑 12/12。 +- M9-06 native/SceneIR/marquee/App/GP-adapter/main-viewport/Offscreen/protocol/Worker/unit/e2e/ + native-check/package/WASM SHA-256: + `45e54e9f2d4a6157d8298f3432fbbe4281e44d6c8190712edca5e22c5a4ca831` / + `d2885203b6c138067b94b13c6f03c68769c2853317d32b6d7d9718ab5d1e6c28` / + `1e97396358b09a380c149ff5ed75b7b2dbfbd2beae49376e0b0ff3024e9a53a6` / + `c0860527a27e568432f618bb0a69b57e2ec03e77e3c231272763deedb4fe7b2f` / + `83a03a9626fd4434be1c18099aea29eba070bba9ae45ff109d47feace274ad99` / + `de061f817f722a5850393f5d1903ed13fa7cf43a8bbbb8f809aca2ccafc583f0` / + `955ba945e0155b2ef2ade573ac32bf9e2ad2153ec690efd18cb10a8d34cfc495` / + `4874e9bbebe35feeab73b8cfcc3b0259738b86fad21a180cb9d3a1ccde7a7e0b` / + `fb6f03491fa5421dc99555bb7db23f9ed69bd01bcc273ec169e8b8e75dee7d79` / + `460fe70ceaca4ffae11d99abe0b47c97d80fffb2b47182a0899a4f7d05c8db46` / + `abcd92e29eb08debb64f5a90e153d12348fc370f15a86b808412c5297fa4d83e` / + `55ba74a1544b946c7a5ed6f931649928bdabdd32a299866dc34a7cbe9649590a` / + `a2cf86a99737a92088eb9085f1673746c64bcc8091712a68110610c522426528` / + `d65c4680359b35300ea148ddd5e11f765eab241e7fe40d1d5f30d9bd2e3f1b23`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-07 实际验证 +- 新增纯 `grease-pencil-selection` schema v1:当前 data/layer/frame/drawing、稳定 point/stroke ID、 + selection source 和独立 revision 构成唯一状态;REPLACE/ADD/TOGGLE/CLEAR 均绑定 + `baseSelectionRevision`。stale revision、跨 drawing、重复 point ID、同索引多身份、同 stroke ID + 多索引、伪造前缀和 1M point 超预算均在 UI 状态发布前失败关闭。 +- Properties 新增真实 2D point canvas,按当前 drawing 的 XY 坐标绘制 stroke/point;pointer 与键盘 + 点选进入同一 selection transaction。App 不再让 2D/3D 各维护一份 point selection;Select All、 + 重选同一对象、工具模式和关闭项目也与中心 store 保持一致,selection revision 与 Main revision + 明确分离。 +- 主线程 ViewportRenderer、Offscreen wrapper/Worker 的 point pick 和 marquee 都携带发起时的 + base selection revision;Offscreen 仅在实际应用 point 高亮后回执 revision/point IDs。迟到结果 + 由中心 store 返回 `REVISION_CONFLICT`,不会覆盖更新后的 2D/3D selection。 +- 真实 `modifier_grease_pencil_scene.blend` 在 2D canvas 先选 1 点,再由 3D current-drawing marquee + 选择全部 4 点;selection revision 为 `0 -> 1 -> 2`,source 为 `CANVAS_2D -> VIEWPORT_3D`, + 2D canvas 与主线程/Offscreen 3D 高亮回执一致,Main revision 全程不变。 +- `WEB_TEST_PORT=5411 npm --prefix web run test:grease-pencil-selection`:unit 2/2;真实 Chromium + 主线程/OffscreenCanvas 2/2。`WEB_TEST_PORT=5410 npm --prefix web run test:grease-pencil-marquee`: + M9-06 unit 3/3、双后端 2/2;`WEB_TEST_PORT=5412 npm --prefix web run test:e2e -- --workers=1 + --grep "N-016 Grease Pencil"`:既有 N-016 聚合 12/12。 +- `npm --prefix web run test:grease-pencil`:native layer/frame/stroke、undo/redo、save/reopen 通过; + 全量 unit 106/106、typecheck、lint、production build(70 modules)、local-dependencies 和 + `git diff --check` 全部退出 0。 +- 未计入完成的首跑失败:`WEB_TEST_PORT=5407` 的测试坐标未先滚动 Properties canvas 到可见区, + pointer 未触发;补 `scrollIntoViewIfNeeded` 后 `WEB_TEST_PORT=5408` 暴露 UI 将含 point/stroke + 额外字段的引用直接传给 drawing-only 精确解析器。协调器显式投影四个 drawing 字段后, + `WEB_TEST_PORT=5409` 2/2;完成 Select All/重选/关闭状态收口后从 `5411` 再次全量 2/2。 +- M9-07 selection/marquee/App/CSS/main-viewport/Offscreen/protocol/Worker/unit/e2e/package SHA-256: + `7e19b049581edaa984abe7c0f5e6432d4aaca9514afce1060f3136544788aa50` / + `cef77a20b0dc05595adcb160fd4cdb4d9b426d717c714a94f2af7b23b35593ab` / + `bb5c38ad0ed4c1088e205fee2f75b4b14b96b8d337724e1e5d9e2762c56737be` / + `c88c6484e3cad1c86e2deaa0536a20bc3c46dffdb64f6af744e904fa355da506` / + `d1328dc803557b7c42e52d03394a01d79a9ace1aecbf7af28ee08813c975f028` / + `18985f06b83d6ad71cae2e3535e01d45644114b8d12b00d8d547eb77d047bdb1` / + `a69d98e602a0141517dac78fdac2c605d8a032301be104450d84ed502040889b` / + `a822ab87dd69849614a34eca015666284145a1c9d5782ccf7a18c4ab0ec64abd` / + `104785b5e5af27a43e5178a26e6ba130ff35cf6bf082b5816a0528a6e0ce3f07` / + `b83862a4c23d89a279c99f186d43af62a80905d368b46b0a2d5cd7a0ecdb1912` / + `a9e293a04c7aaa3ebdf04958deba60cf5317d4f850d96659cfe5b24cac282310`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-08 实际验证 +- 新增纯 `grease-pencil-reorder` schema v1:layer move 绑定 data/layer stable ID、方向和 Main + `baseRevision`;frame move 额外绑定 source frame、target frame 与 drawing stable ID。stale + revision、边界 no-op、伪造 drawing、已占用 target、相同 source/target、非法额外字段和超出 + 正负 1,000,000 的帧号均在 Main 前稳定失败关闭。 +- Blender Main 新增 drawing-preserving frame move;Worker 进入 WASM 前以当前 SceneIR/revision + 二次校验。Properties 的 layer 上下移动和 target frame 移动均只提交一次 Main transaction, + undo/redo 后 revision 单调推进;保存到 OPFS、关闭并恢复后 layer 顺序、frame 12 与原 drawing ID + 保持一致。 +- 官方 Blender 5.2.0 golden 生成器以真实 `modifier_grease_pencil_scene.blend` 新建 `Web Drafts` + layer/frame,执行 layer DOWN 与 frame `1 -> 12`,另存并重开;重新生成结果与提交 golden 逐字节 + 一致,fixture SHA-256 为 + `3a8525077807f9178dac3a5ba84b524e5f8e80874b15c54e2a6658ecb80315a3`。 +- `source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8` + 返回 `ninja: no work to do`;`bash tools/web/install-web-engine-assets.sh` 校验 single/pthread + manifest 4 项资源通过。 +- `WEB_TEST_PORT=5416 npm --prefix web run test:grease-pencil-reorder`:unit 2/2,真实 Chromium + Main/UI/undo/redo/save/reopen 1/1;`npm --prefix web run test:grease-pencil`:native + layer/frame/stroke、undo/redo、save/reopen 通过。 +- M9-06/M9-07 回归:marquee unit 3/3 + 主线程/Offscreen 2/2,selection unit 2/2 + + 主线程/Offscreen 2/2;`WEB_TEST_PORT=5417 npm --prefix web run test:e2e -- --workers=1 + --grep "N-016 Grease Pencil"`:既有聚合 12/12。 +- `npm --prefix web test`:全量 unit 108/108;typecheck、lint、production build(71 modules)、 + local-dependencies 和 `git diff --check` 均退出 0。N-016 仍保持 parity `BLOCKED`,完整 group + hierarchy、frame duplicate、2D stroke/segment/lasso、完整 Dope Sheet、modifier 和 desktop + drawing golden 未被本任务扩大声明。 +- M9-08 protocol/App/Worker/Main/API/header/stub/unit/e2e/generator/golden/package/WASM SHA-256: + `630286c6243295d55ce7ff99125518086321e8fde6cbc6481b4775f1081cee19` / + `c80d082e8d2e26efe440d6e9dae994b20458003cbb27f5172581068d7a477320` / + `3c692df2bf13df4214c498f5a9b1ceb2a687e24104c18e5d5bba8fc2be913784` / + `8b0e607b94507c0cae4e973a42f223a56fef1d8cd4fcf6773c5bdca065bedfee` / + `feed5740701794914fbd1f8a93113d0913ebc4feb187adc858746c888fcab4ab` / + `078836b7c19d5c90e74909c93c4d0b73d5eaa2010a3394517bbe40db358f84d5` / + `8575b407b4b1bcd119d3d9d02d0344cdcfdb527542a6c1000786183fb8e7e6b7` / + `7e28bb4292b1d81f4b04f7eaca25a759f98a809eac3f3cb3c566dd49cfaee70a` / + `60824eff69e49883954df4b01f49642666eeb97cc48477f418d3239f1c69180f` / + `b19c3c2cdb94e026b888ca9c659cc9981735053fe3d74c037850e81bfa68cea4` / + `922ce1bcacd788983ad6e3d855b8477653c0f0acda45b49e11e46925a8afb7c4` / + `dd7ee25e51cef9fbf9ff226fc26b349c1f883774bfd944b890d13c83e47cabaa` / + `e9c285a35dffd17f577087df5dfa08719575850d37b01a6c70523b79084ab8eb`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-09 实际验证 +- 新增纯 `paint-depth-visibility` schema v1:请求绑定 object/mesh stable ID、Main revision + 和最多 100,000 个顶点索引;返回绑定后端、RGBA depth readback 尺寸/字节数、 + 遮挡像素数和可见顶点。stale revision、伪造 ID、重复/越界顶点、未声明字段、 + 非 GPU source 与 64 MiB readback 超预算均在发布结果前失败关闭。 +- 主线程 `ViewportRenderer` 和 Offscreen Worker 都使用同一生产 helper:在真实 + WebGL2 `WebGLRenderTarget` 中以 `MeshDepthMaterial/RGBADepthPacking` 渲染当前场景, + 再读回 GPU RGBA depth 与候选顶点的投影深度比较;没有使用 CPU raycast/proxy + 冒充可见性。资源、scene override、clear state 和原 render target 在成功/失败路径均恢复。 +- 真实 Chromium fixture 在相机前方构造大遮挡面和后方三角形;主线程与 + OffscreenCanvas 均只返回前方 `[0,1,2,3]`,后方 `[4,5,6]` 被 GPU depth 排除, + 两后端结果一致;stale Offscreen 请求返回 `REVISION_CONFLICT`。 +- `WEB_TEST_PORT=5418 npm --prefix web run test:paint-depth-visibility`:unit 3/3,真实 + Chromium 主线程/Offscreen 1/1;`npm --prefix web test`:全量 unit 111/111。 + `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build` + 和 `git diff --check` 均退出 0,production build 73 modules transformed。 +- M9-09 protocol/GPU-helper/main-viewport/Offscreen/protocol-worker/Worker/unit/e2e/package SHA-256: + `def08629f9957bf2f31b58c6426ed0dd736fdf947fd40d0875a20d516f1fbe16` / + `b8b9c3960a06af9571323b273589848bdd0033d0dbba351994fcc08c97058830` / + `bef752dd255ffe0a0119cd51f9f9efcbd503692094fcb6fe7ecebe0d1fc33c69` / + `8a236d979d8f25f420e84919c0812f057dbc4cfd32d9914292abc3625be458af` / + `ff397e06638bef71e0435222dbc1e6d0550699f3208429937a7a4c73bc8027d8` / + `3e1540a1e931ebf56544202ae2c8febc1a9a5c34408a4b33f2f154db682c5129` / + `a781cd40c3e773e8cf8991af5e16c70e3c4aa82a8b0847ab381d7fe36eab66a4` / + `20afebca07bc5ac89525690b40372386f7d78b9bf579b77316f5143e9f7a6d1f` / + `d6acbd199bd0b33405c1d96a6a05ce4b4724d0502101ae8f214b0d6830f251b2`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-10 实际验证 +- 新增 `paint-stroke-session` schema v1 与 Worker 内有界事务:`BEGIN` 绑定 + `paint-pointer:*` session ID、Main base revision 和 vertex-color/weight 目标;最多 4,096 + 个连续序号 chunk、单 chunk 16,384 项、整笔 1,000,000 项/64 MiB,最多同时 + 8 个会话。跳号、重复索引、非法数值、声明 chunk 数漂移和 stale revision + 均在 Main 前失败关闭。 +- 每个 chunk 只进入 WebEngine Worker 缓冲,不调用 WASM;同一顶点跨 chunk 重复时 + 确定性 last-write-wins,`COMMIT` 按顶点索引排序后生成唯一 + `setVertexColors`/`setVertexWeights` Main 命令。`CANCEL` 直接释放缓冲,打开新 + `.blend`、Worker restart 或 shutdown 也不会保留未提交会话。 +- 真实 `attribute_scene.blend` 在 Chromium 中提交 2 个 chunk/5 个输入/4 个唯一 + 顶点;chunk 期间 Main revision 不变,commit 仅 `r -> r+1`。一次 undo 移除整个 + `M9StrokeColor` 属性,第二次 undo 稳定返回空历史;redo 恢复整笔,cancel + 会话前后 revision 不变。 +- `WEB_TEST_PORT=5420 npm --prefix web run test:paint-stroke-session`:unit 4/4、真实 + Chromium Main/undo/cancel 1/1;`WEB_TEST_PORT=5421` 的 M9-09 双后端深度回归 1/1。 + `npm --prefix web run test:paint-roundtrip` 的 color/weight normalize/mirror gate/undo/save/reopen + 全通过;全量 unit 115/115、typecheck、lint、production build(73 modules)和 + `git diff --check` 均退出 0。 +- M9-10 session-protocol/WebEngine-protocol/client/Worker/unit/e2e/package SHA-256: + `7c33e1f9df475a741cb78f69b044d4ddc3797b08bc7467f5bd1e16d85cea7808` / + `aeeaba3bfef4d2b1924b15f6bd9fc3028c1144b9202f29a0b9aa04576cf9fcd7` / + `71d0c9e25da3f478f18f34f3f5131526d566f0365aa88ec8078d6a32d5879dc2` / + `714347cc7b448067742ddb36d0dc1ace26fd9520c9d3112310e67bf331e17ce8` / + `3ee03cd9cb8082d7b1330df9c4d6fcb310f01a696d1aa43ca885652cea3b5796` / + `ab9077bc4f7efc574f49388b2e1e4fe1342ea1ff7a110f4695e3476ce78df3f5` / + `1b5e7aa17c54240d992a5fb61bfac109656bb438f4cdfa736cc3caf1c223be1b`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-11 实际验证 +- 新增 `texture-paint-asset` schema v1:target 绑定 project/image/texture asset、 + packed 或 UDIM tile 1001--1999、Main revision、尺寸、PNG MIME、SRGB/LINEAR、 + project-relative source path 和 base asset SHA-256;dirty patch 必须与同一 tile/revision/ + dimensions/color space 完全一致。 +- Storage Worker 从已验证的 content-addressed packed PNG 解码真实 RGBA8,复用 + `applyUdimTilePatch` 复验 base/result pixel SHA-256 后重新编码 PNG。新字节先通过 + OPFS 临时文件、content-addressed 命名和回读 hash,然后才在 IndexedDB + `setting` 单事务把 tile binding 从旧 hash 切换到新 hash。 +- 注入 `after-asset-write` 故障时,新 asset 可成为后续可清理孤儿,但已发布 + binding 的 generation、asset/pixel hash、path 和 updatedAt 逐字段不变;旧资产仍 + 可读。成功重试才从 generation 1 切换到 2,每个 tile 独立序列化。 +- 真实 Chromium 对 2x2 packed tile 1001 先写红色 dirty pixel、故障注入写绿色、 + 成功重试;同时对 UDIM tile 1002 写蓝色。新 Storage Worker 重建后两份 + binding 与解码像素逐字节恢复。 +- `WEB_TEST_PORT=5422 npm --prefix web run test:texture-paint-asset`:unit 2/2、真实 + Chromium packed/UDIM/OPFS/binding fault/restart 1/1;全量 unit 117/117。既有 paint + Main round-trip 通过;`WEB_TEST_PORT=5423` 的 content-addressed restart/quota/ + cross-project cleanup 4/4 通过;typecheck、lint、production build(73 modules)、 + local-dependencies 和 `git diff --check` 均退出 0。 +- M9-11 texture-protocol/paint/storage/error/client/Storage-Worker/OPFS/unit/e2e/package SHA-256: + `f85bcc0e1a8445d6e83846a841d8e201c41de8e7e307132565bfec3d7a3f5ab9` / + `76db7c4ff146d929571d7b58f66faf4e2a2f3c8d6cc2d3fca937a17be8b5f827` / + `77482c74d71bcca9aaf57d96ec0c201698fe9c887d85c5681edcea354417e23c` / + `36868e5278ab11509fc744719bd8e058934ff922675a6901f4a52587a709fa2f` / + `67b228bd0e5e81fa34ab198feefa1430454807e85460dea70ecb5a9ef09dbb66` / + `b6bcce466257fef0ea2d53ef894f2cc455e03834302576ab1230ff38da0241da` / + `74c63f14bef3ddbf60d1106519b3c9cdf370702cb1427e13e59670175d16b840` / + `c3796e4bbfab3ed70f897e2682eec5c9e6a5ba166dc2fea24da553ebf411f47b` / + `3b4299d5319dbbf0839a04ee55962d30f448202c88747ebdabb9bbe1c07031cd` / + `8d71d52be5b32b54a45b2c6dab6ed46c8edecf8dac7d600b8d1a64f3e32a4ea8`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-12 实际验证 +- 新增 `WeightPaintOptionsIR`/bounded helper:`normalize`、`limit`(1--32)、`mirror`、 + `mirrorAxis`(X/Y/Z)和 `mirrorTolerance`((0,1])进入 schema;重复顶点、越界值、 + 非法组合和超预算输入在 Worker/纯协议层结构化拒绝。已有 pointer-session 目标可选携带 + 同一策略,commit 仍只产生一个 `setVertexWeights` Main 命令。 +- Blender Main `setVertexWeights` 的固定顺序为写入/移除目标组 -> verified local-coordinate + reciprocal mirror -> limit lowest influences -> normalize all remaining influences。镜像先 + 建立空间桶并验证每个顶点的反射 counterpart 和 reciprocal identity;无法验证或超过 100k + 顶点稳定返回 `CAPABILITY_MISSING`/`PAINT_BUDGET_EXCEEDED`,不修改 Main。limit 与 mirror + 的 affected vertices 一起进入同一 undo/save transaction。 +- 新增 `tools/web/generate-weight-paint-golden.py` 和 `tests/golden/M9-12/weight-paint.json`, + 使用官方 Blender 5.2.0 LTS 在 `rigged_shape_scene.blend` 上生成 initial、normalize、 + limit-normalize、mirror 四个逐顶点 group weight 快照;`check-weight-paint-golden.mjs` + 对 WASM snapshot 和 save/reopen 逐项比较,误差门 `1e-6`。 +- Properties Paint 面板新增 normalize、influence limit、mirror 和轴选择;Apply Weight + 将这些选项直接提交到 Main,未引入 UI 副本或绕过 revision/undo 协议。 +- 验证命令与结果: + `source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8` + 通过(Blender 头文件既有 4 条 char conversion warning);stable `web_engine.js/.wasm` + 与构建产物同步后,`npm --prefix web run typecheck`、`npm --prefix web run lint`、 + `npm --prefix web test`(120/120)、`npm --prefix web run build`(73 modules)和 + `git diff --check` 均退出 0。 +- `npm --prefix web run test:paint-roundtrip`:normalize/limit/mirror/undo/save/reopen 全通过; + `npm --prefix web run test:weight-paint-golden`:Blender 5.2 golden 四步、WASM 与重开全通过; + `node --test web/tests/unit/weight-paint.test.mjs web/tests/unit/paint-stroke-session.test.mjs`:7/7。 +- 未计入完成的首跑失败:golden 首次使用旧 stable WASM 导致 limit 结果未更新,定位为构建 + 产物未同步到 `web/app/src/vendor/blender`;同步后从头重跑通过,未保留失败结果为 READY 证据。 +- M9-12 protocol/native/UI/worker/golden/unit/package/WASM SHA-256(按上述文件顺序): + `edbf8e9ef9270c6ca36b88d3ebccbe138c37c52e717556aaf35ef0e0d24677cc` / + `915be99077af68e456d799a786476cd7ed92628089713327df36f56e31b92791` / + `6ade55f27ea68bbe68c4ceb3a1cd728376b537fe023271a7c962b65883074b3b` / + `d7f8a6c9aae526f9cc744b584aa99b716eb1a3ebe57b3a87141dde65cb813b89` / + `0a34ba598336b87e82dc14e284050be77e2ee92bd32ec266c35fad443387009d` / + `d54dcf6a2403152579e22f60353f07907e422317f9778fa4c9fdd1a5927e9883` / + `3b9d1805ee80ad1efa0f483c3dab4faabffb834fe6f7b596a0f6f565f48c906a` / + `e97b9ef38064cce235d179fd8c547305a2c1195a5cffdfe4b5fde1135bf71b14` / + `76d9cf460e4d9571464951036fe7dd9fa7abc9937fc1cfca6da0acf8ebb6e5a5` / + `15edc2b3849efa6b7ee7940ba479d009e0a6c354337f7feabc00994fd16b5c8b` / + `0418284f19eb94ecc3d76f00dedbbab9a332d60a32f25f452f1dedfcc694aca3` / + `6ec17c56d361216128e982bb2de622435b7927680efddba8107445841a17901b` / + `4ce0c45d59f64c8838e3ee0e8f16039b7652212b2b80ac9a2ea77070e1f9dbaa` / + `966c5b20af5e5c66fc161a56d2dc27b431a35c993f0dbc09a089359d1a076fea` / + `9b6d30050d199f7aa9cd6ea4b6a0079808cb39652144b6ac4c3dbf5b9c39210e` / + `94602f0ee8f0b954f1d6d3dd07c955880a4cc32b731ebfb14349251517de066e`。 +- 生产双 variant 重新构建并安装成功:single WASM `6e096facf58911f01bff0fa2a649b3b77bdc98cbb8b06533d067583f2902ae21`、 + pthread WASM `6463dc2afb84a64c7d1f4159acc93440e02be83f8a8f246fc96c92b238962553`,manifest + `938619cee619d578165eb33dcd445f22e34d5d3c8fa521d42697eabc98cf646c`;stable full-Main + 检查入口继续绑定 `build_web_blender6`,避免把 native smoke stub 误当作 Main 写回证据。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M9-13 实际验证 +- 新增 `paint-pbvh-capability` schema v1,以 Blender 5.2 `DNA_brush_enums.h` 冻结 46 个活跃 + brush:Sculpt 32、Vertex Color 4、Weight 4、Texture 6。请求严格绑定 operation、domain、 + brush、object/mesh stable ID 和 Main base revision;未知字段、伪造 ID、跨域 brush 和 stale + revision 在能力结果发布前失败关闭。 +- WebEngine Worker 直接检查生产模块是否导出 `_web_engine_apply_pbvh_stroke`;stable、single、 + pthread 三个 JS glue 均确认未导出,因此全部 46 项稳定返回 + `N-017/BLOCKED/PAINT_PBVH_UNAVAILABLE`。没有把 uniform-grid、GPU depth 或 bounded patch + 冒充 PBVH;即使未来出现同名入口,session context 和逐 brush desktop/WASM golden 未就绪时 + 仍分别返回 `PAINT_PBVH_CONTEXT_UNAVAILABLE` 和 `PAINT_PBVH_BRUSH_UNVERIFIED`。 +- 真实 `attribute_scene.blend` Chromium 逐项查询 46 个 brush;查询前后 Main revision、live + handles 和 WASM allocated bytes 完全不变。stale 请求优先返回 `REVISION_CONFLICT`;绕过 + TypeScript 注入 `proxySuccess` 的请求返回包含 code/severity/message/recoverable 的标准 + `PAINT_SCHEMA_INVALID`,未进入 Main。 +- `WEB_TEST_PORT=5427 npm --prefix web run test:paint-pbvh-capability`:unit 4/4、真实 Chromium + 1/1;`npm --prefix web test`:全量 unit 124/124。`npm --prefix web run test:paint-roundtrip`、 + `npm --prefix web run test:weight-paint-golden` 和 `WEB_TEST_PORT=5425 npm --prefix web run + test:paint-stroke-session` 均通过,确认既有 bounded Paint Main/undo/save 路径未回归。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build`、 + `npm --prefix web run check:local-deps` 和 `git diff --check` 均退出 0;production build 73 + modules transformed。M9 进度更新为 13/14,N-017 仍为 parity `BLOCKED`、V1 release `READY`。 +- 未计入完成的重跑失败:`WEB_TEST_PORT=5426` 功能结果全部正确,但 E2E 对标准错误对象做整对象 + 比较时漏写预期 `message`;补齐稳定消息断言后改用新端口 5427 从 unit 到 Chromium 全量重跑通过。 +- M9-13 protocol/capability-gates/error/WebEngine protocol/client/Worker/unit/e2e/package/Blender + inventory/WASM SHA-256(按上述文件顺序): + `749eb772f0cdeb0fe79c058391a47fdf680dc331db300a91a22d324e535f32c5` / + `8702afe367fd62d39868156b74e69eb46bbf0c522e30d847dae02459fcb25c45` / + `a15d9e2d3e84884245505d18226f9e1484469d8313aa6534aff480fa4746264d` / + `8bcbe72bc02ebd2af73e92657093697ef513f8e0fc31ea17f5967d3fd2c8ed6b` / + `7627509efe48ff377e72d0cc8c144b7858e784a23733e07b26438a5c4975166b` / + `4a65547e6b1fea16a120b4e72eb37d4d9f7c7ced4342f535796535262ff65fcc` / + `a2efa988f50a3e4ea9363021130798d6bf7b8ed1669a980b879197f9c3fb5bff` / + `03d13687f78995fe6ae4c683ece3783be77217daf8ec85df66e77451717a7729` / + `38a9a0ba0c7ee6b6435209965af4498377c245c4f197f2c3534358103047009e` / + `c56dfd81a52096c32a9f3c7756f8b86599906763fad33d26f5a9bf52e4fbfa39` / + `94602f0ee8f0b954f1d6d3dd07c955880a4cc32b731ebfb14349251517de066e`。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 关键当前构件 - binary archive SHA-256:8af6c782b8e4b31020219597b821160b7634439749c455fd31d9d919676e2e2e @@ -213,4 +1478,854 @@ M7-08 立即执行 - quick/Chromium/release report SHA-256:dcaa32be911fa7ed36127b5cb1458bb07dcca19d562cabc64e022f13b094f911 / 90b19d057957f1edce264c7137f89d6701643e46feb88001054eea1bf0a18eb5 / 7d28a5018b78ba45935f832388d6b4e632c6198476a2fdcb4a0abef3066c5ec6 -- HEAD commit:17ab961485fe2ea1574c5f0b7d3ef9608a2bf424 +- HEAD commit:7c16b279d52899d2494e64d58ba9897a9b69dabc + +M9-14 实际验证 +- 新增 `web/protocol/editing-domain-recovery.ts` schema v1,固定 `CURVE`、 + `GREASE_PENCIL`、`PAINT` 三个编辑域的 baseline identity、Worker restart、OOM、GPU + release 和 small-scene evidence。解析器严格校验 SHA-256、稳定 data/object ID、 + revision/hash 关系、`GPU_GEOMETRY_UPLOAD -> GPU_GEOMETRY_BUDGET_EXCEEDED` 映射、 + token 资源清理、一次 release/一次 reinit 和非空像素;重复域、身份漂移、释放次数漂移均 + 稳定拒绝。`summarizeEditingDomain` 只收集当前可见且属于目标编辑域的对象。 +- 新增 `web/app/src/testing/editing-domain-recovery.ts` 真实 runner。Curve 使用真实 + `setCurveControlPoints`/`setCurveTopology` Main writer,Grease Pencil 使用真实整帧 + `setGreasePencilStrokes`,Paint 使用真实 `setVertexColors`;每域保存后由全新 + `WebEngineClient`/Worker 重开并以 identity hash 复核。由于新序列化 `.blend` 的 Main + logical revision 从基线重新开始,恢复门以内容 identity hash 为权威并记录 revision reset。 +- OOM 阶段使用 token-isolated `OOMFaultSession`:先保留一个可释放 GPU lease,再在 + `GPU_GEOMETRY_UPLOAD` 注入失败,关闭 session 后 live resources 为 0、released bytes + 大于 0,未授权 token 不能消费 fault session。GPU 阶段分别创建 Curve line、Grease Pencil + points 和 Paint mesh 的 WebGL2 小场景,释放 geometry/material/renderer 恰好一次,再创建 + 新 renderer;两次读回均有非零像素。 +- 新增 `tests/golden/M9-14/manifest.json`、`web/tests/unit/editing-domain-recovery.test.mjs` + 和 `web/tests/e2e/editing-domain-recovery.spec.ts`;package script 为 + `npm --prefix web run test:editing-domain-recovery`。 +- fixture SHA-256:Curve + `ae8ef85d606aa120ce6b7611fc03407fd80bb60311e94a6ef73f6384d0b6c8b4`;Grease Pencil + `3a8525077807f9178dac3a5ba84b524e5f8e80874b15c54e2a6658ecb80315a3`;Paint + `12fa75bb79f8c38e660d3d2a8fc9cc16dd3df4fc9208b0e2ad94fb1f8aa68f71`。 +- `WEB_TEST_PORT=5435 npm --prefix web run test:editing-domain-recovery`:unit 3/3、真实 + Chromium 三域 1/1;每域四阶段均 `RECOVERED`,identity hash、data IDs、对象计数和非空 + 像素门通过。 +- `npm --prefix web test`:全量 unit 127/127;`npm --prefix web run typecheck`、 + `npm --prefix web run lint`、`npm --prefix web run build`(73 modules)和 + `git diff --check` 均退出 0。 +- M9-14 文件 SHA-256:protocol + `42ab53dde3161ec31ca7de420046a2c06f58408b3ac4919c9904e02d875af258`;runner + `2bb9805795fc2104e5443207a08951ac7684e67913064d6795c9d165b0c54c7e`;unit + `ca565149e7769b40f59e865e42d2dfb0f4ecb2a4655371004678c14536ec9ef6`;e2e + `7ab3421d1b44eb78d13525f67a26f1f6ed20816ae9b43c010c9ed3ebdf32c09a`;golden + `f548052f39f6259f30b0569528160512aad955e61e8e8559e0d553c30d5619f9`。 +- `docs/status/M9-14.md`、`docs/CURRENT_EXECUTION_PLAN.md` 和 + `docs/PROJECT_STATUS_AND_NEXT_WORK.md` 已同步为 M9 `14/14`;N-015/N-016/N-017 + 全域 parity 仍保持 `BLOCKED`,仅新增有界故障恢复切片证据。 +- 未计入完成的失败:前三次 E2E 发现 Curve fixture 的控制点只在 binary geometry 中、 + 以及 save/reopen 后 logical revision 重置;runner 已改用已发布 Curve topology fallback + 与内容 hash 恢复门,换用端口 5435 从 unit 到 Chromium 全量重跑通过。 +- 后续领取点:机器队列的最新 `nextTask`;本文不手工维护任务名称。 + +M10-01 实际验证(2026-08-16 America/New_York) +- 按主计划领取 M10-01。现有实现新增 `GeometryNodeGraphIR` 的 Main/WASM reader:从 + Blender 5.2 `bNodeTree` 读取图接口、节点、socket 数据类型/有限默认值和 link;稳定 ID + 使用 node identifier、socket identifier 和 typed ID reference,不使用显示名称;`__extend__` + UI 扩展 socket 被明确排除。每图预算固定为 4096 graphs、4096 nodes、16384 links、65536 + sockets、4096 interface sockets,异常和超预算输入 fail-closed。 +- `geometryNodeGraphs` 作为 SceneIR schema 1 的可选字段发布;现有 + `web/protocol/schema-version` 仍为 `1`,因为本任务只增加可选字段,不改变已有字段含义或 + 持久化 schema。未支持节点保留原始 metadata,未宣称 Geometry Nodes 求值能力,交由 M10-02 + allowlist gate 处理。 +- 真实 fixture `tests/files/web/modifier_geometry_nodes_scene.blend` SHA-256: + `f3820511f791769837d75be092934130c397cf46ee0acf79db28efca9a7948c9`;desktop golden + `tests/golden/W-075/modifier_geometry_nodes_scene.json` SHA-256: + `93c21e5158daefce10f2d4df674e636bf3f6ed3dd43e1576c45a02a8592f5886`;M10-01 manifest + `tests/golden/M10-01/geometry-node-main-reader.json` SHA-256: + `ca4f0a1c23466f4a70612c7bbb78d888f7300988b74820b174bad42968d300cd`。 +- `source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8`: + 通过,`ninja: no work to do`;稳定 WASM reader 运行通过。 +- `node --test web/tests/unit/geometry-nodes.test.mjs`:3/3; + `node tools/web/check-geometry-node-main-reader.mjs`:3 graphs、10 nodes、7 links、9 defaults, + stable ID、graph hash、desktop golden、save/reopen、unsupported Simulation preservation 全通过。 +- `WEB_TEST_PORT=5440 npm --prefix web run test:geometry-node-main-reader`:unit 3/3、reader 1/1、 + Chromium Worker 1/1 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、`npm --prefix web run build` + (73 modules)和 `git diff --check` 均退出 0。 +- M10-01 文件 SHA-256:protocol `667c07b3561c35c9ce75c0034ecafc407d6a7435b4b5535354168c16b7ee0925`; + SceneIR `0d6c74bb39bd8ffdf748f369aee7f11e553b022a61bf6e557a21b948ebe35aee`;Main reader + `510b4017c540fc4d1ac135c1ce7251cc5d7143f31d8604e1d545de36716da9a2`;API + `b1050443141f664322fa2e9f11209f5b2b7dfe4e4927ed9af177bce0f8d74549`;unit + `922d9435b5f8b0ef648784957feba8f8716f6407f099eaad8d62e99db4f4f908`;E2E + `aa8e79f5ddc40bb78afea4587aa80fde21eba5b00838b5d0c5f3be2c48f41953`;WASM + `2875acccbb8d262526c3522c635ab5cf0aa0e7e2ac9b1e3c54cce00151460793`。 +- `docs/status/M10-01.md` 已新增;M10 计数更新为 1/15;N-012 全域仍为 `in_progress`, + 不支持节点 evaluator/Simulation 等后续项未提前关闭。后续领取点:机器队列的最新 + `nextTask`,本文不缓存任务名称。 + +M10-02 实际验证(2026-08-16 America/New_York) +- 导出并冻结 `GEOMETRY_NODE_ALLOWLIST_SCHEMA=1` 和 16 项 + `GEOMETRY_NODE_ALLOWLIST`:Group Input/Output、Transform/Set Position、Join/Separate、 + Realize Instances、Store Named Attribute、Int/Vector Input、Value、Compare、Math 以及 + Object/Collection/Image Info。外部资源节点继续受 stable ID、缺失/linked/corrupt 和 owner + cycle 门约束。 +- 新增 `tests/golden/M10-02/geometry-node-allowlist.json`、M10-02 unit 断言和独立真实 + Chromium Worker spec。真实 Main `WebGeometryNodesSimulation` 中的 + `GeometryNodeSimulationInput/Output` 保持原数据;提交该图稳定返回 + `GN_NODE_UNSUPPORTED`,失败前后 graph JSON、graph SHA-256 和 Main revision 完全不变。 +- allowlist 内 `WebGeometryNodes` 通过协议门后仍由生产 Worker 返回 `CAPABILITY_MISSING`, + 因为 M10-03 的逐节点 evaluator desktop/WASM golden 尚未完成;同样不改变 Main 或图数据。 + 本任务只关闭 allowlist/无损阻断,不扩大 N-012 全域求值声明。 +- `WEB_TEST_PORT=5441 npm --prefix web run test:geometry-node-allowlist`:unit 4/4、真实 + Chromium 1/1;`WEB_TEST_PORT=5442 npm --prefix web run test:geometry-node-main-reader`: + M10-01 unit 4/4、reader 1/1、Chromium 1/1 回归通过。 +- `npm --prefix web test`:全量 131/131;`npm --prefix web run typecheck`、 + `npm --prefix web run lint`、`npm --prefix web run build`(73 modules)和 + `git diff --check` 均退出 0。 +- M10-02 文件 SHA-256:protocol + `23c2289ec2acbdce0beba47c0b6539bc3f14f5a71ce29927df86cf6c705d6ec3`;unit + `e60933ed0f946bdaa96f5322ba8d80b26c8425c623dd6e87da33016a1592c299`;E2E + `ff2c838b5c16d86eff13699586a0cd1121ee1a86d5ea52d7b89c01d35632bb77`;golden + `471fec9c5382ab7522b6c18ab580a6926eaa59eb5a8b72d23968670b9b9ab37a`;production Worker + `4a65547e6b1fea16a120b4e72eb37d4d9f7c7ced4342f535796535262ff65fcc`;package + `b86395d256e46ac4e8298e18e1d594861a5ca757fdae2193120e7b38182c28fb`。 +- `docs/status/M10-02.md` 和 N-012 状态已同步;M10 计数更新为 2/15。后续领取点: + 机器队列最新 `nextTask`,本文不缓存任务名称。 + +M10-03 实际验证(2026-08-16 America/New_York) +- 新增有界 `WebGeometryNodeEvaluator`,从 active Group Output 按需执行 schema 1 的 16 项 + allowlist。Geometry 使用 Blender `GeometrySet`、join/realize/transform 与 mesh attribute + API;Object/Collection 只读取同一 Main 的 evaluated depsgraph 资源,Image Info 读取真实 + image dimensions。求值次数最多 4096;未支持 socket/operation/resource/cycle 继续稳定返回 + `GEOMETRY_NODES_EVALUATOR_UNSUPPORTED`,Simulation Zone 不放行。 +- 新增官方 Blender 5.2.0 LTS 生成器、`geometry_node_allowlist_evaluator.blend` 和 + `M10-03/geometry-node-evaluator.json`。11 个最小场景让 Group I/O、Transform/Set Position、 + Join/Separate/Realize、Store Named Attribute、Int/Vector/Value/Compare/Math 以及 + Object/Collection/Image Info 全部进入可观测输出;golden 固定 topology、positions、bounds、 + point Float attribute 与误差门(position max `1e-5`、RMS `1e-6`、attribute `1e-6`、 + bounds `1e-5`)。 +- `build_blender_5.2.0/bin/blender -b --factory-startup --python + tools/web/generate-geometry-node-evaluator-golden.py -- `:生成 11 cases; + 排除输出路径导致的 fixture path/hash 字段后,与仓库 golden 逐字段一致。Blender 自动备份 + `geometry_node_allowlist_evaluator.blend1` 已移除,不作为交付输入。 +- `source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8`: + 通过,`ninja: no work to do`,当前 full-Main 构件已由该 evaluator 源生成且与稳定 runtime 同步。 +- `WEB_TEST_PORT=5443 npm --prefix web run test:geometry-node-evaluator-golden`:unit 4/4;Node + full-Main WASM 16 nodes/11 cases,maximum/RMS position error 均为 0、attribute 通过;真实 + Chromium Worker 打开/求值/保存/重开 1/1 通过。 +- M10-01/M10-02 回归分别使用端口 5444/5445:Main reader 4/4 + reader 1/1 + Chromium 1/1; + allowlist unit 4/4 + unsupported graph 无损阻断 Chromium 1/1,均通过。 +- `npm --prefix web test`:全量 unit 131/131;`npm --prefix web run typecheck`、lint、production + build(73 modules)、`check:local-deps` 和 `git diff --check` 均退出 0。 +- M10-03 native/generator/checker/fixture/golden/Chromium/package/WASM SHA-256: + `b896c5c52a611e7a309f80ca4bf8323ccded458584f65ab8f6841df24d70cc42` / + `f42ba309e4935a4534eabcb8ab1978abc08d863e2d18d80a70607f31dd3a3066` / + `76a02d4aae70a597f0c26c12de3104d56e3f1cc9fded1d4824a05d7ddc59c1b5` / + `0cb3e33df570b436e32d783eef0a6c2daad04ca5bb4ccb4f0286b3d723ff7978` / + `363ae47cfb50b8c95d0a55116e8663e41d27c7aa332cffe9b702bbe9b8cbfe1c` / + `83b1c96518eb64bef2c8830c778a84eb13d8da0ea62c2488471ed5fefea975c8` / + `f9130696e8f53ab5aa0ab9d8d27b45dd0cd868fe2eb2741e41a05b70b81b2584` / + `432c5c9efb03b23a506c1b8f57f0d06ee683274bbdfe415f894f0d3c29db0b77`。 +- `docs/status/M10-03.md`、主计划、项目状态和 N-012 状态已同步为 M10 `3/15`。M10-03 + 只关闭保存图的有界常量/实例求值;任意 field/domain、完整 lazy-function、图写回、linked + resource 与 Simulation Zone 均未扩大声明。后续领取点:机器队列最新 `nextTask`,本文不缓存任务名称。 + +M10-04 实际验证(2026-08-16 America/New_York) +- 新增 field materialization schema 1,绑定 graph ID/hash、Main revision、source/target domain、 + data type、transport 和完整七域 cardinality。POINT/EDGE/FACE/CORNER/CURVE/INSTANCE/LAYER + 元素上限分别为 1M/2M/2M/4M/100k/100k/4096;单批最多 64 fields、32 次真实 domain + conversion、4M 目标元素和 64 MiB。Boolean/Int/Float/Vector/Color 的 scalar/byte 数均由 + parser 安全乘法推导,不接受调用方伪造计数。 +- JSON 单 field 上限固定为 65,536 scalar;更大 field 只允许 `BINARY`,原生 Depsgraph 当前 + point Float 回执若越界则只报告 `BINARY_REQUIRED/GN_FIELD_JSON_BUDGET_EXCEEDED`,不展开 + attribute JSON。协议拒绝内嵌 `values/jsonValues`、未知字段、未知/缺失 domain、伪造数据类型、 + 重复 field、批次聚合溢出和非数组 batch。 +- full-Main Depsgraph 现从 evaluated Mesh 发布 POINT/EDGE/FACE/CORNER 真实基数,并让 + `m10_value` 回执携带 schema、元素/scalar/byte 数。parser 交叉核对 vertex count、domain + cardinality、Float32 byte layout、JSON 阈值和实际 attribute payload;回执与 payload 不能漂移。 +- `source tools/web/emscripten-env.sh && cmake --build build_web_blender6 --target web_engine -- -j8`: + 通过;仅保留 Blender 头文件既有 5 条 warning。full-Main 构件已同步到 src/public stable + runtime,三处 WASM SHA-256 一致为 + `1bb4a947da4332471f3d7bab8f3f628c83d1703244093a554a188668ddafafd7`。 +- `WEB_TEST_PORT=5448 npm --prefix web run test:geometry-node-field-budget`:unit 6/6、真实 + Chromium Worker 1/1。`M10GN_StoreAttribute` 返回七域 `8/12/6/24/0/0/0`、schema 1、 + 8 个 Float scalar、32 bytes 和 8 个 `0.375`;POINT 基数漂移、byte-length 漂移和隐藏 + `values` 数组三种负例全部阻断。 +- 未计入完成的首次重跑:端口 5447 的原生正例已经运行,但负例在浏览器内用相对动态 import, + 被解析成不存在的 `/protocol/depsgraph.ts`。负例改为 Playwright Node 侧静态协议 import 后, + 换端口 5448 从 unit 到 Chromium 完整重跑通过。 +- M10-01/M10-02/M10-03 回归分别使用端口 5449/5450/5451,Main reader、allowlist 无损阻断和 + 16 nodes/11 cases desktop/full-Main WASM/Chromium golden 全部通过;M10-03 maximum/RMS + position error 仍为 0。 +- `npm --prefix web test`:全量 133/133;typecheck、lint、production build(73 modules)、 + `check:local-deps`、`git diff --check` 全部退出 0;`npm --prefix web run test:depsgraph` 的 + empty/basic/rigged 三 fixture 连续 100 次通过。 +- M10-04 Geometry Nodes protocol/Depsgraph protocol/native/unit/E2E/package/error/WASM SHA-256: + `8d04291c29e9ce30e3f2f6d4141bc4101d49529bf7f7edf109d67d059efa58b1` / + `df7e0e29422f5b605f20fd5ab939d170d35048020968d2f6aea86603d0633cd6` / + `5bcc17416fd4d45985bc1529288127b9eeed4a30b47836e687dc5321182c0b11` / + `1aa4df021731d7ba4c4191d56f88c663b223443f78c9fdc1b88c2a43423239c7` / + `95d8380d6a17a5b85df695a3cb850dffa8705f574ae0b9c97422bb802899eb8e` / + `26fc36cc7fde0beb14dc70430cba7ddd748f5e488d58962266a42f85d6e5912a` / + `b98391bd0007d23391a9c0c884350aa433406d4c9a56097e6c02b40c49c95937` / + `1bb4a947da4332471f3d7bab8f3f628c83d1703244093a554a188668ddafafd7`。 +- `docs/status/M10-04.md`、主计划、项目状态、N-012 和专项细分已同步为 M10 `4/15`。 + 本任务只关闭预算/transport/回执一致性,不宣称任意 domain 实际求值、完整 Blender + lazy-function、图写回或 Simulation Zone。后续领取点:机器队列最新 `nextTask`,本文不缓存任务名称。 + +M10-05 实际验证(2026-08-16 America/New_York) +- 将 `SimulationCacheManifestIR` 升为 schema 2,增加 committed `sourceRevision` 与可复算 + 的完整 `revisionHash`。hash 使用带 v2 domain marker 的 JSON-array canonical encoding,绑定 + graph ID/hash、source blend SHA-256、source revision、input hash、Blender version 和 frame + range;cache key 从三个 hash 前 16 位改为 `sim2-` 加完整 64 位 revision hash,避免截断碰撞。 +- manifest/frame 使用 exact-key 校验;legacy schema、未知字段、invalid frame/revision、graph/ + source/input/range 漂移在 payload 处理前稳定阻断。`verifySimulationCache` 先验 revision + identity,再复验总 payload SHA-256 与逐帧 SHA-256。 +- Storage Worker put/full read/frame read/list 全部进入 per-project lock。put 先核对当前 committed + revision/hash,再计算和写入 payload;read/frame 再验证 manifest identity 与当前项目;list + 校验所有 manifest,仅返回当前 revision/hash,旧 revision 保留在存储但不进入当前列表,显式 + 读取返回 `SIMULATION_CACHE_REVISION_MISMATCH`。 +- `WEB_TEST_PORT=5455 npm --prefix web run test:simulation-cache-identity`:unit 3/3、真实 + Chromium 1/1。revision 7 cache Worker 重启后精确恢复;保存到 revision 8 后旧 key 读取被拒绝、 + 列表为空,新 revision key 可写入并列出。负例覆盖 forged graph/source、stale read/put、input/ + revision/frame range drift、legacy schema、未知字段和 frame payload drift。 +- `WEB_TEST_PORT=5456 npm --prefix web run test:simulation-cache`:identity + 既有 Worker restart + smoke 2/2;`WEB_TEST_PORT=5457 npm --prefix web run test:simulation-cache-performance`:600/600 + frame、OPFS、600 published、pending=0、总耗时 5,072 ms,低于 30,000 ms。 +- 首次全量静态门唯一失败是 read-path 重构后遗留未使用 `parseSimulationCacheManifest` import; + 删除后 lint、typecheck、production build(73 modules)、local deps、git diff --check 均通过。 +- `npm --prefix web test`:全量 unit 136/136;M10-04、M10-01/02/03 既有回归未受影响。 +- M10-05 simulation protocol/worker/unit/identity E2E/smoke/performance/package/error SHA-256: + `974b006f1ad4cd1d3d294122fc777c89c3a7da04fdaa7aebedea12b545bc65e5` / + `a7b31eeb601e9e71c74eaceda95704f1d120653e9ade6bfbee6d262a42edaa74` / + `591582a4ce77b699a4462e30d587eb89034f29ec0a22a94c81f8ab7306f2524f` / + `2446060a9c3955ef466adb479966bf424fd9e9c351b65cd8525e90a1a1032e4d` / + `0c8c001290c8c6b3482eeebc105b2f217c55b2eded0c62b4fee4595ec277a11d` / + `f6c465b7afa012030dd8516d676e2663f4dd8298aa156defb905b3fa0229a84b` / + `f9e28fb23af28720f56f97b249cb636599ebd92955ab2ff6d234b821e6d5ea44` / + `4c6045b1cab8348f0acf180a1e618a0d691ddc5092822ca288f974904e5e13a5`。 +- `docs/status/M10-05.md`、主计划、项目状态、N-012 和专项细分已同步为 M10 `5/15`。 + 该任务只关闭 graph/source/revision identity;M10-06 的取消/LRU/重启损坏隔离与播放能力仍未领取。 + 后续领取点:机器队列最新 `nextTask`,本文不缓存任务名称。 + +M10-06 实际验证(2026-08-16 America/New_York) +- 新增 Worker 生命周期级播放准入:新 Storage Worker 必须先通过 + `prepareSimulationCachePlayback` 完整复验 manifest revision binding、总 payload SHA-256 和 + 每帧 SHA-256,逐帧读取才从 `SIMULATION_CACHE_NOT_READY` 切换为可用;`release` 同时撤销 + readiness 和 active playback 保护。完整 cache read 也会建立 verified readiness,以兼容既有调用方。 +- StorageClient 的 cache put/full read/frame read/prepare 接受 `AbortSignal`;取消会立即移除 pending、 + 向 Worker 发送 `cancelRequest`,Worker 在总 hash 和逐帧 hash 间检查取消。真实 + `BrowserTransformCachePlaybackSession.cancel()` 返回 `CANCELLED`、应用/发布 0 帧、pending=0; + 取消 cache 写入不发布 manifest,已写 content asset 在无引用时清理。 +- 新增确定性 `planSimulationCacheLRU` 与 `pruneSimulationCaches`:排序键固定为 + `lastAccessAt -> createdAt -> cacheKey`,active playback 和显式 protected key 不可淘汰;若保护项 + 自身超过预算返回 `budgetSatisfied=false`,不破坏活动播放。被淘汰 manifest 的无引用 Simulation + asset metadata 与 OPFS bytes 同步删除。 +- IndexedDB 升为 schema 7,新增 `simulation_quarantine`。manifest/key 或 payload 缺失、截断、 + hash 漂移时,坏 row 原子移出 `simulation_manifest` 进入 quarantine,保留诊断证据且不阻断其他 + cache list/playback;专项使用独立临时数据库实测 v6 -> v7 migration record/store。 +- `WEB_TEST_PORT=5464 npm --prefix web run test:simulation-cache-lifecycle`:unit 5/5、真实 Chromium + 1/1,覆盖重启前 NOT_READY、full verify、播放/写入取消、active LRU、release、OPFS 篡改、隔离、 + schema migration 和 0 pending。 +- `WEB_TEST_PORT=5460 npm --prefix web run test:simulation-cache-identity`:unit 5/5、Chromium 1/1; + `WEB_TEST_PORT=5461 npm --prefix web run test:simulation-cache`:identity + 既有重启 smoke 2/2; + `WEB_TEST_PORT=5462 npm --prefix web run test:simulation-cache-performance`:600/600 帧、OPFS、 + 完成/发布 600 帧、6,639 ms、0 pending、Worker 重启恢复,通过 30,000 ms 门。 +- schema/snapshot 定向 smoke 2/2、recent-project recovery 4/4;全量 unit 138/138;typecheck、lint、 + production build(73 modules)、local-dependencies、status-consistency(12 parity BLOCKED、0 release + BLOCKED、17 evidence、0 missing)和 `git diff --check` 全部通过。 +- 未计入完成的首次 Chromium 失败:端口 5458 的所有功能流程已执行,但测试 helper 把 + `DOMException.code=20` 当成业务码,导致预期 `AbortError` 的断言失败;只接受字符串业务码后在 + 5459 全量重跑通过,增加播放取消/migration 断言后又在 5464 从头通过。另两次 `npm --prefix web + exec` 定向命令在测试前因从仓库根解析不到 `playwright.config.ts` 退出;改用 `web/` 工作目录后 + schema/snapshot 2/2 和 recent recovery 4/4 通过,这两次命令错误不计为测试失败证据。 +- M10-06 protocol/storage/error/Worker/client/migration/OPFS/unit/lifecycle/identity/performance/package + SHA-256:`3025db84bd4e0151b8894a457c51da630d628afb1760e2f0caeec003f0efce3f` / + `2711dbab0c52a566679b83d5e4d793ec0ffd3f991e744dce765b6bdc54d25c13` / + `7ebff554dd6346b539215a6545c67806bca2ead33953700b79c0910fd0cfb408` / + `e31d7dcf93c5dcefb7e6c4cfd7874c748f972849ee7d93adb6294f82aafcf236` / + `aaa3a03e3b91452e90a661566407c261c7a16b1671ed55ec948de7626d49bfa4` / + `8de1fcbbda9d7a4e7496995306c4e9fc83224211a516cf7241496b6a145953b5` / + `2e8ef9d72194bf803a7e823fb2b7de30b9f10ae6dd4b031b81b97ff82eee9cc6` / + `4ece0f5051667b04fab78c81a847def7a0c77d9267bd9e4fa2bbe4b9308125bf` / + `861d370f72896987cef65d2e4d78ab3edcc1d4828d4a0fa0b5301b72a4a2932a` / + `aa305871e8651018342b123112beab10a43dab3313e2c9765e450dc91c08b6e1` / + `d7083c24789fc5877a7841bf611aee0ff471cc191621de1b30720d637635b44c` / + `fe90c0bd741a7840f6184726737a1a93c606841b8408dd7fd6dec823a8352cf6`。 +- `docs/status/M10-06.md`、主计划、项目状态、N-012、专项切片和 storage migration 状态已同步为 + M10 `6/15`。Simulation Zone evaluator、浏览器 bake 和 GN modifier seek 仍未声明。后续领取点: + 机器队列最新 `nextTask`,本文不缓存任务名称。 + +M10-07 至 M10-09 实际验证(2026-08-16 America/New_York) +- M10-07 新增 schema 1 有界 Shader compiler,固定 RGB/Value、六项 Math、Image Texture、 + Normal Map、Principled 和 Output allowlist,以及 nodes/links/depth/textures/identifier 预算。 + Engine Worker 在 `setShaderGraph` Main transaction 前失败关闭;主线程与 Offscreen 共用 + `createPBRMaterial` 和同一纹理 binding report。 +- `WEB_TEST_PORT=5521 npm --prefix web run test:shader-compile`:M10-07 unit 5/5、Chromium + 3/3。默认 5173 端口首次被既有进程占用,测试未启动且未计入功能失败;改用独立端口完整通过。 +- M10-08 新增 64 字节十六进制 `compileKey`:对 report schema、graph hash、renderer backend + 和按 usage/image 排序后的 imageId/assetId/asset SHA-256/colorSpace 做确定性 SHA-256。ImageIR + 增加可选 SRGB/NON_COLOR/LINEAR;未知 backend 和非法纹理 digest 结构化阻断。 +- `WEB_TEST_PORT=5523 npm --prefix web run test:shader-compile-key`:unit 7/7、Chromium 1/1; + 纹理 hash 和 color-space 任一变化都会改变 key。端口 5522 首次因测试从 Vite 未暴露的仓库根 + `/protocol` 导入而在编译器执行前失败;改由应用 PBR 入口后完整重跑通过。 +- M10-09 新增 `PBRMaterialPipeline`:失败候选只记录 `shaderCompileFailure` 并释放自身,不替换/ + 释放上一份可用材质;后续成功候选才替换旧材质。`WEB_TEST_PORT=5525 npm --prefix web run + test:shader-pipeline`:Chromium 1/1,验证对象身份、稳定错误码和下一次成功替换。 +- M10-07 回归 `WEB_TEST_PORT=5524 npm --prefix web run test:shader-compile`:unit 7/7、Chromium + 3/3;typecheck、lint、production build(74 modules)、status-consistency(12 parity BLOCKED、 + 0 release BLOCKED、17 evidence、0 missing)和 `git diff --check` 通过。 +- M10-08 compiler/SceneIR/render-assets/Engine Worker/PBR/unit/E2E/golden SHA-256: + `acd5f8deb2912f6823533df12baba8049c5e64d440c17a6dc3f90741f71774b2` / + `a65ef3b4be5303b1b86b3ee6829c795870dcc2490ba58d3a66b8bed8172d6442` / + `840c09a6c2ec56cc75a9ca72bcbdde6957612a7c61b19ab83be47c571eca8b67` / + `668728f7e5b6edf9e1cee3e6152238d16ac19f43f030e94e77bfe24e7ff50489` / + `8a9cb52c8e7b1481096017cc659d93541c21724c1203d22755e37e1e3c365250` / + `9ae9bb4c778d9e31a607d0c7d678ecf3448ed4e5b57c88ab4ba767a2f0850cc7` / + `a09ebfa451e76c33f421bc8402fac4c77faedee88020431a505602f317b4eb65` / + `187666d9dfb9d9cdd5bd6195cc2f7ee57bb78fa0d05d6020762c06a4ab015984`。 +- M10-09 pipeline E2E/golden SHA-256: + `e6771f672e885250db199cbf97a0a3866084ab9bb3f2ad25d17f5c53edaa99af` / + `5dbb916e512935a76dacde37242b123707897f54ab10d7b99e246f57afbc94ac`。 +- `docs/status/M10-07.md` 至 `M10-09.md`、主计划、项目状态、N-013 和专项切片已同步为 + M10 `9/15`。global protocol schema 保持 1;当前领取 M10-10,任意 Shader 节点仍未声明。 + +M10-10 实际验证(2026-08-16 America/New_York) +- `RenderCapabilityRequest.ARBITRARY_SHADER.nodeTypes` 放宽为任意字符串;gate 对未知类型去重、 + 排序并返回稳定 `PBR-012/ARBITRARY_SHADER` block,错误码 `SHADER_NODE_UNSUPPORTED`、 + recoverable=true。既有 ShaderGraph validator/Worker gate 保留原图并在 Main 前阻断。 +- `WEB_TEST_PORT=5526 npm --prefix web run test:shader-capability`:Chromium 1/1;`VORONOI`、 + `CUSTOM_OSL` 以及反向输入顺序返回完全相同的 task/capability/status/code/message。 +- typecheck、lint 和 `git diff --check` 通过。 +- M10-10 gate/spec/golden/package SHA-256: + `322929e9c7b627a9e79b8a6f11d5e9b0c7c05a5754f82d41c45c1134bbaaf385` / + `6337801f10b9115ba7b53a43152ae69a324b5baa61c8e455cc61505437228cbf` / + `b7182bb57e10d0b302b4c7dc9bc43160721a2c9414b056c86173c3b5c2501739` / + `d6d4dcaf4f5c852f884ba2064da96a8c30519c661bf4a16c59e115742aa37e8a`。 +- `docs/status/M10-10.md`、主计划、项目状态、N-013 和专项切片已同步为 M10 `10/15`; + 当前领取 M10-11,仍不开放任意 Shader、NLA operator 或 Physics solver。 + +M10-11 实际验证(2026-08-16 America/New_York) +- 新增桌面 Blender 5.2 layered Action fixture `tests/files/web/nla_time_mapping_scene.blend`: + `M10_NLA_TimeMapping` 的 `M10 Time Mapping` Track 含 `M10 Scaled Clip`(scene 20..40、 + action 1..11、scale=2)和 `M10 Reverse Repeat Clip`(scene 45..65、action 1..11、 + reverse、repeat=2),均为 `REPLACE`/`NOTHING`。 +- `node tools/web/check-nla-evaluation-golden.mjs`:WASM Main reader/Depsgraph 1 Track、 + 2 Strip、12 帧通过,desktop matrix 最大误差 `0`;每帧读取前后的 NLA/Action JSON 相同。 +- `WEB_TEST_PORT=5527 npm --prefix web run test:nla-evaluation-golden`:Node/WASM checker + 和 Chromium Worker `1/1` 通过。 +- `npm --prefix web run typecheck`、`npm --prefix web run lint`、 + `npm --prefix web run check:local-deps`、`python3 -m py_compile` 和 `git diff --check`:通过。 +- M10-11 文件 SHA-256:fixture generator + `a941ecb850ddd165ede117f6411615c6daed4d6bdaffaeac8228a308b7fbe745`;golden generator + `941a63cc9edbaa0d8dd25025872e3fc44bf64df54d5a9232ad4128e6ac0cd5c5`;checker + `2ce9db0fcbceaf30e398e8feb5c002a60395ac34e61382d9414e980e51e6fdd5`;fixture + `b32f783debe213a1345ec4528e5ed27f0f4de00d7896b626df39756289119855`;golden + `2d148ac0cbc2ac9281d430da20dba8373ad1bb0de6ec5fa578adf36296455fbe`;Chromium spec + `c12f105d206a03dae5b73b076cc3b0a758d597cea7532b6720f4653568df0245`。 +- `docs/status/M10-11.md`、`docs/status/N-014.md`、N-014 专项计划、主计划和项目状态已 + 同步为 M10 `11/15`;当前领取点继续只读取机器队列最新 `nextTask`,不把 M10-12 operator + transaction 提前标为完成。 + +M10-12 实际验证(2026-08-16 America/New_York) +- `moveNLAStrip` 要求精确 `baseRevision`,仅移动一个已有 Action Clip,保留原 duration 和 + time mapping;纯 helper 对 source stack 做 structured clone,拒绝空身份、未知 track/strip、 + overlap、非有限值和超过 +/-1e6 frame budget 的输入。 +- Engine Worker 校验 owner/revision 后把候选栈转换为 native `setNLAStack`,由现有 Main + transaction 统一完成 revision、history、undo/redo、save/reopen;stale command 在 Main + mutation 前返回 `REVISION_CONFLICT`。 +- `node --test web/tests/unit/nla.test.mjs`:2/2;`WEB_TEST_PORT=5528 npm --prefix web run + test:nla-operator`:unit 2/2、Chromium 1/1。浏览器覆盖 stale revision、20..40 -> 5..25、 + revision/delta、undo/redo、save/reopen 和 frame-10 native evaluation X=2.5。 +- `npm --prefix web run test:authoring-roundtrip`、`npm --prefix web run typecheck`、 + `npm --prefix web run lint`、`npm --prefix web run test:status-consistency` 和 + `git diff --check`:通过。 +- M10-12 文件 SHA-256:`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`。 +- `docs/status/M10-12.md`、主计划、项目状态、N-014/N-014-B2 和交接文档已同步为 + M10 `12/15`;M10-13 继续按机器队列领取,本文不缓存任务名。 + +M10-13 实际验证(2026-08-16 America/New_York) +- `probePhysicsSolverCapabilities` 按固定七 family 顺序检查 runtime export、初始化结果、 + SINGLE/PTHREAD 要求和 2 GiB 内的可用内存;异常、非法环境、NaN/越界结果均 fail-closed。 +- 只有四门全过的单个 family 才得到 `LOCAL_SOLVER/READY`;其余 family 独立返回明确 probe + 原因并路由 `DESKTOP_SERVER_BAKE`。生产 inventory 未安装 solver adapter,因此七类默认均为 + `EXPORT_UNAVAILABLE`,合成 RIGID_BODY 正例只验证门,不计作真实 solver。 +- `node --test web/tests/unit/physics-solver-probe.test.mjs`:3/3;`WEB_TEST_PORT=5530 npm + --prefix web run test:physics-solver-probe`:unit 3/3、Chromium Worker 1/1。 +- `WEB_TEST_PORT=5531 npm --prefix web run test:e2e -- --grep "N-018 physics"`:Chromium 1/1; + `npm --prefix web run test:physics-main-reader`:CLOTH/SOFT_BODY Main reader、save/reopen 通过, + playback/solver 继续 BLOCKED。 +- typecheck、lint、production build(74 modules)和 `git diff --check` 通过。 +- M10-13 文件 SHA-256:protocol + `adc57a12c7f57c6b1d5f99590d3c36cefa83636aca86c780c2520f3f41148981`;unit + `e499d55770d2024c75803ce1acc1c234814c027279f2b40f42195fd480977653`;Worker + `fbad383de186a659440da42dcad988310ca2e483db27e30d74342c8f57a0f49a`;Chromium spec + `1419b98d68da9b0ab0bf41b37843442741593655e2693159d84e069f1dec3f09`;golden + `bcc8f9094e2b0d8f41ef01fda0daa2a29693d188e602447d6d06358aaa751df4`;package + `875162709315e2a8116c5d28023576a4b95fb076b79453de8b21acdbff2df9dc`。 +- `docs/status/M10-13.md`、`docs/status/N-018.md`、主计划、项目状态和交接文档已同步为 + M10 `13/15`;下一领取点继续只读取机器队列最新 `nextTask`。 + +M10-14 实际验证(2026-08-16 America/New_York) +- Physics cache schema 1 对七 family 分别绑定 cache family、desktop/server bake 来源、Blender + 5.2、source blend/settings/input/cache SHA-256、frameStart/frameEnd、总 byteLength,以及每帧 + byteOffset/byteLength/SHA-256;`COMPLETE` 强制全帧,`PARTIAL` 仍要求 frame 有序且 bytes 连续。 +- `verifyPhysicsCachePayload` 在消费前复算当前 source blend、完整 payload 和逐帧 SHA-256; + source drift 返回 `PHYSICS_CACHE_SOURCE_MISMATCH`,payload/frame drift 返回 + `PHYSICS_CACHE_HASH_MISMATCH`,version/budget/range/unknown field 均稳定失败关闭。 +- `node --test web/tests/unit/physics-cache-family.test.mjs`:3/3;`WEB_TEST_PORT=5533 npm + --prefix web run test:physics-cache-family`:unit 3/3、Chromium Worker 1/1。 +- M10-13 probe 回归 unit 3/3 + Chromium 1/1;Physics Main reader 通过;M10-05 Simulation cache + identity 回归 unit 5/5 + Chromium 1/1;typecheck、lint、production build(74 modules)和 + `git diff --check` 通过。 +- M10-14 文件 SHA-256:error protocol + `6972a144bdc61c2a4270c88df2d86c75640bb5f24b5ac3b7f9a02d69d3c500fd`;Physics protocol + `b02b820503d79cd26b0323a4d2033e79d0eeb3032befd26fafe5eab55634584c`;existing test Worker + `453c6e9e683b9da0ef7ce4bd0e4e430b97102d80ddb9b14cc9d075319b37d818`;unit + `e1c6f07ec58ce4c4c987bd8a36f270497ae3a0f521d6ed27d7f199debeeaccb2`;cache Worker + `8af676506cc0ff1501816431d92d82ca15316b40095d617ecb664b9608e209e0`;Chromium spec + `6a2788587f6692bf5bf19d76400810fa277a6985d1a6ecc1b8d1adb85c609249`;golden + `b220d1fd73f307213078ce3cbd6212544f97ade84a45d54847f107cef69d302d`;package + `9baeb319d8c740f67f3e7615942c78f920e7a56b0d654a5a8f47d68de7243eb4`。 +- `docs/status/M10-14.md`、`docs/status/N-018.md`、主计划、项目状态和交接文档已同步为 + M10 `14/15`;下一领取点继续只读取机器队列最新 `nextTask`。 + +M10-15 实际验证(2026-08-16 America/New_York) +- 新增四个隔离 Chromium Worker 会话的联合门;每域依次执行有界性能负载、超预算输入、恶意 + graph/manifest,再以同会话小输入证明恢复。OOM 项是 evaluator/payload 分配前的确定性 + budget gate,不扩大为任意浏览器 heap exhaustion 声明。 +- 实测:GN 10 x 512 nodes 为 54 ms(门 5,000 ms);Shader 100 compile 为 17 ms(门 + 5,000 ms);NLA 20 x 256 strips 为 62 ms(门 5,000 ms);Simulation 64 frame hashes 为 + 2 ms(门 10,000 ms)。 +- 超预算/恶意输入稳定码: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`。四域失败后小输入均成功。 +- `WEB_TEST_PORT=5536 npm --prefix web run test:m10-domain-gates`:Node 23/23、Chromium 1/1。 + 首次未带独立端口时 23/23 Node 已通过,但浏览器因既有 5173 进程未启动;修正为 5536 后从头 + 完整重跑通过,未把端口失败计为功能证据。 +- 回归:GN field budget unit 6/6 + Chromium 1/1;Shader compile 7/7 + 3/3;NLA operator + 4/4 + 1/1;Simulation lifecycle 6/6 + 1/1;typecheck、lint、production build(74 modules) + 和 `git diff --check` 通过。 +- M10-15 文件 SHA-256:error protocol + `88119782307cb9a61e131f2d5e2e25127256c1e68a333fc7fc3206158b8a1019`;NLA protocol + `96ae7cf9ca3fadbaf98ca15a38937329f203b0bc4a9c39ef685987e7f55353dd`;Simulation protocol + `a1302e42129420a96416e363cbc857d082d4646655685520c9e81f877ce3d9b8`;NLA unit + `e11aa1f395970613cee5d37a9eb21c3fac3422f0080c75c78eb8777effb77adc`;Simulation unit + `2be82cdda991b47fbf8955cfbf2f92cb1fdc7b7c0debb0c8102d4f35e8fce8e3`;Worker + `bc508e1c81ca6efefd67b5c572f93c27dd71bd602319ae887c50e27f8a309a31`;Chromium spec + `2973a596df8613a9cfdb0bd4d300d95b71a8e818e615278a39fb777db6c5cf03`;golden + `580051434ec6673251bac755cc189939de1f887aad088497e16a7657f3a83963`;package + `092931cebf9cb14814951703af26e2d0e4c5e0295c1ba4cb748bfe4ee49448c2`。 +- `docs/status/M10-15.md`、N-012/N-013/N-014 状态、主计划、项目状态和交接文档已同步为 + M10 `15/15`;下一领取点继续只读取机器队列最新 `nextTask`。 + +M11-01 实际验证(2026-08-16 America/New_York) +- `tests/golden/M11-01/lighting-field-parity.json` 覆盖 CameraIR 20、LightIR 17、WorldIR 14、 + Scene render/color-management 9 个叶字段;每项分别记录 reader/writer/main/Offscreen 状态和 + aggregate parity。Orthographic/Panoramic/Custom、DOF 视觉、radius/area spread/sun angle、World + rotation/visual Mist、AgX/look/gamma/white balance 等缺口保持 PARTIAL/BLOCKED。 +- `tools/web/check-lighting-field-parity.mjs` 使用 TypeScript AST 从 `scene-ir.ts` 实时枚举字段, + 拒绝漏项、重复、非法状态、缺失 evidence 路径、COMPLETE 过度声明和关键阻断项漂移。 +- `npm --prefix web run test:lighting-field-parity`:字段 60/60,24 COMPLETE、17 PARTIAL、 + 19 BLOCKED;真实 Main `lighting-roundtrip` 同命令通过 Camera/DOF、Light、World/Mist、拒绝门、 + undo/redo、save/reopen 和 white-balance integrity gate。 +- `WEB_TEST_PORT=5541 npm --prefix web run test:e2e -- --grep "N-019"`:Chromium 2/2; + typecheck、lint、production build(74 modules)、status-consistency 和 `git diff --check` 通过。 +- M11-01 SHA-256:parity table + `b7fee80b4f2469cf119902c939f9091e640a6f0e1307e97ba29f0afa8942c4ed`;checker + `cf910a087f9af72e0782d62e67341f90fd4af1c6c8eddf54169ee6275e24bd9c`;package + `cd7174b04b19e61a280e03e6c61e2ffd20fd507c0716c820b8faa31ea2438ebf`。 +- `docs/status/M11-01.md`、`docs/status/N-019.md`、主计划、项目状态和交接文档已同步为 + M11 `1/14`;下一领取点继续只读取机器队列最新 `nextTask`。 + +M11-02 实际验证(2026-08-16 America/New_York) +- 复用生产 `setCameraProperties`/`setLightProperties`/`setWorldProperties` Main transaction, + 一个 Chromium 流程逐域执行 edit、undo、redo,保存为 `.blend` 后用全新 Engine Worker 重开。 +- 字段断言覆盖 Camera lens、双 sensor dimension/fit、shift、clip、ortho/DOF;Light color、 + energy/exposure、temperature、shadow、spot/area;World color/exposure/Mist。共享 PBR 映射另核对 + 水平/垂直 FOV、film gauge/offset、温度线性 RGB、强度、shadow、spot/area 和背景色。 +- `WEB_TEST_PORT=5545 npm --prefix web run test:lighting-field-roundtrip`:原生 Main roundtrip + 通过,Chromium 1/1;revision 从打开开始每个 edit/undo/redo 均严格 +1。 +- 未计入完成的前两次专项运行:端口 5542 在 World Float32 color 严格相等处失败;改为递归 + `1e-5` 数值容差后,端口 5543 又在 camera near 的剩余严格标量断言失败。统一所有 Blender + Float32 字段的容差、同时保持结构/字符串/布尔严格比较后,端口 5544 通过;补齐 horizontal + sensor-fit 与 temperature color 视口断言后,端口 5545 从原生到 Chromium 再次完整通过。 +- 全量 unit `156/156`;typecheck、lint、production build(74 modules)和 `git diff --check` + 全部退出 0。 +- M11-02 golden/Chromium/native checker/protocol/Main writer/PBR/package SHA-256: + `ac6d84c0099b12cf6c96df2dbbb4281e3308c36f0bda49c92d7548e58ccc286e` / + `9f75c7b88c70ab42c6c418b0056dcffcab3b8851003258891902db9615fcfe67` / + `023e3e43bc37fb38c3063d33c20e59043308013874fce20bc3a49d508b850e6c` / + `48f2290ead27aeda1f17b279f01ae278776b50b4cb4de065d94b0e1d44717dbb` / + `510b4017c540fc4d1ac135c1ce7251cc5d7143f31d8604e1d545de36716da9a2` / + `78352e46c62d0c8d9bf25408ea1d725a843a42068ea3eb304bb3d78f8d612e0b` / + `8074d80265a1378fb3f284206c343ff1e388a5ea43e57946a1d4092076d72ea8`。 +- `docs/status/M11-02.md`、`docs/status/N-019.md`、主计划、项目状态和交接文档已同步为 + M11 `2/14`;下一领取点继续只读取机器队列最新 `nextTask`。 + +M11-03 实际验证(2026-08-16 America/New_York) +- 新增 schema 1 `PBRRenderBudget`。THREE_WEBGL2 固定总 16 lights、4 x 1024 shadow maps, + 其中内置 viewport lights/shadow 也占 2/1 保留槽;THREE_WEBGPU 固定 64 lights、8 x 2048, + runtime adapter limit 只能向下收紧。纹理上限同时覆盖 256 assets、16K 单边、512 MiB + aggregate payload;WebGL2/WebGPU decoded RGBA 上限分别为 512 MiB/1 GiB。 +- planner 按 SceneIR node 顺序保留确定性 light/shadow 前缀,返回 dropped/blocked stable IDs 和 + `GPU_LIGHT_BUDGET_EXCEEDED`/`GPU_SHADOW_BUDGET_EXCEEDED`。主线程与生产 Offscreen Worker + 都在 canvas 发布同一 report;WebGL2 实际 Three scene 复核为总 16 lights/4 shadows。 +- `GPUTextureStore` 在 hash/decode/GPU allocation 前按现有资源+候选批次计算 aggregate budget; + 257 个候选在两条 viewport 路径均 loaded=0/bytes=0。先加载真实 8x8 PNG 后再提交超限批次, + 原 Texture asset identity/hash 保持不变。 +- `WEB_TEST_PORT=5548 npm --prefix web run test:render-resource-budget`:unit 3/3、Chromium 3/3。 + 20 个 scene lights 中渲染 14、丢弃 6;14 个 scene shadow 请求中渲染 3、阻断 11。 +- 未计入完成的一次重跑:端口 5547 的主线程/Offscreen 预算 2/2 已通过,新增纹理保留 case + 在业务代码前因 Vite 不暴露仓库根 `/protocol/render-assets.ts` 动态导入失败;改为浏览器 + `crypto.subtle` 生成 hash、只导入生产 TextureStore 后在 5548 从 unit 到 Chromium 全量通过。 +- 回归:M11-02 原生+Chromium 1/1;4K texture 64 MiB decoded、8K texture 256 MiB decoded + 均通过;packed texture 主线程/Offscreen 2/2。全量 unit 159/159,typecheck、lint、production + build(75 modules)、local-dependencies 和 `git diff --check` 全部通过。 +- M11-03 protocol/PBR/texture store/main viewport/Offscreen Worker/unit/E2E/golden/package SHA-256: + `d7784690358e38b71c6ad3ccc5baf82869d897714d5257f219bbd00dc7e7b0f7` / + `934874434afa6778eaf783c78650ad221a428352741f3ff7173cb3c17a0c3c14` / + `0f18f3bcf04c5c99175d4cac8bbf1bc028fc54d635701fb6a92791e06c335fb6` / + `dcb502ca892fd5431cbf1a13823aec5d83a2aa0a1b40487cb66fa630c6e461b3` / + `c95bce10f0bd73591f002c21415bfbc6f709ecbe61e97a3b5b05bcec2b0e0bef` / + `4bde90788bbf9a94317d83bd398acd321bbcf4e53e26bf40165a889bacff03a2` / + `90834dfff09e61a2d32732d3a8f0db4b9c71414ac4991412d93d322119ef2de7` / + `c312bf5ed8415ad51e5cccb3a2653f65cd9fee46488be2f860e6a5a8d5a7901d` / + `c718983c3f3a552f4c68d9390376777e45a3f23e6999afd5cb4ddc786a12a278`。 +- `docs/status/M11-03.md`、`docs/status/N-019.md`、主计划、项目状态和交接文档已同步为 + M11 `3/14`;下一领取点继续只读取机器队列最新 `nextTask`。 + +M11-04 实际验证(2026-08-17 America/New_York) +- 新增 `web/protocol/render-image-comparison.ts` schema 1 和 app wrapper。输入固定为等尺寸、 + 有界 RGBA8;颜色空间为 `SRGB8`、alpha 为 `STRAIGHT`。每个报告保留实际值、阈值、比较方向 + 和通过状态,检查 MAE、RMS、P95 channel、max channel、坏像素比例、以 reference corner + median 为背景的前景 IoU,以及 alpha coverage delta;尺寸/字节/阈值/4,194,304 pixel budget + 在计算前拒绝,失败返回 `RENDER_REFERENCE_MISMATCH`。 +- `tools/web/generate-m11-render-reference.py` 由 Blender 5.2 `BLENDER_EEVEE` 生成 + `tests/files/web/m11_render_reference.blend` 和 `tests/golden/M11-04/blender-eevee-reference.png`。 + 固定 256×256、camera orbit 对应位置、黑色 rough/specular=0 material、World color、 + `taa_render_samples=1` 和 `dither_intensity=0`。PNG container bytes 可能因路径元数据变化, + `check-render-reference.mjs` 以 committed PNG SHA-256 锁构件完整性,并让 Blender 逐像素解码 + 复验重渲染像素 max=0。 +- `WEB_TEST_PORT=5552 npm --prefix web run test:render-reference`:unit 3/3、Blender reference + 复验通过、Chromium 3/3(main 1/1、Offscreen 1/1、错误构图负例 1/1)。两条生产路径报告 + 完全一致:MAE `2.0433349609375`、RMS `5.448994264342599`、P95 `4`、max channel `110`、 + bad-pixel ratio `0.0047760009765625`、foreground IoU `0.9853400565736072`、alpha delta `0`。 + 收紧后阈值为 MAE 4、RMS 12、P95 12、坏像素比 0.02、IoU 0.97、alpha delta 0。 +- 回归:`npm --prefix web run typecheck`、`npm --prefix web run lint`、production build(75 + modules)、`npm --prefix web run test:status-consistency`、`npm --prefix web run release:evidence-sync` + 和 `git diff --check` 通过。status consistency 为 12 parity BLOCKED、0 release BLOCKED、 + 17 evidence、0 missing;M11 更新为 `4/14`,完整色彩管理、Cycles/复杂 Eevee、高级 shadow、 + Volume 深度合成和全场景 pixel parity 仍保持 BLOCKED。 +- M11-04 hashes:protocol `4345cc0d02b4482201de34292cbf5198009bf615e69c80cc00bc3e76116efec2`; + app wrapper `ad97fd594d0d964e05d4da2bffc84a64ef737fef700045c252b5222250f1ffc7`;error protocol + `e8ea483466b16debaf803ff1640ade07a858a802781e3040d3547028ecb5d19f`;generator + `118c98c77d98922e72660898bfb65842e11743fc77f4124c9f5088a76cb6a94a`;reference checker + `ead3500f054e98c2db4cd6596c983586a622e929bc8a587e9fbd38d94a7df7d1`;unit + `f9800745760b85700e80abc5e389f44d7f7ea3b9e41e1002f4e5449bcb620f67`;Chromium spec + `349c7da0dfd60baec80139cced52079312bd5e892a42f000ba0b4f55c6a4a61d`;fixture + `d2bea55fe4de0b00e73a9241b4d1b0c802c2eb0e6159c0cd48e007b97b9d3963`;PNG + `f5c22636a232bcbb0ed0f772418cb12fced18ff72f4999804197e42caa61480a`;golden manifest + `e9551f86f0b9704d7be35c03f07321bec5d2481ffee4208ae766156d9c9a0135`;package + `38ba6a722a2e0bc2c91d6edbd2b7df5e3609342e3b7ef2ec9646b9c6ce0d25a6`。 +- `docs/status/M11-04.md`、`docs/status/N-019.md`、`docs/CURRENT_EXECUTION_PLAN.md`、 + `docs/PROJECT_STATUS_AND_NEXT_WORK.md`、parity ledger/release evidence 和本文件均已同步。 + 后续领取点继续只读取机器队列最新 `nextTask`,本文不缓存任务名称。 +- M11-04 台账回归补充:首次全量 unit 为 `161/162`,唯一失败是把 post-V1 的 + `web:test:render-reference` 误加入冻结 V1 acceptance,导致计数从 50 declarations/49 unique + 漂到 51/50;从 ledger 的 V1 acceptance 删除该项、保留 M11/N-019 专项记录并重新同步 evidence + 后,全量 unit `162/162`、status consistency 和 `git diff --check` 全部通过。 + +M11-05 实际验证(2026-08-17 America/New_York) +- 新增 schema 1 `RenderRoutingRequestIR/RenderRoutingResultIR`。`target` 与 endpoint availability + 分离:bounded Eevee + WebGL2 返回 `WEB_LOCAL_BOUNDED/READY`;WebGPU 只有 browser capability + 与 renderer bundle 同时存在才可 READY,否则保持 `WEBGPU_RENDERER_UNAVAILABLE`。 +- `BLENDER_CYCLES`/`CYCLES`、complex Eevee、Workbench final render,以及 CUDA、OptiX、HIP、 + Metal、oneAPI 一律返回 `SERVER_JOB`。生产默认 `serverRenderAvailable=false` 时状态为 BLOCKED、 + code=`SERVER_JOB_UNAVAILABLE`;合成 endpoint context 只把同一 server target 变为 READY,未调用 + submit,也未提前声明 M11-06 的 source/build/settings/output hash。 +- SceneIR `renderEngine` 按有界大写身份串处理;未知但合法的 engine 返回 + `SERVER_JOB/BLOCKED/UNSUPPORTED_RENDER_ENGINE/PLATFORM_CAPABILITY_UNAVAILABLE`,非法 identity + 与 backend 在路由前以 `INVALID_ARGUMENT` 拒绝。 +- `WEB_TEST_PORT=5556 npm --prefix web run test:render-routing`:unit 3/3、Chromium 1/1。浏览器 + 先由生产 WebEngine Worker 打开 `m11_render_reference.blend` 并读取真实 + `renderEngine=BLENDER_EEVEE`,再验证 bounded/Cycles/complex/OptiX/configured-server/WebGPU/ + unknown-engine 全矩阵;机器 golden 为 `tests/golden/M11-05/render-routing.json`。 +- 回归:typecheck、lint、production build(75 modules)、release evidence ledger sync 和 + `git diff --check` 通过。M11 更新为 `5/14`;真实 server job、hash/progress/cancel 和结果消费 + 仍保持 M11-06 阻断。 +- M11-05 hashes:protocol `2d41d48609635c810572aaae95979df2fe9e49bbd130c60d70f64042c5d0f097`; + app wrapper `f360c05fa62aa830a486c5be4747488fcbb5ba9b68d82b4f7187e1267da46fa6`;unit + `4ac2e7a6c2265f948d971f72effb4ff9ad1c51839ab6c06d496fc2b0768c4fa8`;Chromium spec + `e5572d9ad9426f1dcc1856fe426261910093fa2f5460117e4ffdcfc7b23b10a1`;golden + `53c5d3a2e51f251d14953594567081597be4250be5b1236832370e74805bd35d`;package + `716a5331990618892bca97f48c7eef589a5af6c5131b8cf3ae52a1b9d923644c`;parity ledger + `4ce36890f5772c2a22338dd4fda1c33d3241a5b328b25c5455c28f4f74f1964c`;release evidence + `8887db65eeab9777c9313b001e70c5cd4f65e8b6d36411ece8bb6fd2ccf062d2`。 +- `docs/status/M11-05.md`、N-019、主计划、项目状态、parity ledger/release evidence 和本文件 + 已同步;下一领取点继续只读取机器队列最新 `nextTask`,本文不缓存任务名称。 + +M11-06 实际验证(2026-08-17 America/New_York) +- 新增 schema 1 `ServerRenderJobRequestIR/ServerRenderJobResultIR`:source `.blend` SHA-256、 + byte length、Main revision、Blender 5.2 version/build SHA-256、严格 settings allowlist/hash 和 + request hash 构成提交 identity;成功结果必须回显同一 identity,并增加 output MIME/bytes/ + SHA-256 和 result hash。schema 1 每份输出只允许一个 still frame,settings 只接受 engine、 + frame、resolution、percentage、samples、PNG/OpenEXR 和 transparent,不接受“哈希但不执行”的 + 未声明字段。 +- `npm --prefix web run test:server-render-job`:unit `3/3`;loopback HTTP server 用真实 + `build_blender_5.2.0/bin/blender` 5.2.0 headless 打开上传的 M11 fixture,并显式应用上述 + settings 后渲染 PNG。成功报告 source + `d2bea55fe4de0b00e73a9241b4d1b0c802c2eb0e6159c0cd48e007b97b9d3963`、settings + `97bb832618bd27e8763722afa0d250ef74383fb355f0ff38763930f57ba1590b`、本轮 output + `564b95cd501da2636085aeada88814c4a3b7af7b1f0188543a1863fe34dd091e`、3915 bytes;source、 + build、settings、output 四类篡改均返回对应稳定 code。 +- M11-06 hashes:protocol `e2b5b95032b0dbcb346aa35d1a10a3b55d6c006603f16b9e72c3de45763906c5`; + errors `747ea52fd96b3ec7ede9ce72c15f56fdc454836ba5bf924cc60198096d4986de`;unit + `c84f86365006360f146601839d7e1394f3f7bc7fa71dcbf62d8d4e4321cdd16d`;server checker + `6dd49fbd1be202a88fa714b2fb81305018da6df5f9d73c95e048e3697742764e`;Blender driver + `48950b0884a52a40ae234f8bbe590a0cb89e58c4d2a2de40ea638f491d111aff`;golden + `36f083f8da7585f850e748e6ac78fc57cd6e8d59bce03d77eee3acd2fa042ae7`;package + `628191a76c7614393a74969bd000faa8a6302d887265d13a1ad49dcfc401c2fc`;parity ledger + `08a2db12253a06d7c9e188032ef4f5eeaad671698cd8244f3050d13377a3939e`;release evidence + `7f514fee95de3d38634c54c7abf342ad44686a9ed5fabe658d04a538cf136c1b`。 +- 回归:typecheck、lint、全量 Node `168/168`、status consistency(12 parity blocked、0 release + blocked)、release evidence(17 records、0 missing)、production build(75 modules)和 + `git diff --check` 全部通过;V1 acceptance 未加入 post-V1 M11 专项,冻结计数保持不变。 +- `docs/status/M11-06.md`、N-019、主计划、项目状态、parity ledger/release evidence 和本文件 + 已同步;M11 更新为 `6/14`。真实远程队列、进度、取消、Freestyle/denoise 以及 M11-07 + compositor allowlist 均未提前声明。下一领取点继续只读取机器队列最新 `nextTask`。 + +M11-07 实际验证(2026-08-17 America/New_York) +- 领取 M11-07:Compositor allowlist 每新增一个 node 单独增加 CPU/WebGPU golden。生产 + `web/protocol/compositor.ts` 新增 schema 1 WebGPU plan,只允许 + `CONSTANT_COLOR`、`EXPOSURE`、`INVERT`、`COMPOSITE` 的无资源、单输入线性 sRGB Float32 + 链;`CompositorWebGPU.ts` 使用真实 Chromium WebGPU compute 执行同一 plan,其他节点、 + 资源输入、断链和错误 socket 在 shader 编译前返回 `COMPOSITOR_NODE_UNSUPPORTED` 或 + `COMPOSITOR_GRAPH_INVALID`。 +- Blender 5.2 生成的 `m11_compositor_allowlist.blend` 含 Constant、Exposure、Invert、Chain + 四个独立场景。Main/WASM reader 读取真实 graph 后,CPU executor 与 Chromium WebGPU + 对 2x2 `LINEAR_SRGB` RGBA 输出逐字节一致;四例最大绝对误差 `0`,CPU/WebGPU golden + hash 均匹配 committed manifest。 +- `WEB_TEST_PORT=5562 npm --prefix web run test:compositor-node-golden`:Node `3/3`、 + Blender 5.2/native golden `1/1`、Chromium WebGPU `1/1`。首次未设置独立端口的运行只在 + 5173 占用处停止,Node/native 已通过但未计入;端口 5562 从头重跑通过。 +- 回归:`npm --prefix web test` 全量 Node `171/171`;typecheck、lint、production build + (75 modules)、`test:status-consistency`(12 parity blocked、0 release blocked、17 + evidence、0 missing)、`test:release-evidence`(READY)和 `git diff --check` 全部通过。 +- M11-07 hashes:compositor protocol + `e35fbfafd67e3026c0dba1ffa1d5ca27c5c659e7baa8a577059d23b2779e2dca`;CPU adapter + `dfa5be2b01f964ec39c939928aa7b29d30fd88cb3f3705508166983f50442950`;WebGPU adapter + `8db2ea4764941c7ab08eca1ab0027d267bb085f4820beb424468506d9ddd1332`;unit + `3e5ea2ed500a002fa7acd656ef902d190368f7972d97dffcbecfda1a6864d093`;Chromium spec + `ee16413f049e55cd3de3b4f4bb7c133f401ca600eeef8f01ed2a0ccf47740daf`;native checker + `668d8ef946df787a2c3d3eee814c71655c8cc6e35976f2e21c6750d5abdaf32`;fixture generator + `1389998aac849065311c85ef559a7d381274c0ebe1987afc3b6783c66a9a678e`;fixture golden + `fcdd0e875ff799d3c8b7ee34a63b5c88694ef6af3a19fed5d1c51b5330c60c95`;M11-07 status + `103291f56e3623825f83de8813348dc437b400eb743b3644715d29f35a7851c4`;package + `5529339c7d876c6ed01cc89e2d2d19449a69f6b96e987e4368474d1903cc6189`;N-020 + `8c5364401ec33ed25b335773e73e35174ebdd3c0d2af5d8f60006eec490406bc`;parity ledger + `06b7b1daccdf55989e580fc1f97867a1c7e66142084d1f659db6ccf4927c183d`;release evidence + `fe80fada4b0fb6e4d507750ba866c0a8d76b522bcc6c830bf97b834c2913a2b4`。 +- `docs/status/M11-07.md`、N-020、主计划、项目状态、parity ledger/release evidence 和本文件 + 已同步;M11 更新为 `7/14`。不把 M11-08 unsupported graph preservation、通用 WebGPU、HDR、 + 资源/分支 compositor 或服务端执行提前声明。下一领取点继续只读取机器队列最新 `nextTask`。 + +M11-08 实际验证(2026-08-17 America/New_York) +- 领取 M11-08 后复核发现:Main reader 已保留 Unsupported Glare,capability gate 也返回 + `COMPOSITOR_NODE_UNSUPPORTED`,但 CPU executor 会忽略未连接到 Composite 的 Unsupported + 节点继续出图,cached 入口还可能在执行 gate 前命中旧结果。现已将排序/去重的完整 graph + Unsupported 预检放到 CPU 求值和 cache lookup 之前,两条入口都 fail-closed。 +- 专项 unit 证明 Unsupported 失败发生在 cancellation callback/像素分配之前,并预置同 key + cache 后确认仍被阻断;输入 graph 深比较不变。真实 Blender 5.2 `compositor_scene.blend` + 经 Main/WASM reader 后,Glare name、`UNSUPPORTED` type、`CompositorNodeGlare`、完整 graph + JSON 和 Main revision 在 gate/CPU/cached 三次阻断前后均不变。 +- `WEB_TEST_PORT=5565 npm --prefix web run test:compositor-unsupported-gate`:unit `2/2`、 + native Main reader 通过、Chromium `1/1`。`WEB_TEST_PORT=5566 npm --prefix web run test:e2e + -- --grep "N-020"`:支持图 CPU 执行与真实 Unsupported Main graph 阻断 `2/2`。 +- 第一次新增 unit 全量为 `172/173`:cached API 是 async,但测试误用同步 `assert.throws`,实际 + rejection code 已是预期值但落在测试结束后。改为 `await assert.rejects` 后专项与全量从头 + 重跑,最终 Node `173/173`;该首次失败不计完成证据。 +- 回归:typecheck、lint、production build(75 modules)、status consistency(12 parity + blocked、0 release blocked、17 evidence、0 missing)、release evidence(READY)和 + `git diff --check` 全部通过;冻结 V1 acceptance 未加入 post-V1 M11-08 专项。 +- M11-08 hashes:compositor protocol + `4a977cf9e39be1aa75ff14244660589a556039a05550a029ff2c0628a9357b6f`;unit + `ab87a04f15f408a2f605b35bc72e67537f2954bee3543ea767f9b2ac04ffdf84`;Chromium spec + `c5e4097afb010235f6a245fd8fbe92b5d50eed5ff752ac802fd031f3ffc3adbd`;N-020 smoke + `c525c50eb09e3307675d92c59b76b466fba1d736ea2f2ec486ca5467e2777bc6`;golden + `6a29952726ff883933d42f069be9591bce7499029ffd1abde17dac21adce831b`;package + `fddbbf2158b39cafae0bc5fdec02e5b43737278dce52a5a04e25082802926051`;M11-08 status + `60f5b83aecff41cc1d54e5e8e9089a2ec1dac96ef9a0f47d1f65d0a148e0dfa7`;N-020 + `ff0d330abda09f83993ad4669c89ca62cd83eab6170a2778390f552f58b0978e`;parity ledger + `5ea3e379b1ff48659e487ccd65980929cd673c7efa0cae96a2cccc2d44351d17`;release evidence + `c43b4d2779956494eb0ebc0b68f91b8b60c5265c977de4c0b85d239f1520819b`。 +- `docs/status/M11-08.md`、N-020、主计划、项目状态、parity ledger/release evidence 和本文件 + 已同步;M11 更新为 `8/14`。通用 WebGPU、资源/分支节点、HDR/色彩管理、服务端 Compositor + 与 M11-09 media codec probe 均未提前声明。下一领取点继续只读取机器队列最新 `nextTask`。 + +M11-10 实际验证(2026-08-17 America/New_York) +- 领取 M11-10:新增 schema 1 `SequencerMediaCacheManifestIR`。cache identity 对完整 MOVIE + source family/MIME/byte length/SHA-256、source-bound READY decode receipt、规范化 RGBA8 + profile 和 source frame 做确定性 SHA-256;`decoded` 字段按 family 显式排序,JSON 属性顺序 + 不影响 identity。proxy payload 另绑定 byte length/SHA-256。 +- 生产 `SequencerMediaProxyCache` 在任何 HTMLMedia decode 前复算 source bytes hash 并检查 + receipt/source gate;只生成初始 decoded frame 的 SRGB8/STRAIGHT RGBA8 proxy,不提前声明 + M11-11 精确 seek/revision gate。profile 不得超过实际 decoded dimensions,单帧和总 LRU budget + 上限均为 64 MiB。 +- `WEB_TEST_PORT=5573 npm --prefix web run test:sequencer-media-cache`:unit `3/3`、Chromium + `1/1`。真实 16x16 H.264 fixture 生成 8x8/256-byte proxy,identity 固定为 + `68a3af14865841e81f69bd75f2605461de4819fe025d158ac3723fa0cdf31525`;一帧预算下 frame 1 + 确定性淘汰 frame 0,`clear()` 释放 256 bytes 后 entries/bytes 均为 0。 +- Chromium 将 proxy payload 与 manifest 分别通过 Storage Worker 写入 content-addressed + OPFS/IndexedDB asset,终止 writer 后由新 Storage Worker 读取并按当前 runtime receipt 复验。 + source drift、READY receipt metadata drift、payload mutation 和 bad source bytes 分别返回 + `SEQUENCER_CACHE_SOURCE_MISMATCH`、`SEQUENCER_CACHE_CAPABILITY_MISMATCH` 或 + `SEQUENCER_CACHE_HASH_MISMATCH`。 +- 两次未计入完成的首轮问题均只在测试:unit 首轮错误地把恰好 64 MiB 当作超限,修正为 + 64 MiB 加一行后通过;Chromium 首轮在调用 `clear()` 后才采集 `statsBeforeClear`,调整采样 + 顺序后端口 5572/5573 均从头通过。 +- 回归:M11-09 codec probe unit `3/3` + Chromium `1/1`;100 万帧 long-media `1/1`, + 10,001 strips/11,914 references、71 次 LRU 淘汰、双 Worker 重开和固定 session hash 通过; + Sequencer Main reader 通过;全量 Node `179/179`;typecheck、lint、production build(75 + modules)、status consistency(12 parity blocked、0 release blocked、17 evidence、0 missing)、 + release evidence(READY)和 `git diff --check` 全部通过。 +- M11-10 hashes:protocol + `1dd5ee8cade2642723bac6f0c8e34d09cf178d000beba6eb63626284ee35fd5e`;browser adapter + `57070b1a42129afdfb8121ec11a48b0792b3de9d84f7e8a78017c7d6e12343aa`;app exports + `8ca82257c758580f5fe91a3a5cd21f8ae9e9ce10f9fa71fcd7cf1f30e0b6367b`;unit + `f4cc1171dd6875bf079e242beed846409a1d1f53b9497882aa475bd2773b43c7`;Chromium spec + `6d9d26e3639a74b173f77ec5d67e53e5ec42b88dd6e34bda54c8168b8251423e`;golden + `39a71f8d97f0045da6ec913b6a0231ac3363f67a420eea5ab3fabaa8f1d47c33`;package + `2c7fb9f8657155088e4a080443b8a0ba9c13aae9f46f1807ff3b8820640c44c4`;M11-10 status + `0569c1881a74522ae7fd83e8d9e6b2f21e6e8b0b507ca1569c7b1a9a2245a02d`;parity ledger + `b41f56a687bb95ca584a263ac7fc42111930fc63bc6ee780ef11c2bf3056aeb1`;release evidence + `8cb240e06078e5ce49a345b4ea68b1d9eb543040acd2da5caef0fe4bc6a56fca`。 +- `docs/status/M11-10.md`、N-021、主计划、项目状态、parity ledger/release evidence 和本文件 + 已同步;M11 更新为 `10/14`,machine slice 计数为 188 completed/58 blocked。精确 seek/scrub + revision gate、多帧 proxy、waveform、A/V sync、设备恢复和最终编码均未提前声明。下一领取点 + 继续只读取机器队列最新 `nextTask`,本文不缓存任务名称。 + +M11-11 实际验证(2026-08-17 America/New_York) +- 领取 M11-11:新增 schema 1 `SequencerMediaRevisionRequestIR/ResultIR/StateIR`,SEEK、SCRUB、 + DECODE 三类操作共享 timeline ID/revision 与单调 request revision;completed result 必须逐字段 + 回显 request identity,并增加有界 source frame 与 payload SHA-256。未知字段、非法 operation、 + 小数 revision 和非法 hash 在 revision gate 前拒绝。 +- 生产 `SequencerMediaRevisionGate` 的每次 `begin` 先推进 request revision;timeline replacement + 再推进一次 revision token 以使全部 pending request 失效,同 timeline revision 不允许倒退或 + 重复。`resolve` 只有在纯 gate 返回 `PUBLISH` 后才调用 callback,因此 stale result 不会更新 + visible frame 或写 decode cache。 +- `WEB_TEST_PORT=5576 npm --prefix web run test:sequencer-media-revision` 首次完整运行即通过: + unit `4/4`、Chromium `1/1`。浏览器让 SEEK@1 晚于 SCRUB@2 返回,只有 SCRUB 发布;真实 H.264 + DECODE@3 在 timeline 7->8 换代后完成并被阻断;当前 DECODE@5 发布并成为唯一 cache write; + requestId 伪造的 SEEK@6 同样为 `STALE/REVISION_CONFLICT`。 +- machine golden 固定五项决策和副作用:`SCRUB@2 PUBLISH`、`SEEK@1 STALE`、 + `DECODE@3 STALE`、`DECODE@5 PUBLISH`、`SEEK@6 STALE`;published 仅 + `[SCRUB@2,DECODE@5]`,cacheWrites 仅 `[DECODE@5]`。该任务关闭的是结果发布竞态门,不把 + synthetic SEEK/SCRUB completion 扩写为帧精确 WebCodecs seek。 +- 回归:M11-10 unit `3/3` + Chromium `1/1`;M11-09 unit `3/3` + Chromium `1/1`;100 万帧 + long-media `1/1`;全量 Node `183/183`;typecheck、lint、production build(75 modules)、 + status consistency(12 parity blocked、0 release blocked、17 evidence、0 missing)、release + evidence(READY)和 `git diff --check` 全部通过。 +- M11-11 hashes:protocol + `326f1525f50af3b2c4132ff58508ed6174ba3aa4a5075b2a6660e549c280826a`;production controller + `29a44404bb28e46d67439eec0622a69ce4cd7264ce45da77a3f320c8687b7006`;app exports + `be2eca8482a60e437878b99f69d3178f47fee61618886d141760320e3a3ed1e1`;unit + `2099f738120897dfdfbb8662741831774b5f9150d51924dfa7832907177de5e7`;Chromium spec + `e47f105360ce0e2d4d561d3cf790ceec9f41dd6bac7361965914ada307db843c`;golden + `672b4fde6e27f8b15cd51d839cacb5efb128c0d0f4c7f6aa4e184c713f9b45e5`;package + `5d17b17d67ee587191552c3dfe226cd8f2e5a872791224d8e6c22aafa1177ebe`;M11-11 status + `bc246287b4a97735c9e19642029257ff83498c262490c289ea6791bdec0c038b`;parity ledger + `e5d5078364184def026573cb0645b566770dc03412246de27fc493971ddcdba0`;release evidence + `81ac36f001f09567fe3f837ed31f9b0c601939962162fbdfb192494a1ac89d77`。 +- `docs/status/M11-11.md`、N-021、主计划、项目状态、parity ledger/release evidence 和本文件 + 已同步;M11 更新为 `11/14`,machine slice 计数为 189 completed/58 blocked。帧精确 + WebCodecs seek、多帧 proxy、最终编码、音频设备恢复和跨域 fault lifecycle 均未提前声明。 + 下一领取点继续只读取机器队列最新 `nextTask`,本文不缓存任务名称。 + +M11-12 实际验证(2026-08-17 America/New_York) +- 领取 M11-12:新增 schema 1 `SequencerFinalExportRequestIR/RouteIR`,将 Main timeline + ID/revision、source `.blend` SHA-256、帧范围/有理帧率、分辨率和声明的 container/video/audio + codec 绑定为 settings/request 两级 SHA-256。帧范围与 Blender/server render 合同一致支持 + -1,000,000..1,000,000,但总帧数最多 1,000,000;非法组合、未知字段和超预算输入在路由前拒绝。 +- 生产路由唯一结果为 `SERVER_EXPORT`。endpoint 缺失返回 + `BLOCKED/SEQUENCER_EXPORT_SERVER_UNAVAILABLE`;endpoint 存在只返回 + `SERVER_EXPORT_REQUIRED`。浏览器 `VideoEncoder` 仅记录探测事实,存在与否都不能改变 route, + `localEncoding` 始终为 `BLOCKED`;真实 server encode、上传/进度/取消、混音和输出验证未提前声明。 +- `WEB_TEST_PORT=5579 npm --prefix web run test:sequencer-final-export` 首次完整运行通过:unit + `4/4`、Chromium `1/1`。浏览器通过 WebEngine Main 打开真实 Blender 5.2 + `sequencer_scene.blend`,使用 timeline `sequencer:scene:SequencerScene` revision 1、frames + 1..250、24000/1001 FPS 建立请求;无 server、有 server/无 encoder、有 server/注入 encoder + 和实际 Chromium scope 四种路径均保持本地编码阻断。 +- machine golden 固定 source blend + `5f5212487bb6b5df62b5ca915c75133f1ca45678614712ea4c7d902d823cd90c`、settings + `01e39d1fdd88c75aecad7bc48a2b756bbcd645929963528b42ceb180b51e5566` 和 request + `20d3a9e333804e014a993a9892a557842293fc44902229dd6eb65661f0691cfc`。 +- 回归:M11-09 unit `3/3` + Chromium `1/1`;M11-10 unit `3/3` + Chromium `1/1`;M11-11 + unit `4/4` + Chromium `1/1`;100 万帧 long-media `1/1`;Sequencer Main reader;全量 Node + `187/187`;typecheck、lint、production build(75 modules)、status consistency(12 parity + blocked、0 release blocked、17 evidence、0 missing)、release evidence(READY)和 + `git diff --check` 全部通过。 +- M11-12 hashes:protocol + `559516163ce9a9c60917033455c71a1ae6be2f34666371f12723a056ddfdb3dd`;browser adapter + `f9f193eb71afa48a23b4eb618cd98b5c0064c59943b3652fb6a601b545ac175c`;app exports + `d30783528fabaaefe8835998e11d75da357d265ee06db67713e57e4b85b58d7d`;error contract + `747894b216db59c753b24ce85eb946925e64340f63684311e5d9628c9a6a9c8e`;unit + `6f5030852e89160fd898b2aff4dc9911963342bd6c2301a2c0e8a3d518ddbcbd`;Chromium spec + `d8cba79c9a330c1e5c777c8f0ed08362b29f50702c31d8229fd65f4304bce363`;golden + `c0061f82ef156e53ad4dfc6aedf7c6a8f53e616d8d9fa1435fd9491ba3d07e01`;package + `83ee5bfa50a0e5b742048d4563ae815171232a121cf3df4a1e813f21313a6509`;M11-12 status + `fc849c33261969db9ea32e7c55ffc713e0e715c62a19532a8c9f42dc12c1fb96`;N-021 + `5acf3e554512ae715dd887deb039637179b51b6ee64442d74a1f7c8e0ce5a04f`;parity ledger + `7f548c66ca871bca059deb9553ee50ac9bd9d3909f1e9be9cce004c7b938154e`;release evidence + `9cb2a4b8f315c0e1d2baa5ed5899ea2241d4f03d43188709b2e9d904e96dfdf0`。 +- `docs/status/M11-12.md`、N-021、主计划、项目状态、parity ledger/release evidence 和本文件 + 已同步;M11 更新为 `12/14`,machine slice 计数为 190 completed/58 blocked。M11-13 音频 + context/device 恢复和 M11-14 跨域 fault lifecycle 均未提前声明。下一领取点继续只读取机器 + 队列最新 `nextTask`,本文不缓存任务名称。 + +M11-13 实际验证(2026-08-17 America/New_York) +- 领取 M11-13:新增 schema 1 `SequencerAudioSessionReportIR`,将 context 的 + `UNAVAILABLE/SUSPENDED/RUNNING/CLOSED` 与 output 的 `BLOCKED/SILENT/ENABLED` 分离;每个 + initialize/resume/suspend/mute/recover/close 操作推进单调 revision,并绑定 muted、有效 gain + 和稳定 issue code。矛盾状态(例如 suspended/muted 却 ENABLED)在协议层拒绝。 +- 生产 `SequencerAudioSession` 独占一个 `AudioContext` 和 master `GainNode`。mute 将 gain 设为 + 0,unmute 恢复配置的 0.75;resume/suspend 必须观察到真实目标 state 才返回成功。close 先归零、 + disconnect,再关闭 context 并丢弃引用。缺 API/构造失败、resume failure 和 suspend failure + 分别保持静音并返回稳定结构化 code,不把 API 存在当作设备 READY。 +- `WEB_TEST_PORT=5586 npm --prefix web run test:sequencer-audio-recovery` 首次完整运行通过:unit + `3/3`、Chromium `1/1`。浏览器在真实 click handler 内创建原生 `AudioContext`,依次通过 + suspend、mute、muted resume、unmute、二次 suspend/resume 和 close;machine golden 序列中 + muted resume 为 `RUNNING/SILENT/gain=0`,unmute 为 `RUNNING/ENABLED/gain=0.75`,close 为 + `CLOSED/SILENT/gain=0`。显式缺设备 scope 返回 + `UNAVAILABLE/BLOCKED/SEQUENCER_AUDIO_DEVICE_UNAVAILABLE`。 +- 回归:M11-09 unit `3/3` + Chromium `1/1`;M11-10 unit `3/3` + Chromium `1/1`;M11-11 + unit `4/4` + Chromium `1/1`;M11-12 unit `4/4` + Chromium `1/1`;100 万帧 long-media + `1/1`;Sequencer Main reader;全量 Node `190/190`;typecheck、lint、production build(75 + modules)、status consistency(12 parity blocked、0 release blocked、17 evidence、0 missing)、 + release evidence(READY)和 `git diff --check` 全部通过。 +- M11-13 hashes:protocol + `0b1b75c7630b599723914bbced9ee0a107bd8c0ac4382ced1f1c5114dac8fd01`;production controller + `c09914d2581ee454e042ed6a9ef5b8c0f6f7b0688eadb1deff45163263742109`;app exports + `e630f647ddd3680a04e07ce7388cf03f54268d4e2d839aa5991cb9ceb6a53bda`;error contract + `6589f712058a39a2177cb174cc355c01777a549d254c4b9eac2301a344c619f2`;unit + `1a888d1b19a3d12091e6edd05287a5bcc19912415cf7f2ed73a028d12846a298`;Chromium spec + `1b94ea07ab5fafb0b06fbd73742c77d6b69d745a546fc60def85857844afc719`;golden + `584aea46687fd5cec8d89e8fac51314acc9a8d05af2a8d762e8d6a74483e0940`;package + `8d87390f9b1b7382bf9c0ba189f7a9d95daa3ee15d1d422a8d856fa4eb6d3cca`;M11-13 status + `51b28251b41468450553699c1d6766343c6ea286b97cab078f27eeb7936ef500`;N-021 + `e9b9b64cc9521acf51c59b3051996c12644fe47a80c4b89a24e1e68dfed6f66c`;parity ledger + `0a9b4c1026c42b5f77869f222b1531601ed229184a1d1737e74047c7b3406730`;release evidence + `c6da09f4c7c584d7f0139c324fd3e80f33a33a28d866d6c7f8dadf9f7507076c`。 +- `docs/status/M11-13.md`、N-021、主计划、项目状态、parity ledger/release evidence 和本文件 + 已同步;M11 更新为 `13/14`,machine slice 计数为 191 completed/58 blocked。waveform、A/V + sync、实际多 strip 混音/设备选择和 M11-14 跨域 fault lifecycle 均未提前声明。下一领取点 + 继续只读取机器队列最新 `nextTask`,本文不缓存任务名称。