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 6e107578..39bcd9e6 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 @@ -1543,7 +1543,7 @@ 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 == "deleteNonMeshData" || + type == "setFontProperties" || type == "setFontAdvanced" || type == "setFontLinks" || type == "setVolumeProperties" || type == "deleteNonMeshData" || type == "setMetaballElements" || type == "createGreasePencilLayer" || type == "removeGreasePencilLayer" || type == "moveGreasePencilLayer" || type == "insertGreasePencilFrame" || type == "removeGreasePencilFrame" || @@ -1934,6 +1934,20 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle, applied = main_error.empty() && web_engine_blend_main_set_font_links( engine->authoritative_main, command.value("dataId", "").c_str(), font_ids, main_error); } + else if (type == "setVolumeProperties") { + WebVolumePropertiesEdit properties; + properties.source_path = command.value("sourcePath", ""); + properties.display_density = command.value("displayDensity", 1.0f); + properties.interpolation = command.value("interpolation", "LINEAR"); + properties.step_size = command.value("stepSize", 0.0f); + properties.velocity_grid = command.value("velocityGrid", ""); + properties.velocity_scale = command.value("velocityScale", 1.0f); + applied = web_engine_blend_main_set_volume_properties( + engine->authoritative_main, + command.value("dataId", "").c_str(), + properties, + main_error); + } else if (type == "setMetaballElements") { std::vector elements; const json source_elements = command.value("elements", json::array()); 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 366d51c8..cdd33a89 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 @@ -735,6 +735,28 @@ std::vector node_socket_default_values(const ParsedBlend &blend, const El return read_float_array(*blend.sdna, *default_value, "value", 4); } +std::optional node_socket_by_identifier(const ParsedBlend &blend, + const ElementRef &node, + const std::string &identifier) +{ + for (const ElementRef &socket : linked_list_elements(blend, node, "inputs")) { + std::string socket_identifier = read_string(*blend.sdna, socket, "identifier"); + if (socket_identifier.empty()) socket_identifier = read_string(*blend.sdna, socket, "name"); + if (socket_identifier == identifier) return socket; + } + return std::nullopt; +} + +std::optional node_socket_default_element(const ParsedBlend &blend, + const ElementRef &node, + const std::string &identifier) +{ + const std::optional socket = node_socket_by_identifier(blend, node, identifier); + if (!socket) return std::nullopt; + const std::optional pointer = read_pointer(*blend.sdna, *socket, "default_value"); + return pointer && *pointer != 0 ? element_for_pointer(blend, *pointer) : std::nullopt; +} + std::optional raw_array_element(const ParsedBlend &blend, uint64_t pointer, const std::string &type_name, @@ -813,6 +835,36 @@ std::optional compositor_graph_from_scene(const ParsedBlend &blend, node_ir["properties"] = {{"color", {values[0], values[1], values[2], values[3]}}}; } } + else if (blender_type == "CompositorNodeExposure") { + const std::optional exposure = node_socket_default_element( + blend, node, "Exposure"); + const std::optional value = exposure ? read_float(*blend.sdna, *exposure, "value") : + std::nullopt; + if (value && std::isfinite(*value) && *value >= -20.0f && *value <= 20.0f) { + node_ir["type"] = "EXPOSURE"; + node_ir.erase("blenderType"); + node_ir["properties"] = {{"exposure", *value}}; + } + } + else if (blender_type == "CompositorNodeInvert") { + const std::optional factor = node_socket_default_element(blend, node, "Fac"); + const std::optional invert_color = node_socket_default_element( + blend, node, "Invert Color"); + const std::optional invert_alpha = node_socket_default_element( + blend, node, "Invert Alpha"); + const std::optional factor_value = factor ? read_float(*blend.sdna, *factor, "value") : + std::nullopt; + const std::optional color_value = invert_color ? + read_integer(*blend.sdna, *invert_color, "value") : std::nullopt; + const std::optional alpha_value = invert_alpha ? + read_integer(*blend.sdna, *invert_alpha, "value") : std::nullopt; + if (factor_value && std::abs(*factor_value - 1.0f) <= 1e-6f && color_value == 1 && + alpha_value == 0) + { + node_ir["type"] = "INVERT"; + node_ir.erase("blenderType"); + } + } nodes.push_back(std::move(node_ir)); } if (output_node_id.empty()) output_node_id = fallback_output_node_id; @@ -835,6 +887,18 @@ std::optional compositor_graph_from_scene(const ParsedBlend &blend, std::string to_socket_name = read_string(*blend.sdna, *to_socket, "identifier"); if (from_socket_name.empty()) from_socket_name = read_string(*blend.sdna, *from_socket, "name"); if (to_socket_name.empty()) to_socket_name = read_string(*blend.sdna, *to_socket, "name"); + const std::optional to_node = element_for_pointer(blend, *to_node_pointer); + const std::string to_blender_type = to_node ? read_string(*blend.sdna, *to_node, "idname") : + std::string(); + if (to_blender_type == "CompositorNodeInvert" && to_socket_name == "Color") { + to_socket_name = "Image"; + } + else if ((to_blender_type == "NodeGroupOutput" || + to_blender_type == "CompositorNodeComposite") && + read_string(*blend.sdna, *to_socket, "name") == "Image") + { + to_socket_name = "Image"; + } if (from_socket_name.empty() || to_socket_name.empty() || from_socket_name.size() > 256 || to_socket_name.size() > 256) { @@ -2414,6 +2478,22 @@ json non_mesh_data_from_record(const ParsedBlend &blend, else if (record.type_name == "Volume") { const std::string path = read_string(*blend.sdna, element, "filepath"); data["resourceKind"] = "OPENVDB"; + json properties = { + {"displayDensity", 1.0f}, + {"interpolation", "LINEAR"}, + {"stepSize", 0.0f}, + {"velocityGrid", read_string(*blend.sdna, element, "velocity_grid")}, + {"velocityScale", read_float(*blend.sdna, element, "velocity_scale").value_or(1.0f)}, + }; + if (const std::optional display = embedded_element(*blend.sdna, element, "display")) { + properties["displayDensity"] = read_float(*blend.sdna, *display, "density").value_or(1.0f); + const int64_t interpolation = read_integer(*blend.sdna, *display, "interpolation_method").value_or(0); + properties["interpolation"] = interpolation == 2 ? "NEAREST" : "LINEAR"; + } + if (const std::optional render = embedded_element(*blend.sdna, element, "render")) { + properties["stepSize"] = read_float(*blend.sdna, *render, "step_size").value_or(0.0f); + } + data["volumeProperties"] = std::move(properties); const std::optional packed_file = read_pointer(*blend.sdna, element, "packedfile"); const std::vector packed_bytes = packed_file ? packed_file_bytes(blend, *packed_file) : @@ -3079,7 +3159,9 @@ json scene_ir_from_blend(const ParsedBlend &blend, mesh_bind_matrices[data_id->second] = matrix; json groups = json::array(); int index = 0; - for (const ElementRef &group : linked_list_elements(blend, object, "defbase")) { + std::vector source_groups = linked_list_elements(blend, *data, "vertex_group_names"); + if (source_groups.empty()) source_groups = linked_list_elements(blend, object, "defbase"); + for (const ElementRef &group : source_groups) { groups.push_back({{"name", read_string(*blend.sdna, group, "name")}, {"index", index++}}); } if (!groups.empty()) vertex_groups_by_mesh_id[data_id->second] = std::move(groups); 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 4ff42a18..a98aed07 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 @@ -37,6 +37,7 @@ #include "BKE_object.hh" #include "BKE_object_deform.h" #include "BKE_scene.hh" +#include "BKE_volume.hh" #include "BKE_fcurve.hh" #include "BKE_image.hh" @@ -69,6 +70,7 @@ #include "DNA_world_types.h" #include "DNA_userdef_enums.h" #include "DNA_vfont_types.h" +#include "DNA_volume_types.h" #include "BLI_listbase_iterator.hh" #include "BLI_listbase.h" @@ -187,6 +189,18 @@ MetaBall *find_metaball(Main *main, const char *data_id) return nullptr; } +Volume *find_volume(Main *main, const char *data_id) +{ + if (main == nullptr || data_id == nullptr || strncmp(data_id, "volume:", 7) != 0) { + return nullptr; + } + const std::string name(data_id + 7); + for (Volume &volume : main->volumes) { + if (id_name(volume.id) == name) return &volume; + } + return nullptr; +} + Light *find_light(Main *main, const char *data_id) { if (main == nullptr || data_id == nullptr || strncmp(data_id, "light:", 6) != 0) return nullptr; @@ -3471,6 +3485,54 @@ bool web_engine_blend_main_set_font_links(WebBlendMainState *state, return true; } +bool web_engine_blend_main_set_volume_properties(WebBlendMainState *state, + const char *data_id, + const WebVolumePropertiesEdit &properties, + std::string &error) +{ + Volume *volume = find_volume(state != nullptr ? state->main : nullptr, data_id); + if (volume == nullptr || + !ensure_single_user_data(state != nullptr ? state->main : nullptr, &volume->id, error)) + { + if (error.empty()) error = "NON_MESH_DATA_UNSUPPORTED: Volume data-block was not found"; + return false; + } + const std::string &path = properties.source_path; + const bool project_relative = path.size() >= 7 && path.rfind("//", 0) == 0 && + path.compare(path.size() - 4, 4, ".vdb") == 0 && + path.find('\\') == std::string::npos && + path.find("/../") == std::string::npos && + path.compare(path.size() - 3, 3, "/..") != 0 && + path.size() < sizeof(volume->filepath); + if (!project_relative || !std::isfinite(properties.display_density) || + properties.display_density < 0.0f || properties.display_density > 1000000.0f || + (properties.interpolation != "NEAREST" && properties.interpolation != "LINEAR") || + !std::isfinite(properties.step_size) || properties.step_size < 0.0f || + properties.step_size > 1000000.0f || properties.velocity_grid.size() >= sizeof(volume->velocity_grid) || + !std::isfinite(properties.velocity_scale) || properties.velocity_scale < -1000000.0f || + properties.velocity_scale > 1000000.0f) + { + error = "NON_MESH_PROPERTY_INVALID: Volume source and properties are outside the bounded project-relative range"; + return false; + } + BKE_volume_unload(volume); + BLI_strncpy(volume->filepath, path.c_str(), sizeof(volume->filepath)); + volume->display.density = properties.display_density; + volume->display.interpolation_method = properties.interpolation == "NEAREST" ? + VOLUME_DISPLAY_INTERP_CLOSEST : + VOLUME_DISPLAY_INTERP_LINEAR; + volume->render.step_size = properties.step_size; + BLI_strncpy(volume->velocity_grid, + properties.velocity_grid.c_str(), + sizeof(volume->velocity_grid)); + volume->velocity_scale = properties.velocity_scale; + volume->id.recalc |= ID_RECALC_GEOMETRY; + for (Object &object : state->main->objects) { + if (object.data == &volume->id) object.id.recalc |= ID_RECALC_GEOMETRY; + } + return true; +} + bool web_engine_blend_main_set_metaball_elements(WebBlendMainState *state, const char *data_id, const std::vector &elements, 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 2deff457..91135984 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 @@ -67,6 +67,15 @@ struct WebFontTextBoxEdit { float height = 0.0f; }; +struct WebVolumePropertiesEdit { + std::string source_path; + float display_density = 1.0f; + std::string interpolation; + float step_size = 0.0f; + std::string velocity_grid; + float velocity_scale = 1.0f; +}; + struct WebGreasePencilPointEdit { std::array position = {0.0f, 0.0f, 0.0f}; float radius = 0.01f; @@ -341,6 +350,10 @@ bool web_engine_blend_main_set_font_links(WebBlendMainState *state, const char *data_id, const std::array &font_ids, std::string &error); +bool web_engine_blend_main_set_volume_properties(WebBlendMainState *state, + const char *data_id, + const WebVolumePropertiesEdit &properties, + std::string &error); bool web_engine_blend_main_set_metaball_elements(WebBlendMainState *state, const char *data_id, const std::vector &elements, diff --git a/docs/BLENDER_5_2_WEB_FEATURE_PARITY.md b/docs/BLENDER_5_2_WEB_FEATURE_PARITY.md index 2d3c7867..08d55449 100644 --- a/docs/BLENDER_5_2_WEB_FEATURE_PARITY.md +++ b/docs/BLENDER_5_2_WEB_FEATURE_PARITY.md @@ -1,6 +1,6 @@ # Blender 5.2 Web 功能对标与全量迁移台账 -更新时间:2026-08-12 +更新时间:2026-08-13 本文件以仓库内 `blender-5.2.0/source/blender/` 为基线,记录 Blender 功能域在 Web 项目中的迁移方式和唯一领取顺序。`completed_current_scope` 只表示声明子集通过验收, @@ -37,14 +37,14 @@ | NLA | `in_progress` Action Clip/reverse/repeat 子集 | 多轨混合、Transition/Meta、Animated Time、UI 和导出 | N-014 | | Curve/Curves/Surface/Text/Metaball | `in_progress` reader/预览子集 | 数据块 IR、Main operator、tessellation 和保存闭环 | N-015 | | Point Cloud/Volume/Hair | `in_progress` 摘要/资源门 | 有界数据读取、分块 buffer、视口和资源预算 | N-015 | -| Grease Pencil/Drawing | `planned` | layer/frame/drawing/stroke、onion skin 和基础 modifier | N-016 | -| Vertex/Weight/Texture Paint | `planned` | Main/PBVH image transaction、颜色管理和恢复 | N-017 | -| Rigid/Soft Body、Cloth、Fluid、Particles | `planned` | 优先消费 desktop bake;WASM 求值逐 family feature probe | N-018 | -| Camera/Light/World/Color Management | `metadata/viewport subset` | SceneIR/Three/WebGPU 映射、阴影、曝光和差异报告 | N-019 | +| Grease Pencil/Drawing | `in_progress` 有界 Main/3D editor | layer/frame/drawing/stroke、onion skin 和基础 modifier | N-016 | +| Vertex/Weight/Texture Paint | `in_progress` vertex color/weight Main 子集 | Main/PBVH image transaction、颜色管理和恢复 | N-017 | +| Rigid/Soft Body、Cloth、Fluid、Particles | `in_progress` reader/cache/playback 安全层 | 优先消费 desktop bake;WASM 求值逐 family feature probe | N-018 | +| Camera/Light/World/Color Management | `in_progress` Main/viewport subset | SceneIR/Three/WebGPU 映射、阴影、曝光和差异报告 | N-019 | | Eevee/Cycles/Freestyle/Render Result | `planned` | WebGPU 实时子集;Cycles/硬件后端采用服务端 Blender | N-019 | -| Compositor | `planned` | 受限 node graph + WebGPU/CPU executor;完整图可服务端执行 | N-020 | -| Video Sequencer/Audio | `planned` | strip/time/edit proxy;codec 能力探测;最终编码可服务端 | N-021 | -| Movie Tracking/Mask | `planned` | 数据/编辑协议和基础浏览器工具;重计算按能力门 | N-022 | +| Compositor | `in_progress` 有限 Main reader/CPU executor | 受限 node graph + WebGPU/CPU executor;完整图可服务端执行 | N-020 | +| Video Sequencer/Audio | `in_progress` reader/transition 子集 | strip/time/edit proxy;codec 能力探测;最终编码可服务端 | N-021 | +| Movie Tracking/Mask | `in_progress` Mask reader/raycast/marquee | 数据/编辑协议和基础浏览器工具;重计算按能力门 | N-022 | | Asset Browser/Library/Override | `partial` | catalog、preview、append/link/override、路径与来源沙箱 | N-023 | | Import/Export formats | `GLB subset` | glTF/OBJ/PLY/STL/USD/Alembic 能力矩阵和 desktop round-trip | N-023 | | Blender editors/workspaces/keymap | `partial shell` | View3D/Outliner/Properties/UV/Node/Graph/Dope/NLA/Spreadsheet | N-024 | @@ -84,21 +84,27 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 65,536 点 chunks、SHA-256、256 MB 场景预算和 1M 点性能门。 2. N-015-B(`done_current_scope`):native reader 已读取真实 PointCloud/Curves position、 radius、curve offset 和属性;depsgraph 对 Curve/Surface/Font/Metaball 输出真实 mesh、UV、 - 材质槽和 source face mapping;Volume 保持项目路径与 VDB grid/decoder 安全门。 + 材质槽和 source face mapping;Volume 保留 metadata,并采用 desktop/server OpenVDB -> + NanoVDB、浏览器 range stream + WebGPU 的重新纳入方案。 3. N-015-C(`in_progress`):Curve/Surface control point/resolution、有界 cyclic/handle metadata、 bounded Poly Curve create/delete、Poly/Bezier conversion、Curve/Surface/Font/Metaball 数据块 rename、 Font body/geometry properties、逐字符样式/textbox、已有/packed VFont 四样式链接和 Metaball element Main command 已支持并通过 undo/save/reopen;一维多 spline 创建/删除/重排、 批量 handle/cyclic 和二维 Surface U/V topology/order/rational weight transaction 已通过 - undo/save/reopen 与 depsgraph 求值;多 handle 选择拖拽/gizmo UI 和任意外部路径字体导入仍推进。 + undo/save/reopen 与 depsgraph 求值;多 handle 连续 preview、handle-local 原点/正交方向及 + 单次 Main commit 已完成,任意外部路径字体导入仍推进。 4. N-015-D(`in_progress`):Three.js 主线程和 OffscreenCanvas Worker 共享 WNM chunks,真实 PointCloud/Curves 可预览,Point/Curve bounded raycast、Metaball proxy 与 evaluated mesh 分层; - 左右 handle identity raycast/单 handle 轴向 gizmo 已完成;WebGPU、跨对象 selection history、 - 多 handle 拖拽和 SceneDelta range patch 仍待完成。 + 左右 handle identity raycast、跨对象 selection history、range patch、多 handle 拖拽和 + handle-local gizmo 已完成;WebGPU 等价显示仍待完成。 5. N-015-E(`in_progress`):Blender 5.2 fixture、Chromium 双 renderer、depsgraph 真求值、GLB 拒绝/映射和 USD loss report、Main roundtrip、desktop golden、7 对象 GLB/USD round-trip、 - 1M binary gate、Worker restart 与 Chromium 真实 OPFS quota 已接入;VDB renderer 仍是 - 当前阻断。后续浏览器验收仅覆盖 Chromium,不配置 Firefox/WebKit。 + 1M binary gate、Worker restart 与 Chromium 真实 OPFS quota 已接入。VDB 转换请求、NanoVDB + grid/material/chunk manifest、逐块 hash/range streamer、分阶段能力门、14 条真实资源 catalog + 和 OpenVDB 13 -> NanoVDB 32 desktop converter 已完成;server job、HTTP body 断点续传、OPFS、 + 有界 Float32 WebGPU 双视口和 Main Volume 属性保存重开已有专项证据。GPU resident paging、 + 联合重开、完整材质、大 bundle/OOM 和 golden 仍 `BLOCKED`。后续浏览器验收仅覆盖 + Chromium,不配置 Firefox/WebKit。 专项字段、验收命令和停止条件见 `docs/status/N-015.md`。 @@ -107,13 +113,15 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 1. N-016-A:Layer/Frame/Drawing/Stroke/Point/Attribute schema 与最大 layer/frame/point 预算。 2. N-016-B:Main create/remove/reorder layer,insert/remove frame,draw/erase stroke transaction。 3. N-016-C:material、onion skin、cyclic stroke、radius/opacity/color 和基础 modifier reader。 -4. N-016-D:Chromium 主线程/Offscreen 的 3D current-frame stroke preview 已完成;2D editor、 - stroke/point selection、timeline/dope integration、gizmo 和 worker restart 继续推进。 +4. N-016-D:Chromium 主线程/Offscreen 的 3D current-frame stroke preview、点 raycast、多选 + 高亮、连续 drag preview、单次 gizmo Main commit、真实 drawing frame 的有界 Dope 导航和 + Worker 保存重开已完成;完整 2D canvas/marquee 与完整 timeline/dope 编辑继续推进。 5. N-016-E:desktop drawing hash、像素 golden、save/reopen 和 GLB/USD loss report。 ## N-017 Paint 与权重 -1. N-017-A:Vertex Color/Weight/Texture Paint stroke schema、PBVH/UV hit 和 brush 预算。 +1. N-017-A:Vertex Color/Weight/Texture Paint stroke schema、UV hit、uniform-grid 候选查询、 + 显式深度可见性门和 brush 预算;真实 PBVH/深度采样仍阻断。 2. N-017-B:vertex color 与 vertex group Main transaction、normalize/limit/mirror 权重。 3. N-017-C:packed/UDIM image tile transaction、色彩空间、dirty tile 和原子保存。 4. N-017-D:armature deform golden、seam bleed、mask/face selection 和 undo/redo。 @@ -123,21 +131,25 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 1. N-018-A:Rigid Body、Soft Body、Cloth、Fluid、Dynamic Paint、Particle/Hair 能力清单。 2. N-018-B:每 family 的 settings/dependency/cache manifest;目标对象和 collection 防循环。 -3. N-018-C:先消费 Blender desktop deterministic bake,frame seek 不允许复用错误帧。 +3. N-018-C:frame seek 不允许复用错误帧;浏览器自有 BTF1 已连接不可变 SceneIR preview、 + 可取消顺序播放和跨 Storage Worker 重启精确读取;Blender desktop deterministic bake family + decoder、正式 viewport UI 控制和 100 帧 golden 仍阻断。 4. N-018-D:逐 family 探测 WASM solver;初始化、线程或内存门失败时保持 bake-only。 5. N-018-E:浏览器 bake start/cancel/commit、故障恢复、长任务进度和 100 帧 golden。 ## N-019 灯光与渲染 1. N-019-A:Camera、Light、World、View Transform、Exposure、Mist、Shadow 的完整 SceneIR。 -2. N-019-B:Three/WebGPU 映射和 capability report;材质/灯光变化使用增量更新。 +2. N-019-B:Three/WebGPU 映射和 capability report;共享 PBR 路径已应用有界 Kelvin 线性灯光 + 颜色,材质/灯光变化使用增量更新。 3. N-019-C:Eevee 有限特性矩阵、shadow map、transparent sorting、probe 和后处理。 4. N-019-D:Cycles/Freestyle/硬件 denoise 使用服务端 Blender job 协议与结果 hash。 5. N-019-E:desktop/Web 像素误差、色彩管理、设备丢失和 1M 三角形性能门。 ## N-020 Compositor -1. N-020-A:Compositor GraphIR、socket/link、image/render-layer 资源和 cycle 校验。 +1. N-020-A:Compositor GraphIR、socket/link、image/render-layer 资源和 cycle 校验;真实 Main + Exposure 与默认 Invert 参数链已进入 CPU executor,其他参数节点仍按 Unsupported 保留。 2. N-020-B:Transform/Color/Alpha/Blur/Mix 等有限 node 的 CPU/WebGPU executor。 3. N-020-C:viewer/composite output、frame cache、tile budget 和 worker cancellation。 4. N-020-D:unsupported node 保留 metadata 并可提交服务端 Blender,不删图继续执行。 @@ -146,7 +158,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 ## N-021 Sequencer 与音频 1. N-021-A:Scene/Movie/Image/Sound/Effect/Meta strip schema、channel、range 和 proxy metadata。 -2. N-021-B:Main add/remove/move/trim/split、transition 和 modifier 白名单。 +2. N-021-B:Main add/remove/move/trim/split、transition 和 modifier 白名单;CROSS/GAMMA_CROSS + 已有真实依赖顺序、进度和静帧 source-frame 解析,尚未接媒体解码。 3. N-021-C:WebCodecs/HTMLMedia capability probe、waveform/proxy 和精确 timeline seek。 4. N-021-D:浏览器不支持的 codec/混音/编码提交服务端;资源路径必须沙箱化。 5. N-021-E:A/V sync、frame hash、save/reopen、丢帧和损坏媒体测试。 @@ -156,7 +169,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 1. N-022-A:MovieClip、Track/Marker、Plane Track、Mask Layer/Spline/Point schema。 2. N-022-B:Main marker/mask edit、selection、keyframe 和 camera solve metadata。 3. N-022-C:浏览器 tracking feature probe;完整 solve 可使用服务端 Blender。 -4. N-022-D:Clip/Mask editors、overlay 和 compositor/scene resource binding。 +4. N-022-D:Clip/Mask editors、overlay 和 compositor/scene resource binding;真实 Main Mask 的 + 锁定过滤、稳定点 raycast 与 replace/add/toggle marquee 已验证。 5. N-022-E:desktop solve/error golden、媒体故障、保存和重开。 ## N-023 Asset、Library 与 IO @@ -165,7 +179,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 2. N-023-B:Append/Link/Library Override Main transaction、reload/relocate 和循环依赖门。 3. N-023-C:glTF/OBJ/PLY/STL 首批本地 IO;USD/Alembic 按编译能力或服务端执行。 4. N-023-D:每格式 import -> save -> reopen -> export -> desktop reimport 语义比较。 -5. N-023-E:zip bomb、路径穿越、外部 URI、许可证和大文件流式预算。 +5. N-023-E:zip bomb、路径穿越、外部 URI、许可证和大文件流式预算;archive 重复/前缀冲突、 + 累计压缩预算和确定性 range plan 已落地,真实 ZIP 解码仍阻断。 ## N-024 Editors 与工作流 @@ -175,6 +190,9 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 4. N-024-D:Blender-compatible keymap、可配置 shortcut、gizmo、drag preview/commit。 5. N-024-E:desktop/mobile layout、键盘/触控/笔、无重叠截图和 accessibility 门。 +当前有界切片还包括 workspace/editor/mode 作用域 keymap,以及可执行、上下文过滤的 F3 +operator search;这不等价于 Blender 全量 operator registry、context menu 或 editor writer。 + ## N-025 Scripting 与平台适配 1. N-025-A:默认拒绝任意 Python、Text autorun、driver expression 和 add-on 安装。 @@ -183,6 +201,9 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 4. N-025-D:CUDA/Metal/HIP/OptiX、native window、file watcher 等返回平台能力报告。 5. N-025-E:恶意脚本、依赖混淆、逃逸、重放和审计日志发布门。 +当前拒绝审计已具备有界 SHA-256 链、严格 UTC 顺序、requestId replay 与篡改拒绝; +无网络 sandbox 和持久化发布审计仍阻断。 + ## N-026 全域发布门 1. 生成 machine-readable parity manifest:每个 Blender family 为 @@ -192,6 +213,11 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保 4. `.blend`/image/media/script fuzz、OPFS quota、OOM、设备丢失、网络中断和恢复验证。 5. 许可证/source offer/SBOM、确定性包、文档状态与测试报告一致后才能发布。 +当前证据为 13 条有效记录、15 个缺失,整体仍为 `BLOCKED`;新增 VDB 边界记录只证明 +转换/manifest/range 协议与显式阻断;已通过 1M geometry、 +600 帧 OPFS simulation cache、4K/8K texture、运行中断网和主线程 WebGL device loss, +仍缺 10M geometry、长媒体、OOM 及 N-016 至 N-025 上游 family 的完整验收。 + ## 默认验收入口 新增任务必须把专项命令接入 `web/package.json`,并至少执行: diff --git a/docs/PBR_RENDERING_IMPLEMENTATION_PLAN.md b/docs/PBR_RENDERING_IMPLEMENTATION_PLAN.md index 8596e19a..490eee5f 100644 --- a/docs/PBR_RENDERING_IMPLEMENTATION_PLAN.md +++ b/docs/PBR_RENDERING_IMPLEMENTATION_PLAN.md @@ -133,9 +133,9 @@ transmission material 数、shadow caster 数、WASM/GPU 内存压力和帧时 - PBR-007-A:done_current_scope;`render-assets.ts` 定义 schema v1、SHA-256、MIME/尺寸/字节预算和稳定错误码,App 从 `requestAsset` 生成载荷并同时写入 OPFS。 - PBR-007-B:done_current_scope;Base Color/Emission 使用 sRGB,Normal/Data 使用 non-color/linear,主线程和 Worker 使用相同 `GPUTextureStore`。 -- PBR-007-C:in_progress;ImageBitmap 解码、旧 Texture dispose、状态回传已完成;mipmap、anisotropy、GPU context-loss 恢复和全局显存预算仍未开放。 +- PBR-007-C:in_progress;ImageBitmap 解码、旧 Texture dispose、状态回传和主线程 WebGL context-loss 恢复已完成;mipmap、anisotropy、Offscreen context-loss 恢复和全局显存预算仍未开放。 - PBR-007-D:Normal Map 切线空间、UV Map 选择、wrap/filter 和缺失纹理 fallback。 -- PBR-007-E:in_progress;双后端同一载荷正例已覆盖,context-loss/Worker crash 后重新上传仍属于 PBR-011 故障注入门。 +- PBR-007-E:in_progress;双后端同一载荷正例和主线程真实 `WEBGL_lose_context` 恢复已覆盖,Offscreen context-loss/Worker crash 后重新上传仍属于 PBR-011 故障注入门。 ### PBR-008 UDIM 与外部资源:in_progress(manifest/阻断已完成,多 tile 采样未声明) @@ -169,7 +169,10 @@ transmission material 数、shadow caster 数、WASM/GPU 内存压力和帧时 - PBR-012-A:done_current_scope;`queryRenderCapability` 明确返回 `WEBGPU_RENDERER_UNAVAILABLE`,没有 WebGPU renderer bundle 时不伪装为 WebGPU。 - PBR-012-B:done_current_scope(安全门);BLOOM/SSAO/SSR/TAA/DOF/MOTION_BLUR 未有真实 pass 时返回 `POSTPROCESS_PASS_UNAVAILABLE`,未声明假实现。 -- PBR-012-C:done_current_scope(安全门);Volume/SSS 返回独立 `VOLUME_SHADER_UNAVAILABLE`/`SUBSURFACE_SHADER_UNAVAILABLE`,任意 Shader node 返回 `SHADER_NODE_UNSUPPORTED`。 +- PBR-012-C:in_progress;Volume 已重新纳入 desktop/server OpenVDB -> NanoVDB + WebGPU + 路径,当前完成 manifest/material/range 安全门,真实 WGSL/材质/golden 前仍返回 + `VOLUME_SHADER_UNAVAILABLE`;SSS 与任意 Shader node 仍分别结构化阻断。详细任务见 + `docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md`。 - PBR-012-D:浏览器 path tracer 若引入,作为独立 Render backend,不声称 Cycles 等价。 ## 8. 当前验收 diff --git a/docs/PROJECT_STATUS_AND_NEXT_WORK.md b/docs/PROJECT_STATUS_AND_NEXT_WORK.md index d0a2c958..345ba44b 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-12 +更新时间:2026-08-14 ## 1. 当前结论 @@ -37,9 +37,10 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM | 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 未开放 | +| 发布性能/故障门 | Chromium 118 条 E2E、1M geometry、600 帧 OPFS cache、4K/8K texture、运行中断网、主线程 WebGL context loss | 已完成已声明门;10M、长媒体、OOM 和 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、desktop golden 与 7 对象 GLB/USD round-trip | 有界本地切片 in_progress;VDB renderer、完整 topology editor/跨对象 history/range patch、外部字体导入、跨浏览器 OPFS 性能仍阻断 | +| 非 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 资源 catalog、desktop OpenVDB->NanoVDB 与分块协议已完成,server/WebGPU/保存重开/golden 未完成 | ## 3. 部分完成或仍有边界的能力 @@ -52,7 +53,7 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM | `.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/gizmo、7 对象 GLB/USD 和 Chromium quota | 多 handle 选择拖拽/gizmo UI、跨对象 history/range delta、新外部字体导入、VDB renderer/voxel upload 仍 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/NanoVDB 协议与 desktop converter | 新外部字体导入及 VDB server job、WebGPU renderer、材质、保存重开和 golden 仍 planned/阻断 | | 大场景 | Worker 二进制解码、per-mesh/range transferable、linked Mesh 实例化、LOD/OPFS cache、frustum culling、能力门 OffscreenCanvas Worker、100k/1M 内存门 | 超出当前 WBG1 的网络式渐进流送仍属于后续性能扩展 | | Decimate 全对标 | Collapse 的当前网格集合已覆盖,错误路径结构化 | Un-Subdivide、Dissolve、所有 delimiter/权重/对称组合及大模型性能矩阵未完成 | @@ -181,9 +182,11 @@ Blender 5.2 全域功能矩阵、浏览器/服务端边界和 N-015 至 N-026 `NonMeshDataIR`、native SDNA reader、Main 权威 Curve/Surface/Font/Metaball 写回、bounded Poly Curve create/delete、Poly/Bezier conversion、非 Mesh 数据块 rename、Curve 有界 topology/Font geometry、undo/redo、 save/reopen、真实 legacy evaluated mesh、PointCloud/Curves 属性和 WNM 二进制分块已经接通。 主线程与 OffscreenCanvas 消费同一 chunks;GLB 只接受真实 evaluated mesh,USD 提供 -`UsdGeomMesh`、`UsdGeomPoints`、`UsdGeomBasisCurves` 和 `OpenVDBAsset` 的机器可读映射/损失。 -VDB renderer、完整 Curve handle/cyclic topology editor、二维 Surface mutation、跨对象 history/range patch、新字体导入和跨浏览器 OPFS quota 仍按 -`docs/status/N-015.md` 的阻断项推进。 +`UsdGeomMesh`、`UsdGeomPoints` 和 `UsdGeomBasisCurves` 的机器可读映射/损失。 +VDB 已改为 desktop/server OpenVDB -> NanoVDB、浏览器分块读取 + WebGPU。真实资源 catalog、 +desktop converter 和协议/range 基础件已接通,但 server job、renderer、材质、保存重开和 golden 仍阻断;handle-local 专用 gizmo +已接通,新字体导入等剩余项按 `docs/status/N-015.md` 和 +`docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md` 推进。 ### PBR-001 至 PBR-012 模型物理渲染(核心切片 done_current_scope,资产/高级渲染安全门 in_progress) @@ -192,10 +195,19 @@ VDB renderer、完整 Curve handle/cyclic topology editor、二维 Surface mutat 现已接通 SHA-256 GPU 纹理载荷、颜色空间、尺寸预算、ImageBitmap 解码和双后端状态;PBR-008 现已接通 UDIM manifest/缺失资源/多 tile 结构化阻断;PBR-009 已支持有限 packed raster equirectangular PMREM/IBL 生命周期;PBR-012 已提供 WebGPU、任意 Shader、Volume/SSS 和 -高级后处理的能力查询门。HDR/EXR、多 tile 采样、context-loss 恢复、真实 WebGPU renderer、 +高级后处理的能力查询门。主线程 WebGL context-loss 已有显式恢复与真实 Chromium 门; +HDR/EXR、多 tile 采样、Offscreen context-loss、真实 WebGPU renderer、 高级 pass 和未声明 Shader 家族仍不得声称完成。架构映射、细分状态、停止条件和验收命令统一维护在 `docs/PBR_RENDERING_IMPLEMENTATION_PLAN.md`。 +### N-026 当前发布实况 + +`docs/status/release-evidence.json` 已生成 13 条成功 command/output/artifact-hash 记录;其中 +VDB 边界记录没有设置发布 true 字段,只证明 Phase 0 协议门。 +`test:release-evidence` 计算为 15 个缺失并保持 `BLOCKED`。其中 3 个是实际证据字段 +`performance.geometry10M`、`performance.longMedia`、`faults.oom`,其余 12 个来自仍为 +`BLOCKED` 的 N-016 至 N-026 family。浏览器范围仅为 Chromium。 + ## 6. 当前验收命令 ```bash @@ -223,9 +235,16 @@ npm --prefix web run test:topology-collapse 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:network-interruption +npm --prefix web run test:device-loss +npm --prefix web run test:texture-4k-performance +npm --prefix web run test:texture-8k-performance npm --prefix web run test:malicious-blends npm --prefix web run test:browser npm --prefix web run release:offline +npm --prefix web run release:evidence +npm --prefix web run test:release-evidence WEB_TEST_PORT=5193 npm --prefix web run test:e2e # 5173 被占用时指定可用端口 WEB_TEST_PORT=5193 npm --prefix web run test:e2e -- -g "N-015|non-mesh" ``` diff --git a/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md b/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..80970a7b --- /dev/null +++ b/docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md @@ -0,0 +1,242 @@ +# VDB -> NanoVDB -> WebGPU 实施方案 + +更新时间:2026-08-13 + +## 1. 范围与完成定义 + +VDB 重新纳入 N-015/N-019/N-023/N-026。产品链路固定为:桌面工具或受控服务端使用 +OpenVDB 读取 `.vdb`,转换为 NanoVDB;浏览器只读取受校验的 `.nvdb` 分块,并由 WebGPU +执行稀疏树遍历、采样和体积积分。 + +浏览器 Blender WASM 必须继续保持 `WITH_OPENVDB=OFF`。`WITH_OPENVDB=ON` 或 +`WITH_NANOVDB=ON` 只能证明编译/链接选项,不能证明以下能力: + +- 外部 VDB 的受限读取、分块上传、失败恢复和缓存淘汰; +- NanoVDB 数据布局与 WGSL 遍历正确; +- density/temperature/color/emission/velocity 的 Blender 材质语义; +- `.blend` 保存、转换产物绑定、重开和资源丢失诊断; +- 与 Blender 5.2 桌面图像/数值 golden 一致; +- 大体积、损坏输入、OOM、取消和 Chromium WebGPU 性能门。 + +只有上述链路均有真实资源与自动测试证据时,VDB 才能从 `BLOCKED` 转为可发布能力。 + +## 2. 架构和信任边界 + +```text +.blend + .vdb + | + | source SHA-256, grid allowlist, budgets + v +Desktop/Server Blender 5.2 + OpenVDB + NanoVDB + | + | deterministic .nvdb + manifest.json + converter identity + v +Project asset store / HTTP Range / OPFS content-addressed cache + | + | contiguous ranges, per-chunk SHA-256, whole-bundle SHA-256 + v +Chromium Storage/Render Worker + | + | bounded GPU pages, validated grid ranges and transforms + v +WebGPU storage buffers + WGSL NanoVDB traversal + volume integrator +``` + +信任边界如下: + +1. `.vdb` 是不可信输入,只允许进入桌面/服务端转换沙箱;浏览器不加载 OpenVDB。 +2. 转换器输出也不直接信任。浏览器先校验 schema、路径、预算、连续 range、分块 hash 和整包 + hash,再允许 GPU 上传。 +3. manifest 是持久化真源,路径必须位于项目资源根;HTTP 必须返回精确 `206 Content-Range`, + OPFS 必须使用 content-addressed key。 +4. GPU resident set 独立受限,CPU/网络总包预算不能代替 GPU 预算。 +5. Main 只保存源绑定、转换版本、产物 hash、grid/material 映射;不能把临时 URL 或未验证 + GPU 状态写成已完成资源。 + +## 3. 产物与协议 + +### 3.1 转换请求 + +`VDBConversionRequestIR` 固定记录 schema、job、项目内 `.vdb` 路径、源字节数/hash、源 grid +清单、选择 grid、量化策略、分块大小和输出 `.nvdb` 路径。`VDBConverterIdentityIR` 固定记录 +DESKTOP/SERVER、Blender/OpenVDB/NanoVDB 版本和转换器可执行文件 hash。 + +同一源 hash、转换器 identity、选择 grid、量化和 chunk size 必须产生相同的 bundle hash;分别生成 +但语义相同的 OpenVDB fixture 允许因文件 UUID 而有不同源 hash,其 NanoVDB grid/bundle 应一致。 +job ID 和输出路径不进入内容键;不能仅以文件名作为缓存键。 + +### 3.2 NanoVDB bundle manifest + +`NanoVDBBundleManifestIR` schema 1 记录: + +- 源 `.vdb` 与 bundle `.nvdb` 的项目路径、长度和 SHA-256,以及包含 grid/量化/chunk 参数的 + canonical conversion request SHA-256; +- 每个 grid 的 value type/class/semantic、active voxel、标准 NanoVDB segment 范围、内部 grid + payload 范围、index/world bounds、voxel size 和 4x4 index-to-world;文件内 payload 偏移不要求 + 对齐,上传到 GPU storage buffer 时另行满足 32-byte alignment; +- density/temperature/color/emission/velocity 材质绑定和受限参数; +- 32-byte 对齐、连续覆盖整个 bundle 的 chunks,以及每块 SHA-256; +- `NANOVDB_STORAGE_BUFFER`、GPU page/resident budget 和 `volume-wgsl-v1` 语义版本。 + +首期 GPU value type 白名单为 Float32、Float16、Vec3f32、Vec4f32。Double、Bool、Int、Point +Index、未知 grid class 返回 `NANOVDB_GRID_UNSUPPORTED`,不进行隐式转换。 + +### 3.3 浏览器流送 + +`streamNanoVDBChunks` 串行执行 range read -> 分块 hash -> consumer upload,并在每块后报告字节 +进度。串行是首期的确定性基线;通过内存峰值和取消测试后才能加入最多 4 路的有界并发。 +`createHttpNanoVDBRangeSource` 只接受 HTTP 206,且 `Content-Range` 的起点、终点、总长度必须 +与 manifest 精确一致。resumable adapter 对临时状态和网络异常做有界重试;response body 中断后 +保留已收字节,以相同 ETag 的 `If-Range` 从精确偏移续传,干净结束的短响应仍直接拒绝。 + +OPFS 适配器以 `bundleSha256/chunkIndex` 为 key 原子提交。所有块校验完成后才提交完整 bundle +manifest;中断留下的 staging 数据不会被重开流程发现,重开后的每次 chunk 读取仍重新校验 SHA-256。 + +## 4. 材质与渲染语义 + +首期声明 `Principled Volume` 有界子集: + +| Blender 语义 | NanoVDB/manifest | WebGPU 行为 | +| --- | --- | --- | +| Density | `densityGrid` + `densityScale` | 非负消光/散射密度 | +| Temperature | `temperatureGrid` + scale | 只在 Blackbody emission 开启时采样 | +| Color | `colorGrid` 或常量颜色 | 线性工作空间散射颜色 | +| Emission | `emissionGrid` + scale | 线性辐射项;负值钳制并报告 | +| Velocity | `velocityGrid` | 首期仅保留/报告,motion blur 未验证前不消费 | +| Anisotropy | manifest scalar | Henyey-Greenstein,范围 `[-0.99, 0.99]` | + +首期只开放单 Volume、单散射/吸收积分、线性/最近采样、对象变换和 scene exposure。多重散射、 +体积阴影、烟火黑体、motion blur、多个 volume overlap、Cycles 等价保持独立阻断。 + +WGSL 实现必须包括 NanoVDB header/version/magic 检查、root/internal/leaf address 上界检查、空 +节点跳跃、index-to-world/world-to-index、ray-box intersection、自适应步长上限、early +transmittance termination 和 NaN 防护。shader 不能通过越界 buffer read 猜测无效节点。 + +## 5. 保存与重开 + +保存流程分两层: + +1. Blender Main 中的 Volume 保留项目相对 `.vdb` 引用和 grid/material 参数。 +2. Web project manifest 保存 `sourceSha256 -> conversion request -> bundleSha256` 绑定、转换器 + identity、chunk manifest 和 OPFS/object-store 定位信息。 + +重开时先比较 `.blend` Volume 引用、源 hash 和 bundle manifest。源改变、converter 改变、bundle +缺失、hash 不符或 shader semantic version 不符均返回结构化 `BLOCKED` 并要求重转;不能静默 +复用旧缓存。保存成功标准包括:清空 Worker、重新打开 `.blend`、重新发现 bundle、逐块复验、 +恢复同一 grid/material 映射并得到相同像素/采样摘要。 + +## 6. 详细任务分解 + +### Phase 0:能力声明与协议 + +- `VDB-001`:删除浏览器伪 decoder 入口,raw `.vdb` 浏览器解码固定返回 + `VDB_CONVERSION_REQUIRED`。状态:`done_current_scope`。 +- `VDB-002`:实现 source manifest、conversion request、converter identity、路径/hash/预算门。 + 状态:`done_current_scope`。 +- `VDB-003`:实现 NanoVDB bundle/grid/material/GPU/chunk manifest、range plan、分块与整包 + hash 校验。状态:`done_current_scope`。 +- `VDB-004`:实现串行 range streamer 和严格 HTTP 206 适配器。状态:`done_current_scope`。 +- `VDB-005`:能力门分别报告 desktop converter、server converter、stream、WebGPU renderer; + 禁止单一布尔值概括全链路。状态:`done_current_scope`。 + +### Phase 1:真实资源与转换器 + +- `VDB-010`:在本机资源库收录许可证明确的 tiny smoke、density+temperature、color、level-set、 + 大稀疏体积和损坏文件;记录来源 URL、许可证、原始 SHA-256。状态:`done_current_scope`; + 资源库为 `/home/mes123456/resource-library/blender-web-vdb`,含 14 条受 hash 约束记录。 +- `VDB-011`:建立 native/desktop CMake preset,OpenVDB 只在该目标开启;固定 Blender 5.2、 + OpenVDB/NanoVDB 版本并生成可执行文件 hash。状态:`done_current_scope`;desktop target 实际 + 链接 OpenVDB 13.0/TBB 并生成 NanoVDB 32.9,浏览器仍保持 `WITH_OPENVDB=OFF`。 +- `VDB-012`:实现 grid inventory 预扫、allowlist、active voxel/内存/输出预算和取消,调用官方 + OpenVDB/NanoVDB API 写出确定性 `.nvdb`。状态:`done_current_scope`;inventory、allowlist、源/输出/ + grid/active voxel 预算、LOSSLESS/FP16、report、确定性、cancel-file、timeout 和失败原子清理已验证。 +- `VDB-013`:实现服务端 job API:content hash 幂等、上传/转换/下载、进度、超时、取消、日志、 + 不可信输入隔离和输出签名。状态:`done_current_scope`;当前以 bubblewrap、只读 root、资源限制、 + HMAC 构件签名和原子输出为受控本地服务基线。 +- `VDB-014`:桌面与服务端对同一 fixture 的 manifest/bundle hash 相同;版本变化必须导致缓存 + key 变化。状态:`done_current_scope`;语义相同的 VDB、官方 sphere 重转及桌面/隔离服务端 + 转换均得到相同 NanoVDB hash。 + +### Phase 2:存储与流送 + +- `VDB-020`:实现项目 asset manifest 和 source/bundle binding,支持 source hash 变化失效。 + 状态:`done_current_scope`。 +- `VDB-021`:实现 OPFS range source、staging/journal/atomic commit、重启发现和 LRU page cache。 + 状态:`done_current_scope`;已验证真实 bundle、取消清理、Worker 重开、tamper gate 和 bundle LRU。 +- `VDB-022`:实现 HTTP Range 重试、ETag/If-Range、断点续传、重复/乱序/短响应拒绝。 + 状态:`done_current_scope`;临时状态/网络异常重试、稳定 ETag、response-body 精确偏移续传、 + ETag 改变、错位 `Content-Range`、短响应和逐块 hash 均有 Chromium 故障门。 +- `VDB-023`:增加网络中断、Worker terminate、quota、tamper、manifest rollback 测试。 + 状态:`done_current_scope`;网络/body 中断、Worker terminate、真实 OPFS quota、chunk tamper、 + manifest rollback、incomplete staging recovery 和旧 bundle 保持均已验证。 +- `VDB-024`:在 512 MiB CPU、1 GiB bundle、512 MiB GPU 上限下验证峰值;不得整包复制到 + JS heap。`planned`。 + +### Phase 3:WebGPU NanoVDB + +- `VDB-030`:加入 Chromium WebGPU capability probe、adapter/device limits 和 device loss 状态机。 + 状态:`done_current_scope`;真实 adapter/device limit probe、device loss、device 重建和重传后 + 固定点 sample 一致已完成;生产视口资源重建仍归 `VDB-034`。 +- `VDB-031`:实现 NanoVDB Float/Vec leaf traversal WGSL 和 CPU reference sampler;用固定 index + 点比较值与 active/inactive 状态。状态:`in_progress`;Float32 CPU/WGSL 与 native sample 已逐点 + 一致,Float16/Vec 仍保持阻断。 +- `VDB-032`:实现 GPU page allocator、storage buffer upload、indirection、resident LRU 和 + dispose;每页上传前已通过 chunk hash。`BLOCKED`。 +- `VDB-033`:实现对象 bounds raycast、front-to-back ray marching、步长/early exit 和深度合成。 + 状态:`in_progress`;有界正交 bounds、front-to-back integration、early exit 和确定性 96x96 + Chromium 图像已完成,生产相机深度合成待接入。 +- `VDB-034`:接入主线程/Offscreen renderer 的相同 volume scene delta;device loss 后重建已 + 验证页面。`BLOCKED`。 + +### Phase 4:材质、Main 与导出 + +- `VDB-040`:读取 Blender Volume/Principled Volume 节点并生成显式 material mapping/loss report。 + 状态:`in_progress`;density、常量 color/emission、anisotropy、nearest/linear 已映射, + color/temperature/emission grid 和 velocity 明确生成 loss。 +- `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-043`:GLB 明确报告 Volume 无核心映射;USD 仅在 desktop USD/OpenVDB 路径真实可用时 + 写入 field asset,不把 bounds proxy 当体积导出。`BLOCKED`。 + +### Phase 5:真实验收与发布 + +- `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,记录容差与色彩空间。 +- `VDB-052`:64 MiB/512 MiB/1 GiB sparse bundle 的首帧、渐进清晰、峰值 CPU/GPU 和取消门。 +- `VDB-053`:损坏 magic/version/tree offset/hash、zip bomb 等价超预算、NaN transform、设备丢失、 + 网络中断和 OOM 门。 +- `VDB-054`:保存重开、资源缺失、源变更重转、旧 schema migration、离线 OPFS 重开。 +- `VDB-055`:N-026 证据记录包含真实转换器、资源、Chromium WebGPU、desktop golden 和构件 hash。 + +Phase 5 全部任务当前为 `BLOCKED`,不能由 Phase 0 的协议 fixture 代替。 + +## 7. 验收命令与停止条件 + +当前可运行门: + +```bash +npm --prefix web run typecheck +npm --prefix web run test:vdb +npm --prefix web run test:vdb-availability +npm --prefix web run test:vdb-native +npm --prefix web run test:vdb-server +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 +``` + +阶段停止条件: + +- 新增资源若无许可证/来源/hash:不得进入 VDB catalog 或 golden。 +- desktop converter 已构建;server job API 未实现时 `SERVER_CONVERSION` 保持 `BLOCKED`。 +- 无 CPU reference sample 对照:WGSL traversal 不得进入 `READY`。 +- 仅能显示 box/point proxy:体渲染仍为 `BLOCKED`。 +- 未验证 material mapping、Main 保存重开或 desktop/Chromium golden:N-015 与 N-019 仍为 + `BLOCKED`。 +- Chromium 是当前唯一浏览器门;不配置 Firefox/WebKit,也不把未测浏览器写成兼容。 diff --git a/docs/status/N-015.md b/docs/status/N-015.md index 21c1d26e..05d24579 100644 --- a/docs/status/N-015.md +++ b/docs/status/N-015.md @@ -1,15 +1,21 @@ # N-015 非 Mesh 几何数据块 -状态:`in_progress`(本地有界切片已落地,VDB renderer/完整交互仍阻断) +状态:`BLOCKED / in_progress`(VDB core 已覆盖 server、OPFS、Float32 WebGPU 与 Main 属性重开; +生产视口、高级材质、分页和发布 golden 仍阻断) -更新时间:2026-08-12 +更新时间:2026-08-14 ## 当前声明 当前声明覆盖 Blender 5.2 `.blend` 中有限非 Mesh 数据的稳定识别、Main 权威写回、undo/redo、 保存重开、真实 evaluated mesh 报告和 WNM 二进制分块。Curve cyclic/handle 已有有界 -属性读写、多目标选择和单事务平移提交,未声明完整 topology editor、连续 gizmo preview、 -VDB 体渲染或完整 Volume USD loss fixture。 +属性读写、多目标选择、连续临时预览、handle-local gizmo 和单事务平移提交;未声明完整 +topology editor。 +Volume 采用“桌面/服务端 OpenVDB 转 NanoVDB,浏览器分块读取并以 WebGPU 渲染”的固定架构。 +浏览器不直接解码 OpenVDB;当前已完成真实资源 catalog、独立 OpenVDB 13 -> NanoVDB 32 desktop +converter、受控 server job、转换/清单/分块协议、OPFS 绑定/重开、Float32 CPU/WGSL 树遍历、 +有界体积积分和 Volume Main 属性保存重开。主线程/Offscreen 生产视口、高级 grid 材质、GPU +分页和发布 golden 尚未完成,因此 N-015 整体保持 `BLOCKED`。 | 数据族 | Reader | Web 预览 | 当前门 | | --- | --- | --- | --- | @@ -19,7 +25,7 @@ VDB 体渲染或完整 Volume USD loss fixture。 | 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 | 项目内 OpenVDB path/grid metadata | 无 renderer;可取消 decoder API | `NON_MESH_RESOURCE_MISSING` 或预算阻断 | +| Volume | path/grid metadata + VDB/NanoVDB 转换契约 | WebGPU core 可渲染真实 Float32 density;生产视口未接入 | `BLOCKED` | ## 已实现任务 @@ -32,7 +38,8 @@ VDB 体渲染或完整 Volume USD loss fixture。 5. `NonMeshGeometryChunk` WNM schema 1 提供 65,536 点分块、SHA-256、属性 domain/storage、 256 MB 场景预算和 1M 点性能门;Worker/主线程/Offscreen 均传输并消费分块。 6. native reader 读取 PointCloud/Curves/Hair 的 position、radius、curve offset 和属性值, - 不以摘要计数伪造点;Volume 提供项目路径、OpenVDB grid metadata 和可取消 decoder。 + 不以摘要计数伪造点;Volume 保留项目路径和 OpenVDB grid metadata,raw `.vdb` 必须提交桌面/ + 服务端转换,浏览器不接 OpenVDB decoder。 7. depsgraph 对 Curve/Surface/Font/Metaball 通过 `BKE_mesh_new_from_object` 输出真实位置、法线、 边/三角索引、UV、材质槽和 source face/edge mapping,并在 1M/2M 拓扑预算处结构化阻断。 Web ID registry 已注册 `ID_VF` 并在加载时恢复 Blender 未写入 `.blend` 的 `` 字体 @@ -45,15 +52,17 @@ VDB 体渲染或完整 Volume USD loss fixture。 Metaball element 写回,以及 bounded Poly Curve create、delete、1D Poly/Bezier/NURBS conversion、 二维 Surface U/V dimensions/order/rational weight 网格替换和 Curve/Surface/Font/Metaball 数据块 重命名;一维多 spline 创建/删除/重排和批量 handle/cyclic 已由单个原子 Main 事务覆盖。 - 多 handle 选择、高亮和有界平移已由单个 Main transaction 覆盖,连续拖拽 preview 与 - handle 专用 gizmo UI 仍阻断。单用户、undo/redo、save/reopen 由 + 多 handle 选择、高亮和有界平移已由单个 Main transaction 覆盖;连续拖拽在主线程与 + Offscreen Three 场景中做绝对坐标 preview,pointer cancel 恢复且 pointer up 只提交一次 Main。 + handle-local gizmo 以所选 handle 质心为原点、控制点到 handle 的对齐平均切向为 X 轴, + 构造稳定正交 Y/Z 轴并在两个 renderer 中投影。单用户、undo/redo、save/reopen 由 `check-nonmesh-roundtrip.mjs` 验证。 9. 主线程与 OffscreenCanvas 对 PointCloud/Curve 控制点和 Bezier handle 代理提供 bounded raycast,并对 Mesh 的 VERT/EDGE/FACE 使用一致的三角顶点、边索引解析,回传既有 selection callback。selection history schema 2 覆盖跨对象、多 data block、多 element kind、revision/stale-hit、undo/redo 和有界 selected/unselected range patch,并兼容 schema 1。Bezier 左/右 handle 以全局控制点 映射独立拾取;多 handle 选择、高亮和有界平移经精确 Main 命令写回并通过 undo/redo、 - save/reopen。完整连续 gizmo preview 仍未声明。 + save/reopen。连续 preview、局部轴投影和局部轴 Main delta 已接通。 10. `queryNonMeshCapability(dataId)` 返回 N-015 `READY/BLOCKED` 与机器可读错误码;GLB 对未求值 非 Mesh 拒绝导出。Font/Metaball/二维 NURBS Surface 的 depsgraph 三角网格与 Curve 的 evaluated edge line primitive 已通过 GLB 导出和 Blender 5.2 桌面回导几何校验;零三角且零边的结果仍返回 @@ -62,31 +71,52 @@ VDB 体渲染或完整 Volume USD loss fixture。 已通过 USD-enabled Blender 5.2.0 LTS desktop reimport。PointCloud 使用 GLB `POINTS`/USD `UsdGeomPoints`,Curves/Hair 使用 GLB `LINES`/USD `UsdGeomBasisCurves`;radius 映射 USD widths,typed WNM 属性映射 primvars,GLB 属性损失结构化报告,7 对象 desktop reimport 已通过。 - Volume loss fixture 仍由 VDB 资源/renderer 阻断。 + Volume 不生成 proxy 导出;GLB/USD 映射与 loss fixture 重新列入阻断切片。 11. Blender 5.2 fixture 已包含真实 PointCloud/Curves 属性;Chromium 主线程/Offscreen、真实 depsgraph、WNM 1M 性能测试均接入。 -12. `CurveGizmoDragIR` 为 PREVIEW/COMMIT 请求绑定 data ID、base revision、轴向 delta 和最多 - 256 个去重 handle identity;现有 UI gizmo 在单次 pointer commit 时先执行 stale revision、 - 重复 handle、有限坐标和预算校验,再合并为一次 `setCurveTopology` Main 事务。连续 preview - 仍未开放。 -13. `test:vdb-availability` 确认当前 WASM build 为 `WITH_OPENVDB=OFF`、 - `WITH_NANOVDB=ON` metadata-only,项目和本机资源库没有可用 `.vdb`。因此真实 decoder、 - renderer 和 Volume loss fixture 继续 `BLOCKED`。 +12. `CurveGizmoDragIR` 为 PREVIEW/COMMIT 请求绑定 data ID、base revision、局部 axis vector、 + 轴向 delta 和最多 256 个去重 handle identity;UI gizmo 在单次 pointer commit 时先执行 stale revision、 + 重复 handle、有限坐标和预算校验;pointer move 仅更新 renderer 临时坐标,pointer cancel + 恢复,pointer up 再合并为一次 `setCurveTopology` Main 事务。分块控制点会先按 point offset + 重组为有界 `Float32Array`,因此内联和二进制 Curve 使用同一局部坐标契约。 +13. 当前 WASM build 保持 `WITH_OPENVDB=OFF`。`VDBConversionRequestIR` 记录源 hash、grid + allowlist、量化、chunk size、desktop/server converter identity;`NanoVDBBundleManifestIR` + 校验 grid type/class/semantic、transform/bounds、材质映射、连续 32-byte 对齐 range、逐块与 + 整包 SHA-256、CPU/GPU 预算。raw VDB 浏览器门固定返回 `VDB_CONVERSION_REQUIRED`。 +14. 浏览器 `streamNanoVDBChunks` 已按 range 串行读取、逐块复验和报告进度;HTTP adapter 严格要求 + `206 Content-Range`,对临时状态/网络异常有界重试,并以稳定 ETag/`If-Range` 从中断 body 的 + 精确偏移续传;ETag 改变、错位 range 和短响应均阻断。协议 fixture 不冒充 native decoder。 +15. 独立 `tools/vdb` native target 已实际链接 OpenVDB 13.0/TBB,调用 NanoVDB 32.9 adapter + 转换真实多 grid fog、level set、大 bounds 稀疏体和 CC-BY-4.0 官方 sphere;转换 report 记录 + segment/payload range、type/class、transform、bounds 和 active voxel。资源库 14 条记录均绑定 + license/origin/byte length/SHA-256;截断 VDB 被真实 reader 拒绝,语义相同但 UUID 不同的源得到 + 相同 NanoVDB bundle hash。 +16. 受控 server job 已覆盖 content-hash 幂等、bubblewrap/只读 root/资源限制、进度、超时、取消、 + 原子输出和 HMAC 构件签名;相同输入的 desktop/server bundle hash 一致。 +17. OPFS 已覆盖逐块复验、staging 原子提交、内容去重、取消/残留恢复、source/converter/shader + binding 失效、Worker 重开和 bundle LRU;真实 15 MiB NanoVDB bundle 已通过重开复验。读取时 + 再验 chunk hash,chunk tamper、有效结构的 manifest rollback、真实 quota 和旧 bundle 保持均通过。 +18. Float32 NanoVDB 已有 CPU reference 与 WGSL root/internal/leaf 遍历,native/CPU/GPU 固定点值和 + active 状态一致;Chromium WebGPU 有界积分输出确定性 96x96 image hash。 +19. Volume source/display/interpolation/step/velocity 属性已写入 Blender Main,并通过 undo/redo、 + save/reopen;它不等于 Principled Volume 完整节点语义。 ## 后续分解 -1. N-015-B3:接入真实 OpenVDB decoder 与体素纹理/射线步进;在此之前保持 Volume renderer 阻断。 -2. N-015-C1:连续多 handle 拖拽 preview 与专用 gizmo UI;多 handle 选择、高亮、单事务平移、 +1. N-015-C1:按 handle 本地原点/方向放置的专用 gizmo UI、连续多 handle 拖拽 preview、 + 多 handle 选择、高亮、局部轴单事务平移、 多 spline 创建/删除/重排、批量 handle/cyclic editor transaction 和二维 Surface U/V - topology/order/rational weight 网格替换已完成。 -3. N-015-C2:任意外部路径的新字体导入与资源沙箱;已有/packed VFont 的四 style link Main + topology/order/rational weight 网格替换已完成;完整 topology editor 不在本切片声明内。 +2. N-015-C2:任意外部路径的新字体导入与资源沙箱;已有/packed VFont 的四 style link Main 写回、字符级样式、kern/material 与 textbox 已完成并通过 PFB desktop/WASM golden。 -4. N-015-D1:跨对象 selection history、多目标 element identity、range patch、左右 handle - raycast 和多 handle Main 写回已完成;连续拖拽 preview 归入 C1 阻断。 -5. N-015-D2:evaluated preview 与源控制笼分层显示、WebGPU 等价。 -6. N-015-E1:补齐 Volume 的完整 loss fixture;PointCloud/Curves/Hair、Curve line bake、真实 - 4x4 NURBS Surface mesh、desktop geometry golden、7 对象 GLB 与 USDA desktop round-trip 已通过。 -7. N-015-E2:100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quota;Chromium +3. N-015-D1:跨对象 selection history、多目标 element identity、range patch、左右 handle + 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 和 Main Volume 属性重开已完成; + 继续执行生产 GPU paging、Principled Volume grid 语义、GLB/USD loss、大 bundle/OOM 和 + desktop/Chromium golden,未完成项保持阻断。 +6. N-015-E2:100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quota;Chromium 64 KiB 真实 quota、失败后旧 revision 保持、Worker restart 恢复已通过。后续浏览器验收 仅以 Chromium 为基线,不配置 Firefox/WebKit。 @@ -95,7 +125,9 @@ VDB 体渲染或完整 Volume USD loss fixture。 - `summary-only` 不得进入 `READY`。 - legacy 控制折线不得标记为 evaluated/tessellated curve。 - Metaball sphere proxy 不得用于导出或保存为求值曲面。 -- Volume decoder/renderer、Hair 属性 buffer 缺失时不得生成占位几何。 +- Volume/VDB 不得生成占位几何;协议 fixture 不得作为真实 decoder/renderer 证据。 +- 浏览器构建不得通过开启 `WITH_OPENVDB` 绕过 desktop/server 转换边界。 +- 生产视口、GPU paging、完整材质、故障门和发布 golden 未齐前,VDB 与 N-015 保持 `BLOCKED`。 - Metaball sphere proxy 不得进入 GLB;GLB 只能消费 depsgraph evaluated mesh。 - 任意新增 Main authoring 命令在 save/reopen 与 undo/redo 完成前不得开放 UI。 @@ -117,8 +149,9 @@ BLENDER_BIN=/path/to/usd-enabled/blender \ npm --prefix web run test:nonmesh-usd-blender-roundtrip npm --prefix web run test:nonmesh-binary npm --prefix web run test:nonmesh-interaction -npm --prefix web run test:vdb-availability npm --prefix web run test:vdb +npm --prefix web run test:vdb-availability +npm --prefix web run test:vdb-native 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 d0c07690..3908db02 100644 --- a/docs/status/N-016.md +++ b/docs/status/N-016.md @@ -1,7 +1,7 @@ # N-016 Grease Pencil 状态:`BLOCKED`(协议、有限 reader/Main transaction、当前帧及相邻帧 onion preview 已落地; -完整 2D/3D 编辑器、modifier 语义和桌面 golden 仍阻断) +完整 2D canvas/Dope 编辑、modifier 语义和桌面 golden 仍阻断) ## 已验证切片 @@ -28,12 +28,24 @@ 有界 X 轴平移;协议先校验 point identity、revision、有限坐标和 1M 点预算,再保留 radius、 opacity、vertex color、cyclic、material index 并合并为一个 `setGreasePencilStrokes` Main transaction。它是点级整帧原子写回,不等同于完整 2D 画布选择或连续 gizmo。 +8. N-016-D(视口点编辑部分):当前 drawing 为每条 stroke 建立独立 Three `Points` 代理, + 主线程和 OffscreenCanvas 使用 data/layer/frame/stroke/point 五级身份 raycast;Edit Mode + 可高亮、Shift/Ctrl 多选同一 drawing 的点,并由轴向 gizmo 合并为一个 revision-bound + `setGreasePencilStrokes` Main transaction。跨 drawing 追加会重置选择,避免错误混写。 +9. N-016-D(连续预览部分):轴向 gizmo 的 pointer move 从当前 SceneIR 绝对计算选中点坐标, + 同时更新真实 stroke 线和隐藏 point raycast proxy;pointer cancel/commit 均恢复预览基线, + pointer up 才提交一次 Main。主线程与 OffscreenCanvas 重复回归一致。 +10. N-016-D(时间线部分):现有 Dope Sheet 在活动 Grease Pencil 对象上显示真实 layer drawing + frame 并通过既有 `setFrame` Main 命令导航;不提供帧拖拽、重排或插值伪语义。 +11. N-016-D(重启部分):真实 Grease Pencil fixture 经首个 WebEngine Worker 点编辑和 `.blend` + 保存后,终止 Worker 并由全新 Worker 重开;data/layer/drawing identity、位置、radius、opacity + 均重新解析验证。 ## 仍然阻断 - N-016-C:material datablock 事务、onion fade/range 完整语义和完整 Grease Pencil modifier reader。 -- N-016-D:2D 画布 raycast/marquee、多点可视选择、timeline/dope integration、连续 gizmo 和 - worker restart;3D current-frame stroke viewport 与 Properties 单点原子平移已完成。 +- N-016-D:完整 2D canvas/marquee 和完整 timeline/dope 编辑;3D current-frame 点 raycast、 + 多点高亮、连续 preview、单次 gizmo commit、有界 drawing frame 导航和 worker restart 已完成。 - N-016-E:desktop drawing hash、Chromium 像素 golden、GLB/USD loss report 和 OPFS。 ## 验收 diff --git a/docs/status/N-017.md b/docs/status/N-017.md index c3997157..ef248cd8 100644 --- a/docs/status/N-017.md +++ b/docs/status/N-017.md @@ -1,7 +1,7 @@ # N-017 Paint 与权重 -状态:`BLOCKED`(stroke/patch schema、真实 Three raycast 命中、Main 顶点色/权重 transaction -已落地;PBVH brush、texture paint 和 GPU/image 生命周期未实现) +状态:`BLOCKED`(stroke/patch schema、真实 Three raycast 命中、选择/遮罩门和 Main 顶点色/权重 +transaction 已落地;PBVH brush、texture paint 和 GPU/image 生命周期未实现) ## 已验证切片 @@ -25,15 +25,25 @@ front-face 法线门;它不宣称 PBVH 加速或桌面 brush 等价。`UdimTilePatchIR` 约束 1001-1999 tile、RGBA8、色彩空间、尺寸/256 MiB 预算、revision、base/result SHA-256,并在内存中验证 原子 range patch;尚未绑定 Blender packed image/UDIM Main 写回与保存。 +9. N-017-A(空间查询部分):uniform-grid spatial index 在 1M vertex/1M cell 预算内按 brush AABB + 粗筛候选,再执行球半径、front-face、falloff 精筛;depth-gated 查询必须显式提供已验证可见 + vertex ID 集合,缺失或重复会拒绝。该结构减少全量扫描,但不是 Blender PBVH。 +10. N-017-A/B(选择与遮罩部分):brush query 可要求稳定 vertex identity 的 selection 硬门,并以 + `[0,1]` mask weight 乘入 falloff;重复、未知或越界 identity 会拒绝。颜色和权重 patch 以当前 + revision、当前值、目标值及 brush weight 确定性混合,过期 revision 不会提交。 +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、保存重开后保持。 ## 仍然阻断 -- N-017-A:真实 PBVH 加速结构、由深度缓冲生成的遮挡标记,以及 brush falloff 与桌面语义的 - golden 对照;基础 Mesh/UV raycast hit 和有界 CPU 候选 falloff 已完成。 +- 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-D/E:armature golden、seam bleed、mask/selection、GPU dispose、quota、坏图和 UI。 +- N-017-D/E:armature golden、seam bleed、face mask、GPU dispose、quota、坏图和桌面 UI 对照; + vertex selection 与数值 mask 门已完成,不等同于完整 Paint 面/纹理遮罩系统。 ## 验收 diff --git a/docs/status/N-018.md b/docs/status/N-018.md index 266561c4..1be17431 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 与错误帧门已落地; -cache playback、WASM solver 和 bake job 未实现) +状态:`BLOCKED`(family capability、settings/dependency/cache manifest、错误帧门及浏览器自有 +BTF1 播放会话已落地;desktop bake playback、WASM solver 和 bake job 未实现) ## 已验证切片 @@ -24,12 +24,22 @@ cache playback、WASM solver 和 bake job 未实现) frame、对象 stable ID、精确字节长度、有限 translation、单位 quaternion、非零 scale 和重复 ID。该格式仅是浏览器自有的 cache playback primitive,不是 Blender desktop bake family payload,因此 capability inventory 的 `cachePlayback` 仍为 `BLOCKED`。 +8. N-018-C(预览适配):精确帧匹配的 BTF1 可不可变地映射到 SceneIR preview,按 stable object + ID 更新 translation/quaternion/scale、Euler、local/world matrix 和当前帧;未知对象、错帧、 + 层级环或缺父对象拒绝。该适配只证明浏览器自有 transform cache,不改变 desktop family + bake playback 的 `BLOCKED` 状态。 +9. N-018-C(浏览器播放会话):BTF1 支持有界范围顺序播放、精确 seek 和 cancel;新请求会使旧 + 异步读取失效,即使底层读取器忽略 `AbortSignal`,迟到结果也不会覆盖当前预览。每帧都从同一 + 不可变基准 SceneIR 应用绝对变换,避免逐帧累计误差。 +10. N-018-C(持久缓存集成):两帧真实 BTF1 经过 content-addressed Simulation cache 写入,终止 + Storage Worker 后由新 Worker 精确 range 读取第二帧并应用到 SceneIR;帧 hash、magic、offset + 和最终 object translation 均验证。 ## 仍然阻断 - N-018-A/B:从真实 Main 提取完整 family settings、collection/effector/collision 依赖。 -- N-018-C:desktop bake payload 的逐 family decoder、与 depsgraph/viewport 的 frame playback - 连接和 100 帧 golden;浏览器自有 transform frame decoder 已完成但不替代 Blender bake。 +- 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-E:bake start/cancel/commit、服务端 job、故障恢复、进度与 UI。 @@ -41,6 +51,6 @@ npm --prefix web run test:simulation-cache npm --prefix web run test:physics-main-reader ``` -本轮在 Chromium 隔离端口复验 content-addressed cache 的 Worker restart、目标帧 range read 与 -帧 SHA-256 门通过;没有 desktop bake family decoder 或 solver,因此 playback/solver/bake 状态 -不变,继续为 `BLOCKED`。 +本轮在 Chromium 隔离端口复验 content-addressed cache 的 Worker restart、目标帧 range read、 +帧 SHA-256、BTF1 seek/play/cancel 和迟到结果抑制通过;没有 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 13b38804..66e1e42f 100644 --- a/docs/status/N-019.md +++ b/docs/status/N-019.md @@ -13,20 +13,29 @@ Three exposure/shadow 映射已落地;Scene 颜色管理 writer 和渲染等 3. N-019-B(部分):主线程和 OffscreenCanvas renderer 都优先使用 Scene color-management exposure,World exposure 仅作旧数据回退;Light exposure 转换为强度,`castsShadow:false` 不再被 Three 强制打开。 -4. 白平衡读取有完整性门:Main Light/World 重写若导致 Blender 5.2 tint 序列化为异常近零值, +4. N-019-B(部分):World/Scene Main delta 保留版本化集合,主线程与 Offscreen renderer + 对环境颜色、曝光和 scene render settings 变更触发确定性的 renderer rebuild;未知 delta + 版本和重复 ID 继续拒绝。 +5. 白平衡读取有完整性门:Main Light/World 重写若导致 Blender 5.2 tint 序列化为异常近零值, 不暴露垃圾数值而返回 `whiteBalanceStatus: BLOCKED`。 -5. Camera 严格白名单写回覆盖 Perspective/Orthographic、lens/sensor/sensor fit、shift、clip、 +6. Camera 严格白名单写回覆盖 Perspective/Orthographic、lens/sensor/sensor fit、shift、clip、 ortho scale 和 DOF enable/focus distance/f-stop/blades/rotation/ratio;near/far 与范围在修改前 拒绝,已通过 undo/redo 和 save/reopen。 +7. N-019-B(色温部分):`useTemperature:true` 时,共享 PBR 适配器把 800–20000 K 的有界 + 黑体近似归一到 6500 K 中性白并转换为线性 RGB,再乘入 Light color;关闭色温时原始颜色 + 保持不变。主线程与 Offscreen Worker 共用该实现,数值门和原生 temperature 保存重开通过。 ## 仍然阻断 - N-019-A:Scene color-management writer;直接 DNA 写入会破坏 white balance,必须改接 Blender/RNA 颜色管理 API 后才能开放。Camera writer 已完成。 -- N-019-B/C:AgX/Standard/Raw 的视觉等价、temperature 颜色、Area spread、Mist、DOF、 +- 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-B/C/E(Volume):Float32 NanoVDB WGSL 树遍历和有界 density ray integration 已有真实 + Chromium 数值/图像专项门;GPU page allocator、生产视口深度合成、temperature/color/emission + grid 语义和 desktop/Chromium 三视角 golden 仍阻断,不能据此声明发布级体渲染。 ## 验收 diff --git a/docs/status/N-020.md b/docs/status/N-020.md index c5d08810..9d34fd40 100644 --- a/docs/status/N-020.md +++ b/docs/status/N-020.md @@ -18,12 +18,23 @@ identifier、socket identifier 和 link;活动 Group Output/旧 Composite 映射为 Composite, Viewer 和常量 RGBA 映射到已验证节点,其他节点原样保留 `blenderType` 为 Unsupported。 4096 nodes/16384 links、重复 destination、缺 output 或不可读 node tree 返回 blocked status。 +6. N-020-C(部分):CPU executor 在长像素/blur 循环中周期检查 cancellation,不再只在节点 + 边界取消;内容寻址 frame key 绑定规范化 GraphIR、资源 Float32 内容 SHA-256、frame 和输出 + 尺寸。有界 LRU cache 以克隆后的真实 buffer 字节计费(最多 256 MiB),命中返回隔离副本, + 调用方修改结果不会污染缓存。它是内存帧缓存,不等同于 tile scheduler 或 OPFS 持久缓存。 +7. N-020-A/B(真实参数链):Blender 5.2 Main reader 从真实 socket default 读取 Exposure,并仅在 + factor=1、invert color=true、invert alpha=false 时把 Invert 映射到当前 CPU 精确子集;Group + Output 的动态 `Socket_0` 规范化为 GraphIR `Image`。桌面生成 fixture 经 WASM/Worker 后直接由 + CPU executor 求值 Constant→Exposure→Invert→Composite,RGBA 结果通过;未连接的 Glare 仍保留 + Unsupported 并使完整图 capability gate 保持阻断。 ## 仍然阻断 -- N-020-A:Main graph 写回,以及 Transform/Invert/Exposure/Alpha Over/Blur/Mix/Image/Render Layer - 的逐节点参数与 resource/pass reader;基础真实图结构、常量色、Viewer/Composite 已完成。 -- N-020-B/C:WebGPU executor、tile/frame cache、增量 invalidation 和 GPU dispose。 +- 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-D:服务端 Blender job、source hash 和结果提交。 - N-020-E:desktop HDR/alpha/color-space golden、OOM/fault/device-loss。 diff --git a/docs/status/N-021.md b/docs/status/N-021.md index fc0bb760..525c943e 100644 --- a/docs/status/N-021.md +++ b/docs/status/N-021.md @@ -12,17 +12,24 @@ Main 写回、媒体解码/渲染、音频波形和服务端编码仍未实现 timeline;编辑后重新解析依赖和预算,锁定 strip 与 stale revision 会拒绝。 3. N-021-B/C:`sequencerSourceFrame` 对 speed、trim、split 使用确定性的 source-frame 映射,避免左右片段复用越界源帧。 -4. N-021-C/D(门):运行时只报告 WebCodecs/HTMLMedia 需要精确 probe;codec 必须 +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-A(部分):Blender 5.2 `Scene.ed/Editing.seqbase` 读取 Scene、Movie、Image、Sound、 +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。绝对路径、未知类型、 坏依赖或超预算将 Scene sequencer 标为 `BLOCKED`。 +7. N-021-B(transition 子集):CROSS/GAMMA_CROSS 可按半开显示区间确定性解析 `[0,1)` 进度, + 保持 Blender `input1,input2` 顺序,并为两个活动依赖返回各自 source frame;越界、依赖缺失、 + muted 输入和非 cross effect 拒绝。 +8. 桌面 Blender 5.2 fixture 经 WASM/Worker 后,在真实 CROSS 中间帧得到 factor 0.5;两个单张 + Image strip 的 source range 为 `0..1`,显示 20 帧仍固定到 source frame 1,不伪造成图像序列。 ## 仍然阻断 -- N-021-A/B:Main strip 写回、transition/modifier 完整参数、undo/redo 和 save/reopen; - 基础真实 reader 已完成。 +- 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。 diff --git a/docs/status/N-022.md b/docs/status/N-022.md index dbf368c0..e621118a 100644 --- a/docs/status/N-022.md +++ b/docs/status/N-022.md @@ -13,17 +13,26 @@ 修改使用 revision 事务,锁定项和 stale revision 会拒绝,提交后再次完整解析。 4. N-022-C(门):marker/mask schema edit 可用;browser tracking 只有显式 probe 成功后 放行,camera solve 保持 `BLOCKED` 并要求受验证的服务端 Blender。 -5. N-022-A(部分):Blender 5.2 Mask Main reader 输出 layer、spline 和 Bezier point,保留 +5. N-022-D(部分):按屏幕射线对可见、未锁定且非零透明度的 Mask Bezier 段做有界采样, + 返回稳定的 layer/spline/point/segment identity;精确点命中优先于段命中,未声明的 + selection/raycast 编辑器仍保持阻断。 +6. N-022-A(部分):Blender 5.2 Mask Main reader 输出 layer、spline 和 Bezier point,保留 cyclic/fill、viewport visibility、lock/hide-select、opacity、handle type/坐标、feather 与 selection;1024 layer、100k spline、1M point 预算在分配前检查。MovieClip 不能从外部路径 推导内容 SHA-256,继续阻断而不使用路径散列冒充源摘要。 +7. N-022-D(选择子集):Mask point marquee 支持 replace/add/toggle,验证矩形、已有 stable + identity、重复项和 1M point 预算;输出按 Main 中的 mask/layer/spline/point 顺序确定化,隐藏、 + 锁定和零透明度 layer 不会成为新选中项。 +8. Blender 5.2 fixture 同时包含锁定层和可编辑层,经 WASM/Worker 后实证锁定点不可 raycast, + 可编辑点返回稳定 identity,跨两层的 marquee 仅选择可编辑点,toggle 可确定性清空。 ## 仍然阻断 - N-022-A/B:真实 MovieClip reader、Mask/MovieClip 写回、undo/redo 和 save/reopen;Mask reader 已完成。 - N-022-C:浏览器 tracking 实现、完整 camera/plane solve 与服务端 job/hash 提交。 -- N-022-D:Clip/Mask editor、overlay、selection/raycast 和 compositor/scene 实际绑定。 +- N-022-D:完整 Clip/Mask editor、overlay、handle/segment marquee 与 compositor/scene 实际绑定; + point raycast 和 point marquee 的真实 Main 子集已完成。 - N-022-E:desktop solve/error golden、媒体故障和 Chromium 测试。 ## 验收 diff --git a/docs/status/N-023.md b/docs/status/N-023.md index 5d04b43a..3ee4ffcf 100644 --- a/docs/status/N-023.md +++ b/docs/status/N-023.md @@ -12,22 +12,38 @@ IO 安全门已落地;Append/Link/Override Main、非 GLB 本地导入和跨 content-addressed index 报告 `LOCAL_BOUNDED`,OPFS 只在运行时 API 存在时报告 `PROBE_REQUIRED`。 3. N-023-C(门):现有 GLB 导出与 USD semantic analysis 可放行;GLTF/OBJ/PLY/STL、 USD/Alembic 实际导入保持 `IO_FORMAT_UNSUPPORTED`,库 mutation 需要真实 Main。 -4. N-023-E:项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。 -5. N-023-B(部分):Blender 5.2 Main reader 输出 linked Library stable ID、项目相对路径、 +4. N-023-A/B(部分):预览字节先验 SHA-256 和 byte length,再校验 PNG signature/尺寸; + WebP 仅在 RIFF/WEBP 容器签名正确时进入后续解码门,不从元数据伪造预览内容。 +5. N-023-E:项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。 +6. N-023-B(部分):Blender 5.2 Main reader 输出 linked Library stable ID、项目相对路径、 packed/external 状态、只读标志和 archive-parent dependency;1024 library/每库 1024 dependency、 重复 ID、缺依赖、依赖环与项目外路径由 SceneIR 再校验。缺外部库字节时返回 `LINKED_LIBRARY_RESOURCE_REQUIRED`,不从路径伪造 SHA-256 或声称已加载。 +7. N-023-E(archive 结构门):除 traversal 与展开比预算外,重复规范路径、文件/目录前缀冲突、 + 累计压缩/解压字节和声明源长度不一致均拒绝;`planIOArchiveRanges` 按路径生成确定性、安全整数 + compressed offset 计划。它为后续流式解包提供边界,不代表已有 ZIP decoder。 +8. N-023-E(NanoVDB range 基础件):`.nvdb` manifest 要求连续、32-byte 对齐、逐块 hash; + HTTP source 只接受与 manifest 精确一致的 `206 Content-Range`,并按块校验后消费;临时错误重试、 + 稳定 ETag/If-Range、response-body 偏移续传、错位/短响应拒绝均已完成。OPFS 原子 + staging/commit、source/bundle binding、Worker 重开、tamper/rollback/quota recovery 和 bundle LRU + 已完成;大 bundle 性能仍未实现。 +9. VDB 独立资源库包含 generated/official/source/derived/report/manifest/license,14 条记录逐项绑定 + byte length 与 SHA-256;官方 sphere 使用 CC-BY-4.0 和 Git LFS 权威 hash,未放入仓库工作树。 ## 仍然阻断 - N-023-B:Append/Link/Library Override、reload/relocate 和真实 Main transaction;只读 library inventory 已完成。 - N-023-C/D:GLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。 -- N-023-E:zip fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件流式性能。 +- N-023-E:真实 zip decoder/fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件 + 流式性能;archive 路径冲突、双向字节预算与确定性 range plan 已完成。 +- N-023-E(VDB):GPU page resident LRU 和 64 MiB–1 GiB bundle 中断/内存性能门。 ## 验收 ```bash WEB_TEST_PORT=5323 npm --prefix web run test:e2e -- --grep "N-023 asset" npm --prefix web run test:library-main-reader +npm --prefix web run test:vdb +npm --prefix web run test:vdb-native ``` diff --git a/docs/status/N-024.md b/docs/status/N-024.md index c6b1b302..413ce0ba 100644 --- a/docs/status/N-024.md +++ b/docs/status/N-024.md @@ -1,7 +1,8 @@ # N-024 Editors 与工作流 -状态:`BLOCKED`(统一 context、真实 Main 只读布局清单、selection sync、keymap 和布局预算 -已落地;完整 Blender editor writer、运行时焦点、gizmo/触控和跨设备 golden 未实现) +状态:`BLOCKED`(统一 context、真实 Main 只读布局清单、selection sync、上下文 keymap、 +可执行 operator search 和布局预算已落地;完整 Blender editor writer、运行时焦点、 +gizmo/触控和跨设备 golden 未实现) ## 已验证切片 @@ -11,16 +12,24 @@ workflow state,rect 边界与不可重叠布局经过校验。 3. N-024-C(部分):selection sync 以 revision 事务更新 active object 与 selected IDs, active object 必须属于 selection,过期 revision 会拒绝。 -4. N-024-D(门):keymap 绑定和预算可验证;writer、gizmo 与 touch drag 保持能力阻断。 -5. N-024-B(Main reader):从 Blender `WorkSpace`、`WorkSpaceLayout`、`bScreen`、`ScrArea` +4. N-024-D(部分):key chord 统一规范化 modifier、key 大小写和排序,启用绑定的冲突会在 + resolver 层拒绝,禁用绑定不参与冲突;writer、gizmo 与 touch drag 保持能力阻断。 +5. N-024-D(门):keymap 绑定和预算可验证;writer、gizmo 与 touch drag 保持能力阻断。 +6. N-024-B(Main reader):从 Blender `WorkSpace`、`WorkSpaceLayout`、`bScreen`、`ScrArea` 和 `ARegion` 读取有界 workspace/area/region 清单、编辑器类型、可见性与归一化布局; 不完整或包含未知 editor 的 workspace 被跳过,首个完整 workspace/area 只作为确定性只读 context, 不声称恢复 Blender 运行时焦点。 +7. N-024-D(上下文 keymap):同一 key chord 可按 workspace、editor、mode 隔离;仅作用域 + 相交的启用绑定视为冲突,resolver 使用当前 workflow context 确定命令。 +8. N-024-C(operator search):F3 搜索结果来自当前 workspace/mode/Main 状态;命令可执行 + workspace/mode 切换、Add Cube、Apply Transform、Undo/Redo/Save。Chromium 用真实 `.blend` + 验证了 Edit Mode 隐藏 Object-only Add Cube,Object Mode 执行后 Main revision 只增加一次。 ## 仍然阻断 - N-024-B/C:Properties、UV/Image、Node、Graph、Dope Sheet、NLA、Spreadsheet 等 editor - 专属数据读取/写回、真实运行时焦点、selection history、operator search 和 context menu。 + 专属数据读取/写回、真实运行时焦点、跨 editor selection history 和 context menu;当前 + operator search 仅覆盖已注册的安全命令子集,不代表 Blender 全量 operator registry。 - N-024-D/E:Blender-compatible keymap 执行、gizmo/drag preview/commit、桌面/mobile/笔 触控布局、无重叠截图和 accessibility golden。 @@ -29,4 +38,5 @@ ```bash npm --prefix web run test:editor-main-reader WEB_TEST_PORT=5324 npm --prefix web run test:e2e -- --grep "N-024 editor" +WEB_TEST_PORT=5407 npm --prefix web run test:e2e -- --grep "operator search executes" ``` diff --git a/docs/status/N-025.md b/docs/status/N-025.md index 72f0a635..909f833d 100644 --- a/docs/status/N-025.md +++ b/docs/status/N-025.md @@ -1,7 +1,7 @@ # N-025 Scripting 与平台 状态:`BLOCKED`(默认拒绝策略、真实 Main Text 来源清单、签名 manifest、权限/资源预算、 -平台报告和服务端 hash 门已落地;本地隔离执行、真实 server job 与发布审计未实现) +平台报告、服务端 hash 门和请求决策审计已落地;本地隔离执行、真实 server job 与发布审计未实现) ## 已验证切片 @@ -19,13 +19,19 @@ 6. `test:scripting-isolation` 独立验证未批准 key 返回 `SCRIPT_SIGNATURE_INVALID`,批准 key 仍返回 `SCRIPT_SANDBOX_UNAVAILABLE`,server job 返回 `SERVER_JOB_UNAVAILABLE`;测试不创建 本地执行器,也不把签名校验误报为 sandbox。 +7. 每次本地脚本请求都可生成冻结的 `ScriptExecutionAuditIR`:绑定 canonical manifest/source + SHA-256、权限、CPU/内存/墙钟预算、批准 key 命中、UTC 时间戳、请求摘要和明确拒绝码; + 审计凭证仍只能是 `DENY`,不能绕过 sandbox 门。 +8. N-025-E(审计链):至多 65,536 条拒绝审计按 sequence、前项 SHA-256 与 canonical + entry SHA-256 串联;读取时重新校验请求摘要和整条链,拒绝 requestId 重放、非递增 UTC + 时间戳、内容篡改、断链与超预算日志。该链是内存/序列化协议,不声称已持久化发布审计。 ## 仍然阻断 - N-025-B/C:签名验证密钥管理、无网络 CPython/native sandbox、server Blender job 和 output hash 提交。 - N-025-D/E:真实 GPU/native window/file watcher 适配、恶意脚本/依赖混淆/逃逸/重放、 - 审计日志和发布门。 + 审计日志持久化和发布门;本地请求级审计凭证已完成,不代表隔离执行完成。 ## 验收 diff --git a/docs/status/N-026.md b/docs/status/N-026.md index 919cb9c3..4baea0f8 100644 --- a/docs/status/N-026.md +++ b/docs/status/N-026.md @@ -1,14 +1,14 @@ # N-026 全域发布门 状态:`BLOCKED`(machine-readable parity manifest、依赖/状态检查、命令/hash 证据绑定、 -Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据已落地;10M/大纹理/ -长媒体、simulation cache 性能、OOM/device loss/network interruption 与上游能力仍未齐) +Chromium 完整套件、离线包/OPFS、SPDX SBOM、1M geometry、simulation cache 性能和 +4K/8K texture、运行中断网和主线程 WebGL device loss 证据已落地;10M/长媒体、OOM 与上游能力仍未齐) ## 已验证切片 1. N-026-A:版本化 schema 3 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT`、 - `LOCAL_BOUNDED`、`SERVER` 或 `BLOCKED`、roadmap 状态、完成/阻断切片、验收命令和 - 依赖;缺失 family、非法非阻断状态或依赖环会拒绝。 + `LOCAL_BOUNDED`、`SERVER` 或 `BLOCKED`、roadmap 状态、完成/阻断/明确排除切片、验收命令和 + 依赖;同一切片跨状态重复、缺失 family、非法非阻断状态或依赖环会拒绝。 2. N-026-B/C:Chromium 主线程/OffscreenCanvas、offline/Worker restart/OPFS recovery、 1M/10M geometry、4K/8K texture、长媒体、simulation cache、OOM/device loss/网络 中断/损坏 blend/zip bomb 等证据字段必须逐项为 true 才能放行。 @@ -20,19 +20,41 @@ Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据 完整 suite 或其余 family 的桌面 golden。发布门 schema 3 仅接受 Chromium 浏览器证据, Firefox/WebKit 不在本项目当前测试配置内。 5. N-015 Curve/Surface/Font/Metaball 已通过 Blender 5.2 desktop geometry golden;这四类及 - PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 仍无 renderer - 与 loss fixture,这些证据也不替代其余 family 的 desktop golden。 + PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 已重新纳入, + 当前已有真实资源/license/hash、OpenVDB 13->NanoVDB 32 desktop 转换、manifest/range 和确定性 + 证据;server job、OPFS 重开、Float32 WebGPU core 和 Main Volume 属性保存重开已有专项证据, + 生产视口、GPU paging、GLB/USD loss 和 desktop/Chromium 发布 golden 仍计为阻断。 6. 当前发布包已通过本地 third-party notices、Blender/Three license 文件、无远程运行时依赖、 非空 SHA-256 manifest、SPDX 2.3 lockfile/vendored SBOM、对应源码提供和离线二进制/源码包 确定性复建;100k/1M decimate、5 类损坏 blend 和 archive 高压缩比拒绝有实际命令记录。 7. `docs/status/release-evidence.json` 记录 Chromium 完整 E2E + release suite 以及上述发布命令; `docs/web/sbom.spdx.json` 确定性覆盖 npm lockfile 与 notices 中显式 vendored/native 组件。 +8. simulation cache 性能门在 Chromium 中写入并重开真实 OPFS 内容寻址缓存,逐帧范围读取、 + SHA-256 复验、解码并发布 600 帧 BTF1/SceneIR;25 秒动画量级的写入加播放总耗时须小于 + 30 秒。该门衡量浏览器缓存管线,不声称 native physics 求解或 bake 性能已完成。 +9. network interruption 门在 Chromium 页面和 Worker 启动后切换真实 offline context,离线完成 + Main Add Cube、`.blend` 下载保存和保存文件重开,检查 revision 与 4 个对象均保持;该门不把 + 未实现的远端 job 断点续传计为完成。 +10. 主线程视口显式跟踪 `lost -> restoring -> ready` WebGL 状态;Chromium 使用 + `WEBGL_lose_context` 真正丢失并恢复 context,恢复后重新应用 PBR 配置、绘制非空像素并 + 完成一次 Main 编辑。OffscreenCanvas Worker device loss 尚未计入该证据。 +11. 4K texture 门在 Chromium 生成 4096x4096 PNG,经 SHA-256/维度/字节预算校验、 + `createImageBitmap` 解码、GPUTextureStore 和 Three.js WebGL 实际采样,要求非空彩色像素且 + 解码上传渲染小于 30 秒。 +12. evidence record 只能绑定 schema 中已知且值为 true 的字段;任何绑定字段的记录必须包含 + 至少一个唯一构件 SHA-256,manifest `generatedAt` 必须是可往返的 canonical UTC 时间戳。 +13. 8K texture 门先要求 WebGL `MAX_TEXTURE_SIZE >= 8192`,再对 8192x8192 PNG 执行同样的 + SHA-256、解码、GPU 上传和实际采样,独立 Chromium 进程内耗时须小于 45 秒。 ## 仍然阻断 -- 跨 family 桌面 golden、10M、4K/8K texture、长媒体、simulation cache 性能和 OOM/device loss/ - network interruption fault 仍无真实证据;本项目当前只配置 Chromium,上游 N-015 至 N-025 +- 跨 family 桌面 golden、10M、长媒体和 OOM fault 仍无真实证据; + 本项目当前只配置 Chromium,上游 N-015 至 N-025 的阻断能力会传递到发布门。 +- VDB 发布证据已覆盖 server job、HTTP 续传/OPFS 分块恢复、quota/tamper/rollback、Float32 WebGPU + 数值/专项像素、双生产视口、有限材质映射与 Volume Main 属性重开;仍缺 resident 分页、生产 + Offscreen device-loss 重建、OOM/大 bundle、完整材质和三视角 + desktop/Chromium golden。 - 完整 native dependency/license 审计仍需发行审核;当前 SBOM 覆盖 lockfile 和显式 notices, 不声称替代 Blender 全部传递依赖的发布级法律审计。 diff --git a/docs/status/parity-ledger.json b/docs/status/parity-ledger.json index 7a6b7162..e71d5965 100644 --- a/docs/status/parity-ledger.json +++ b/docs/status/parity-ledger.json @@ -1,17 +1,18 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-12", + "updatedAt": "2026-08-14", "source": "docs/BLENDER_5_2_WEB_FEATURE_PARITY.md", "statusEnum": ["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "BLOCKED"], "families": [ { "id": "N-015", "name": "Non-mesh geometry", - "status": "LOCAL_BOUNDED", + "status": "BLOCKED", "roadmapStatus": "in_progress", - "completedSlices": ["A1", "A2-malformed-binary-partial", "A2-integer-overflow-fixtures", "A2-multichunk-attribute-completeness", "B2", "B3-metadata", "B3-vdb-local-availability-assessment", "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-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", "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", "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"], - "blockedSlices": ["B3-vdb-renderer", "C1-continuous-handle-gizmo-preview", "C2-new-external-font-import", "E1-volume-loss-fixture"], - "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:vdb", "web:test:vdb-availability", "web:test:selection-history", "web:test:nonmesh-interaction", "web:e2e:N-015|non-mesh", "web:e2e:real OPFS quota"], + "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", "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-paging-advanced-material", "C3-volume-combined-asset-viewport-reopen", "E1-volume-glb-usd-loss-desktop-chromium-golden", "E2-vdb-large-stream-device-loss-oom"], + "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": [] }, { @@ -19,8 +20,8 @@ "name": "Grease Pencil", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", "D-editor-main-layer-frame-panel-partial", "D-single-point-revision-bound-main-translation"], - "blockedSlices": ["C-material-modifier-full-semantics", "D-full-2d-canvas-multipoint-dope-gizmo-restart", "E-desktop-browser-golden-export-opfs"], + "completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", "D-editor-main-layer-frame-panel-partial", "D-single-point-revision-bound-main-translation", "D-viewport-point-raycast-multipoint-gizmo-transaction", "D-continuous-point-preview-single-main-commit", "D-bounded-drawing-frame-dope-navigation", "D-worker-restart-save-reopen"], + "blockedSlices": ["C-material-modifier-full-semantics", "D-full-2d-canvas-marquee-dope-editor", "E-desktop-browser-golden-export-opfs"], "acceptance": ["web:test:grease-pencil", "web:test:grease-pencil-editor", "web:e2e:N-016 Grease Pencil"], "dependencies": ["N-015"] }, @@ -29,8 +30,8 @@ "name": "Paint and weights", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "A-bounded-cpu-candidate-falloff", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", "B-selected-vertex-color-weight-ui-transaction-partial", "C-udim-memory-range-patch-hash-boundary"], - "blockedSlices": ["A-real-pbvh-depth-occlusion-desktop-falloff", "B-clean-mirror-desktop-brush", "C-blender-packed-udim-dirty-atomic-save", "D-E-mask-selection-gpu-quota-golden-ui"], + "completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "A-bounded-cpu-candidate-falloff", "A-uniform-grid-candidate-depth-visibility-gate", "A-selection-mask-identity-gated-brush-patch", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", "B-selected-vertex-color-weight-ui-transaction-partial", "B-selection-masked-color-weight-main-transaction", "B-blender52-vertex-group-summary-save-reopen", "C-udim-memory-range-patch-hash-boundary"], + "blockedSlices": ["A-real-pbvh-depth-occlusion-desktop-falloff", "B-clean-mirror-desktop-brush", "C-blender-packed-udim-dirty-atomic-save", "D-E-face-mask-gpu-quota-desktop-golden"], "acceptance": ["web:test:paint-roundtrip", "web:e2e:N-017 paint"], "dependencies": ["N-016"] }, @@ -39,7 +40,7 @@ "name": "Physics and simulation", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-family-capability-inventory", "A-authoritative-main-physics-reader-partial", "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate", "C-browser-transform-frame-binary-decoder"], + "completedSlices": ["A-family-capability-inventory", "A-authoritative-main-physics-reader-partial", "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate", "C-browser-transform-frame-binary-decoder", "C-browser-transform-sceneir-preview", "C-browser-transform-cancellable-playback-session", "C-browser-transform-storage-restart-playback"], "blockedSlices": ["A-B-full-family-settings-effectors-cache-binding", "C-desktop-bake-decoder-playback-100-frame-golden", "D-wasm-solvers", "E-bake-server-recovery-progress-ui"], "acceptance": ["web:e2e:N-018 physics", "web:test:simulation-cache", "web:test:physics-main-reader"], "dependencies": ["N-017"] @@ -49,8 +50,8 @@ "name": "Lighting and render", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-camera-light-world-scene-reader-partial", "A-main-camera-dof-properties-partial", "A-main-light-world-properties-partial", "B-three-exposure-shadow-mapping-partial", "A-white-balance-integrity-gate"], - "blockedSlices": ["A", "B", "C", "D", "E"], + "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"], + "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"] }, @@ -59,7 +60,7 @@ "name": "Compositor", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-graph-resource-cycle-schema", "A-main-graph-structure-reader-partial", "B-bounded-cpu-executor-partial", "C-image-operation-budget-cancel-partial", "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", "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"] @@ -69,7 +70,7 @@ "name": "Sequencer and audio", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-strip-resource-schema", "A-main-strip-timeline-reader-partial", "B-deterministic-move-trim-split-partial", "B-source-frame-seek", "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"], "blockedSlices": ["A", "B", "C", "D", "E"], "acceptance": ["web:test:sequencer-main-reader", "web:e2e:N-021 sequencer"], "dependencies": ["N-020"] @@ -79,7 +80,7 @@ "name": "Tracking and masks", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-clip-track-plane-mask-schema", "A-main-mask-layer-spline-point-reader", "B-revision-marker-mask-edit-partial", "B-source-hash-binding-validation", "C-browser-probe-solve-gate"], + "completedSlices": ["A-clip-track-plane-mask-schema", "A-main-mask-layer-spline-point-reader", "B-revision-marker-mask-edit-partial", "B-source-hash-binding-validation", "C-browser-probe-solve-gate", "D-mask-bezier-stable-identity-raycast", "D-mask-stable-identity-marquee-selection", "D-real-main-locked-editable-raycast-marquee"], "blockedSlices": ["A", "B", "C", "D", "E"], "acceptance": ["web:test:mask-main-reader", "web:e2e:N-022 tracking"], "dependencies": ["N-021"] @@ -89,9 +90,9 @@ "name": "Assets, libraries and IO", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget"], - "blockedSlices": ["A", "B", "C", "D", "E"], - "acceptance": ["web:test:library-main-reader", "web:e2e:N-023 asset"], + "completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "A-preview-content-signature-dimension-verification", "A-vdb-external-resource-license-sha-catalog", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget", "E-archive-path-conflict-compressed-budget-range-plan", "E-nanovdb-http206-range-hash-streaming-boundary", "E-nanovdb-http-retry-if-range-body-resume", "E-nanovdb-opfs-atomic-binding-worker-reopen-bundle-lru", "E-nanovdb-opfs-tamper-rollback-quota-recovery"], + "blockedSlices": ["A", "B", "C", "D", "E", "E-nanovdb-gpu-resident-page-cache-large-bundle"], + "acceptance": ["web:test:library-main-reader", "web:test:vdb-faults", "web:e2e:N-023 asset"], "dependencies": ["N-022"] }, { @@ -99,7 +100,7 @@ "name": "Editors and workflow", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-unified-area-region-context", "B-read-only-editor-manifest-partial", "B-main-workspace-area-region-reader", "C-selection-sync-revision-partial", "D-keymap-layout-budget-gates"], + "completedSlices": ["A-unified-area-region-context", "B-read-only-editor-manifest-partial", "B-main-workspace-area-region-reader", "C-selection-sync-revision-partial", "C-executable-context-filtered-operator-search", "D-keymap-layout-budget-gates", "D-normalized-key-chord-conflict-resolution", "D-context-scoped-keymap-resolution"], "blockedSlices": ["A", "B", "C", "D", "E"], "acceptance": ["web:test:editor-main-reader", "web:e2e:N-024 editor"], "dependencies": ["N-023"] @@ -109,7 +110,7 @@ "name": "Scripting and platform", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "B-approved-key-still-sandbox-blocked-audit", "C-server-source-hash-job-gate", "D-platform-capability-report"], + "completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "B-approved-key-still-sandbox-blocked-audit", "B-request-decision-audit-receipt", "C-server-source-hash-job-gate", "D-platform-capability-report", "E-bounded-replay-resistant-audit-hash-chain"], "blockedSlices": ["A", "B", "C", "D", "E"], "acceptance": ["web:test:script-main-reader", "web:test:scripting-isolation", "web:e2e:N-025 script"], "dependencies": ["N-024"] @@ -119,8 +120,8 @@ "name": "Release gate", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-machine-readable-parity-manifest", "A-dependency-status-validation", "A-command-hash-evidence-binding", "B-chromium-runtime-evidence-gate", "B-chromium-full-suite-evidence", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-zip-bomb-ratio-rejection", "C-release-package-notices-partial", "C-spdx-2.3-lockfile-sbom", "D-deterministic-manifest-serialization", "D-offline-reproducible-source-archive-partial"], - "blockedSlices": ["A", "B", "C", "D", "E"], + "completedSlices": ["A-machine-readable-parity-manifest", "A-dependency-status-validation", "A-command-hash-evidence-binding", "A-known-enabled-field-artifact-binding", "B-chromium-runtime-evidence-gate", "B-chromium-full-suite-evidence", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-performance-4k-texture-upload-render", "C-performance-8k-texture-upload-render", "C-simulation-cache-opfs-playback-performance", "C-network-interruption-main-save-reopen", "C-main-thread-webgl-device-loss-recovery", "C-zip-bomb-ratio-rejection", "C-vdb-resource-converter-provenance-partial", "C-vdb-network-opfs-quota-tamper-device-loss-partial", "C-release-package-notices-partial", "C-spdx-2.3-lockfile-sbom", "D-deterministic-manifest-serialization", "D-offline-reproducible-source-archive-partial"], + "blockedSlices": ["A", "B", "C", "D", "E", "C-vdb-server-webgpu-save-reopen-golden-evidence"], "acceptance": ["web:e2e:N-026 release", "web:test:release-evidence", "web:test:browser", "web:test:release-package", "web:release:offline", "web:test:release-performance", "web:test:malicious-blends"], "dependencies": ["N-015", "N-016", "N-017", "N-018", "N-019", "N-020", "N-021", "N-022", "N-023", "N-024", "N-025"] } diff --git a/docs/status/release-evidence.json b/docs/status/release-evidence.json index 74fa1785..89b9bda2 100644 --- a/docs/status/release-evidence.json +++ b/docs/status/release-evidence.json @@ -1,13 +1,13 @@ { "schemaVersion": 3, "source": "docs/status/parity-ledger.json", - "sourceSha256": "bc1de45b23987518f38d629361693bad977a49da69b509ade1bee0aee36e272f", - "generatedAt": "2026-08-12T23:22:09.909Z", + "sourceSha256": "fbd0b368e00d2dbeb7d63e248ae2cdb6228a20520b44a14722a21aa0693a6395", + "generatedAt": "2026-08-14T14:56:43.298Z", "families": [ { "id": "N-015", "name": "Non-mesh geometry", - "status": "LOCAL_BOUNDED", + "status": "BLOCKED", "roadmapStatus": "in_progress", "completedSlices": [ "A1", @@ -16,13 +16,24 @@ "A2-multichunk-attribute-completeness", "B2", "B3-metadata", - "B3-vdb-local-availability-assessment", + "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", @@ -34,12 +45,17 @@ "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", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", @@ -49,14 +65,17 @@ "E1-pointcloud-curves-hair-glb-usd-loss-fixture", "E2-1M-chromium", "E2-chromium-worker-recovery", - "E2-opfs-quota-chromium" + "E2-opfs-quota-chromium", + "E2-vdb-network-worker-quota-tamper-rollback-faults" ], "blockedSlices": [ - "B3-vdb-renderer", - "C1-continuous-handle-gizmo-preview", "C2-new-external-font-import", - "E1-volume-loss-fixture" + "D2-nanovdb-production-viewport-paging-advanced-material", + "C3-volume-combined-asset-viewport-reopen", + "E1-volume-glb-usd-loss-desktop-chromium-golden", + "E2-vdb-large-stream-device-loss-oom" ], + "excludedSlices": [], "acceptance": [ "web:test:nonmesh-roundtrip", "web:test:nonmesh-desktop-golden", @@ -64,10 +83,16 @@ "web:test:nonmesh-usd-serialization", "web:test:nonmesh-usd-blender-roundtrip", "web:test:nonmesh-binary", - "web:test:vdb", - "web:test:vdb-availability", "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" ], @@ -87,11 +112,15 @@ "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", "D-editor-main-layer-frame-panel-partial", - "D-single-point-revision-bound-main-translation" + "D-single-point-revision-bound-main-translation", + "D-viewport-point-raycast-multipoint-gizmo-transaction", + "D-continuous-point-preview-single-main-commit", + "D-bounded-drawing-frame-dope-navigation", + "D-worker-restart-save-reopen" ], "blockedSlices": [ "C-material-modifier-full-semantics", - "D-full-2d-canvas-multipoint-dope-gizmo-restart", + "D-full-2d-canvas-marquee-dope-editor", "E-desktop-browser-golden-export-opfs" ], "acceptance": [ @@ -112,16 +141,20 @@ "A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "A-bounded-cpu-candidate-falloff", + "A-uniform-grid-candidate-depth-visibility-gate", + "A-selection-mask-identity-gated-brush-patch", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", "B-selected-vertex-color-weight-ui-transaction-partial", + "B-selection-masked-color-weight-main-transaction", + "B-blender52-vertex-group-summary-save-reopen", "C-udim-memory-range-patch-hash-boundary" ], "blockedSlices": [ "A-real-pbvh-depth-occlusion-desktop-falloff", "B-clean-mirror-desktop-brush", "C-blender-packed-udim-dirty-atomic-save", - "D-E-mask-selection-gpu-quota-golden-ui" + "D-E-face-mask-gpu-quota-desktop-golden" ], "acceptance": [ "web:test:paint-roundtrip", @@ -143,7 +176,10 @@ "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate", - "C-browser-transform-frame-binary-decoder" + "C-browser-transform-frame-binary-decoder", + "C-browser-transform-sceneir-preview", + "C-browser-transform-cancellable-playback-session", + "C-browser-transform-storage-restart-playback" ], "blockedSlices": [ "A-B-full-family-settings-effectors-cache-binding", @@ -169,15 +205,21 @@ "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", - "A-white-balance-integrity-gate" + "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" ], "blockedSlices": [ "A", "B", "C", "D", - "E" + "E", + "B-C-nanovdb-production-viewport-paging-advanced-material", + "E-volume-desktop-chromium-pixel-golden" ], "acceptance": [ "web:test:lighting-roundtrip", @@ -195,8 +237,11 @@ "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" ], "blockedSlices": [ @@ -224,6 +269,9 @@ "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" ], "blockedSlices": [ @@ -251,7 +299,10 @@ "A-main-mask-layer-spline-point-reader", "B-revision-marker-mask-edit-partial", "B-source-hash-binding-validation", - "C-browser-probe-solve-gate" + "C-browser-probe-solve-gate", + "D-mask-bezier-stable-identity-raycast", + "D-mask-stable-identity-marquee-selection", + "D-real-main-locked-editable-raycast-marquee" ], "blockedSlices": [ "A", @@ -276,20 +327,29 @@ "completedSlices": [ "A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", + "A-preview-content-signature-dimension-verification", + "A-vdb-external-resource-license-sha-catalog", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", - "E-archive-path-ratio-budget" + "E-archive-path-ratio-budget", + "E-archive-path-conflict-compressed-budget-range-plan", + "E-nanovdb-http206-range-hash-streaming-boundary", + "E-nanovdb-http-retry-if-range-body-resume", + "E-nanovdb-opfs-atomic-binding-worker-reopen-bundle-lru", + "E-nanovdb-opfs-tamper-rollback-quota-recovery" ], "blockedSlices": [ "A", "B", "C", "D", - "E" + "E", + "E-nanovdb-gpu-resident-page-cache-large-bundle" ], "acceptance": [ "web:test:library-main-reader", + "web:test:vdb-faults", "web:e2e:N-023 asset" ], "dependencies": [ @@ -306,7 +366,10 @@ "B-read-only-editor-manifest-partial", "B-main-workspace-area-region-reader", "C-selection-sync-revision-partial", - "D-keymap-layout-budget-gates" + "C-executable-context-filtered-operator-search", + "D-keymap-layout-budget-gates", + "D-normalized-key-chord-conflict-resolution", + "D-context-scoped-keymap-resolution" ], "blockedSlices": [ "A", @@ -333,8 +396,10 @@ "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "B-approved-key-still-sandbox-blocked-audit", + "B-request-decision-audit-receipt", "C-server-source-hash-job-gate", - "D-platform-capability-report" + "D-platform-capability-report", + "E-bounded-replay-resistant-audit-hash-chain" ], "blockedSlices": [ "A", @@ -361,11 +426,19 @@ "A-machine-readable-parity-manifest", "A-dependency-status-validation", "A-command-hash-evidence-binding", + "A-known-enabled-field-artifact-binding", "B-chromium-runtime-evidence-gate", "B-chromium-full-suite-evidence", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", + "C-performance-4k-texture-upload-render", + "C-performance-8k-texture-upload-render", + "C-simulation-cache-opfs-playback-performance", + "C-network-interruption-main-save-reopen", + "C-main-thread-webgl-device-loss-recovery", "C-zip-bomb-ratio-rejection", + "C-vdb-resource-converter-provenance-partial", + "C-vdb-network-opfs-quota-tamper-device-loss-partial", "C-release-package-notices-partial", "C-spdx-2.3-lockfile-sbom", "D-deterministic-manifest-serialization", @@ -376,7 +449,8 @@ "B", "C", "D", - "E" + "E", + "C-vdb-server-webgpu-save-reopen-golden-evidence" ], "acceptance": [ "web:e2e:N-026 release", @@ -414,15 +488,15 @@ "performance": { "geometry1M": true, "geometry10M": false, - "texture4K": false, - "texture8K": false, + "texture4K": true, + "texture8K": true, "longMedia": false, - "simulationCache": false + "simulationCache": true }, "faults": { "oom": false, - "deviceLoss": false, - "networkInterrupt": false, + "deviceLoss": true, + "networkInterrupt": true, "malformedBlend": true, "zipBomb": true }, @@ -441,7 +515,7 @@ ], "command": "npm --prefix web run release:sbom", "exitCode": 0, - "durationMs": 361, + "durationMs": 372, "output": "> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30", "artifactSha256": [ "8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30", @@ -449,6 +523,21 @@ "3c65ff96cc15c9072d2517555484ccfd0009a2814d96faadcc5bd7b2e3458503" ] }, + { + "id": "vdb-boundary", + "fields": [], + "command": "npm --prefix web run test:vdb-availability && npm --prefix web run test:vdb-native && npm --prefix web run test:vdb", + "exitCode": 0, + "durationMs": 5823, + "output": "> blender-web-editor@0.1.0 test:vdb-availability\n> node ../tools/web/check-vdb-availability.mjs\n\nvdb-availability-ok core=READY release=BLOCKED browser-openvdb=disabled desktop=ready server=ready opfs=ready webgpu-core=ready main-roundtrip=ready viewport=blocked advanced-material=blocked resources=9\n\n> blender-web-editor@0.1.0 test:vdb-native\n> node ../tools/web/check-vdb-native-pipeline.mjs\n\nvdb-native-pipeline-ok resources=14 converter=openvdb13-nanovdb32 conversion-deterministic=1 semantic-deterministic=1 official-deterministic=1 malformed=blocked browser-openvdb=off\n\n> blender-web-editor@0.1.0 test:vdb\n> playwright test --config playwright.config.ts -g \"VDB conversion boundary\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1156:1 › validates the VDB conversion boundary and NanoVDB streaming contract (1.5s)\n\n 1 passed (3.3s)\n\n[WebServer] (node:1987436) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1987453) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "885edcc156f87abdbddb0e70fde5566130acfc2770a0b6764d66d12c344973a6", + "7b2d576a613dff314f4b40177f10efa3c01e160b7aeb10bf95a69f6553f8f73b", + "a4c57345a4ea7302b9e2a7e0f8b0ed48f8f27c3ac70d41e02f6d92475a751298", + "179868fde557873b4c74c48e2f0d3199784efb6309ed00dd17310b494b975483", + "a13cf2747dacfbf4c0e6b50dd341db3adac32c7c3c9c07814349a90aa4436a3b" + ] + }, { "id": "chromium", "fields": [ @@ -459,10 +548,10 @@ ], "command": "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser", "exitCode": 0, - "durationMs": 147877, - "output": "and LOD profiles at the protocol boundary (1.1s)\n ✓ 73 tests/e2e/smoke.spec.ts:1866:1 › normalizes skin influences and rejects shape-key loss explicitly (1.1s)\n ✓ 74 tests/e2e/smoke.spec.ts:1891:1 › remaps skin weights and shape keys through the native Collapse worker path (1.4s)\n ✓ 75 tests/e2e/smoke.spec.ts:1947:1 › reports GLB export blockers before any binary export (1.1s)\n ✓ 76 tests/e2e/smoke.spec.ts:1959:1 › blocks Shader graphs that cannot be mapped to glTF PBR (1.0s)\n ✓ 77 tests/e2e/smoke.spec.ts:1986:1 › maps bounded RGB and Value Shader constants to glTF PBR factors (1.0s)\n ✓ 78 tests/e2e/smoke.spec.ts:2019:1 › exports a local SceneIR mesh as a standards-shaped GLB (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2052:1 › keeps per-vertex UV and color attributes in GLB output (1.0s)\n ✓ 80 tests/e2e/smoke.spec.ts:2079:1 › evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.1s)\n ✓ 81 tests/e2e/smoke.spec.ts:2094:1 › embeds local textures and exports glTF skin and animation records (1.1s)\n ✓ 82 tests/e2e/smoke.spec.ts:2144:1 › reports lightweight budget violations without altering usage (1.0s)\n ✓ 83 tests/e2e/smoke.spec.ts:2162:1 › aggregates project, collection, object and LOD budgets without double counting LOD (1.0s)\n ✓ 84 tests/e2e/smoke.spec.ts:2188:1 › round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 85 tests/e2e/smoke.spec.ts:2212:1 › blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 86 tests/e2e/smoke.spec.ts:2255:1 › extracts Blender packed image bytes through the local asset request API (1.3s)\n ✓ 87 tests/e2e/smoke.spec.ts:2300:1 › matches Blender Depsgraph deformation golden within the declared error budget (1.4s)\n ✓ 88 tests/e2e/smoke.spec.ts:2339:1 › evaluates the full Blender Depsgraph or reports its safe capability gate (1.4s)\n ✓ 89 tests/e2e/smoke.spec.ts:2399:1 › exports layered Action keyframes through SceneIR (1.3s)\n ✓ 90 tests/e2e/smoke.spec.ts:2440:1 › patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 91 tests/e2e/smoke.spec.ts:2464:1 › renders through the capability-gated OffscreenCanvas worker (1.3s)\n ✓ 92 tests/e2e/smoke.spec.ts:2477:1 › coalesces linked mesh objects into a raycastable instance group (1.9s)\n ✓ 93 tests/e2e/smoke.spec.ts:2485:1 › returns structured gates for the undeclared capability protocols (1.2s)\n ✓ 94 tests/e2e/smoke.spec.ts:2544:1 › exposes PBR-007 to PBR-012 renderer security gates (1.1s)\n ✓ 95 tests/e2e/smoke.spec.ts:2578:1 › transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 96 tests/e2e/smoke.spec.ts:2587:1 › uses the same packed texture payload in the OffscreenCanvas renderer (1.9s)\n\n 96 passed (2.3m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] › tests/e2e/cross-browser.spec.ts:6:1 › boots the offline engine, renders SceneIR and performs a Main edit (2.8s)\n ✓ 2 [chromium] › tests/e2e/cross-browser.spec.ts:32:1 › keeps content-addressed asset recovery available in Chromium (1.2s)\n ✓ 3 [chromium] › tests/e2e/cross-browser.spec.ts:51:1 › keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.7s)\n\n[WebServer] (node:1350053) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1350065) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1355845) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1355857) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "durationMs": 219085, + "output": " (1.4s)\n ✓ 95 tests/e2e/smoke.spec.ts:2607:1 › reports GLB export blockers before any binary export (1.1s)\n ✓ 96 tests/e2e/smoke.spec.ts:2619:1 › blocks Shader graphs that cannot be mapped to glTF PBR (1.0s)\n ✓ 97 tests/e2e/smoke.spec.ts:2646:1 › maps bounded RGB and Value Shader constants to glTF PBR factors (1.0s)\n ✓ 98 tests/e2e/smoke.spec.ts:2679:1 › exports a local SceneIR mesh as a standards-shaped GLB (1.0s)\n ✓ 99 tests/e2e/smoke.spec.ts:2712:1 › keeps per-vertex UV and color attributes in GLB output (1.1s)\n ✓ 100 tests/e2e/smoke.spec.ts:2739:1 › evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.1s)\n ✓ 101 tests/e2e/smoke.spec.ts:2754:1 › embeds local textures and exports glTF skin and animation records (1.1s)\n ✓ 102 tests/e2e/smoke.spec.ts:2804:1 › reports lightweight budget violations without altering usage (1.1s)\n ✓ 103 tests/e2e/smoke.spec.ts:2822:1 › aggregates project, collection, object and LOD budgets without double counting LOD (1.0s)\n ✓ 104 tests/e2e/smoke.spec.ts:2848:1 › round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 105 tests/e2e/smoke.spec.ts:2872:1 › blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 106 tests/e2e/smoke.spec.ts:2915:1 › extracts Blender packed image bytes through the local asset request API (1.4s)\n ✓ 107 tests/e2e/smoke.spec.ts:2960:1 › matches Blender Depsgraph deformation golden within the declared error budget (1.4s)\n ✓ 108 tests/e2e/smoke.spec.ts:2999:1 › evaluates the full Blender Depsgraph or reports its safe capability gate (1.4s)\n ✓ 109 tests/e2e/smoke.spec.ts:3059:1 › exports layered Action keyframes through SceneIR (1.3s)\n ✓ 110 tests/e2e/smoke.spec.ts:3100:1 › patches changed mesh buffer ranges without replacing stable topology (1.1s)\n ✓ 111 tests/e2e/smoke.spec.ts:3124:1 › renders through the capability-gated OffscreenCanvas worker (1.4s)\n ✓ 112 tests/e2e/smoke.spec.ts:3137:1 › coalesces linked mesh objects into a raycastable instance group (1.9s)\n ✓ 113 tests/e2e/smoke.spec.ts:3145:1 › returns structured gates for the undeclared capability protocols (1.2s)\n ✓ 114 tests/e2e/smoke.spec.ts:3204:1 › exposes PBR-007 to PBR-012 renderer security gates (1.1s)\n ✓ 115 tests/e2e/smoke.spec.ts:3238:1 › transfers packed raster assets into the PBR viewport with an explicit status (1.7s)\n ✓ 116 tests/e2e/smoke.spec.ts:3247:1 › uses the same packed texture payload in the OffscreenCanvas renderer (1.8s)\n ✓ 117 tests/e2e/texture-4k-performance.spec.ts:3:1 › decodes, uploads and renders a validated 4K texture in Chromium (1.8s)\n ✓ 118 tests/e2e/texture-8k-performance.spec.ts:3:1 › decodes, uploads and renders a validated 8K texture in Chromium (4.2s)\n\n 118 passed (3.5m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] › tests/e2e/cross-browser.spec.ts:6:1 › boots the offline engine, renders SceneIR and performs a Main edit (2.8s)\n ✓ 2 [chromium] › tests/e2e/cross-browser.spec.ts:32:1 › keeps content-addressed asset recovery available in Chromium (1.0s)\n ✓ 3 [chromium] › tests/e2e/cross-browser.spec.ts:51:1 › keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.6s)\n\n[WebServer] (node:1987737) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1987749) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1993877) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1993889) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", "artifactSha256": [ - "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c" + "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8" ] }, { @@ -472,10 +561,25 @@ ], "command": "npm --prefix web run test:release-performance", "exitCode": 0, - "durationMs": 89206, - "output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"outputTriangles\":89999,\"elapsedMs\":88134,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":535,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.2166599999909522 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.10756300001230557 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.04437800000596326 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 1.4113389999984065 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.4725370000087423 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4998959999938961 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.49847900000168 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.4405399999959627 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.4891829999978654 msecs. Success: true", + "durationMs": 93399, + "output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"outputTriangles\":89999,\"elapsedMs\":92308,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":540,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.22668800000974443 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.08722699999634642 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.04216300000553019 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 1.3969159999978729 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.700714999999036 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4438179999997374 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.4305880000028992 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.5049569999973755 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.4472269999969285 msecs. Success: true", "artifactSha256": [ - "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c" + "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8" + ] + }, + { + "id": "simulation-cache", + "fields": [ + "performance.simulationCache" + ], + "command": "npm --prefix web run test:simulation-cache-performance", + "exitCode": 0, + "durationMs": 7549, + "output": "> blender-web-editor@0.1.0 test:simulation-cache-performance\n> playwright test --config playwright.config.ts tests/e2e/simulation-cache-performance.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/simulation-cache-performance.spec.ts:3:1 › meets the Chromium OPFS Simulation cache playback performance gate (5.1s)\n\n 1 passed (6.7s)\n\n[WebServer] (node:1994548) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1994560) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "c8074425a4556c8d08c2382dc9fd7be8aa6078d11a45640c4b19bf6ef45aa590", + "331d5c6a59dc80a12cc7105abbd60d25a64a113809eae9ad3d4061d81ec5f59f", + "535285f629b981e63e423f16df47d90afea4f5f17fca2bef36d0f6a8236697b2" ] }, { @@ -485,12 +589,68 @@ ], "command": "npm --prefix web run test:malicious-blends", "exitCode": 0, - "durationMs": 561, + "durationMs": 541, "output": "> blender-web-editor@0.1.0 test:malicious-blends\n> node ../tools/web/check-malicious-blends.mjs\n\nmalicious-blends-ok rejected=5", "artifactSha256": [ "6fcc55bda74da8c95da96ba068b60339bf60ca44147ff8000fbb95d296db2a73" ] }, + { + "id": "network-interruption", + "fields": [ + "faults.networkInterrupt" + ], + "command": "npm --prefix web run test:network-interruption", + "exitCode": 0, + "durationMs": 5632, + "output": "> blender-web-editor@0.1.0 test:network-interruption\n> playwright test --config playwright.config.ts tests/e2e/network-interruption.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/network-interruption.spec.ts:6:1 › keeps Main edit and save-reopen available during a Chromium network interruption (3.1s)\n\n 1 passed (4.7s)\n\n[WebServer] (node:1994872) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1994884) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "b1c03863f44026d44691042536ff1d1095617a35c8d767cc70372e5cbdc70db5", + "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8" + ] + }, + { + "id": "device-loss", + "fields": [ + "faults.deviceLoss" + ], + "command": "npm --prefix web run test:device-loss", + "exitCode": 0, + "durationMs": 5138, + "output": "> blender-web-editor@0.1.0 test:device-loss\n> playwright test --config playwright.config.ts tests/e2e/device-loss.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/device-loss.spec.ts:6:1 › recovers the main-thread Chromium viewport after a real WebGL context loss (2.6s)\n\n 1 passed (4.2s)\n\n[WebServer] (node:1995187) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1995199) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "3b18c884f3baed68d4deb72feafbd7fe8b4af7935aaf02206ccc583ea9c527e2", + "76d781c0f8508ee68fde09b1bb0d3f7db963e3e2be7d10d60708af2a6a582c24" + ] + }, + { + "id": "texture-4k", + "fields": [ + "performance.texture4K" + ], + "command": "npm --prefix web run test:texture-4k-performance", + "exitCode": 0, + "durationMs": 4857, + "output": "> blender-web-editor@0.1.0 test:texture-4k-performance\n> playwright test --config playwright.config.ts tests/e2e/texture-4k-performance.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/texture-4k-performance.spec.ts:3:1 › decodes, uploads and renders a validated 4K texture in Chromium (2.3s)\n\n 1 passed (4.0s)\n\n[WebServer] (node:1995463) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1995475) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "093588bcdf08f8b5a5aa8baceddbd7b499c43e5acda7f793b34ac54c79782490", + "75f2f33753a160f60acc56ad850bc149cef7fa2b54a42d5e75cf22402c9ac91d" + ] + }, + { + "id": "texture-8k", + "fields": [ + "performance.texture8K" + ], + "command": "npm --prefix web run test:texture-8k-performance", + "exitCode": 0, + "durationMs": 7112, + "output": "> blender-web-editor@0.1.0 test:texture-8k-performance\n> playwright test --config playwright.config.ts tests/e2e/texture-8k-performance.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/texture-8k-performance.spec.ts:3:1 › decodes, uploads and renders a validated 8K texture in Chromium (4.6s)\n\n 1 passed (6.2s)\n\n[WebServer] (node:1995736) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1995748) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "artifactSha256": [ + "093588bcdf08f8b5a5aa8baceddbd7b499c43e5acda7f793b34ac54c79782490", + "75f2f33753a160f60acc56ad850bc149cef7fa2b54a42d5e75cf22402c9ac91d" + ] + }, { "id": "zip-bomb", "fields": [ @@ -498,10 +658,10 @@ ], "command": "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library", "exitCode": 0, - "durationMs": 4132, - "output": "> blender-web-editor@0.1.0 test:asset-library\n> playwright test --config playwright.config.ts -g \"N-023 asset\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:711:1 › validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.3s)\n\n[WebServer] (node:1356360) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1356372) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "durationMs": 4198, + "output": "> blender-web-editor@0.1.0 test:asset-library\n> playwright test --config playwright.config.ts -g \"N-023 asset\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:969:1 › validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.3s)\n\n[WebServer] (node:1996023) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1996035) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", "artifactSha256": [ - "6169748c5bf78a8e101196e964db67b8a65c2ba95bb3b4a54e052721be01a7bf" + "5c62539db3833a2113007dfbdfd12ca06704d86e0cf0e57ad97f7fbf6acda7f4" ] }, { @@ -509,11 +669,11 @@ "fields": [], "command": "npm --prefix web run test:release-package", "exitCode": 0, - "durationMs": 4871, - "output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 45 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-hjSYzFAQ.js 36.77 kB\ndist/assets/web-engine.worker-BpNH8d2z.js 243.09 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-B3UcZYVN.js 890.91 kB │ gzip: 239.96 kB\n\n✓ built in 561ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=31935334\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", + "durationMs": 5619, + "output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 51 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-hjSYzFAQ.js 36.77 kB\ndist/assets/web-engine.worker-CWkf48pG.js 245.76 kB\ndist/assets/viewport-render.worker-DXSzkSWB.js 592.78 kB\ndist/assets/web_engine-CvVHxFlQ.wasm 15,048.04 kB │ gzip: 3,562.32 kB\ndist/assets/index-QI0bg0OR.css 11.41 kB │ gzip: 3.18 kB\ndist/assets/index-Ce6I8dqP.js 951.79 kB │ gzip: 256.93 kB\n\n✓ built in 623ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=32067024\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", "artifactSha256": [ "8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30", - "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c" + "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8" ] }, { @@ -524,12 +684,12 @@ ], "command": "npm --prefix web run release:offline", "exitCode": 0, - "durationMs": 36624, - "output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 45 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-hjSYzFAQ.js 36.77 kB\ndist/assets/web-engine.worker-BpNH8d2z.js 243.09 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-B3UcZYVN.js 890.91 kB │ gzip: 239.96 kB\n\n✓ built in 563ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7542083 source=205692822 sha256=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7542083 source=205692822 sha256=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b\noffline-reproducibility-ok binary=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b source=ee2cbbe62f42abae959bd5e3d1aa4cc5bee491ae23a85fa50b7519dc6a2549b0\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", + "durationMs": 41303, + "output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 51 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-hjSYzFAQ.js 36.77 kB\ndist/assets/web-engine.worker-CWkf48pG.js 245.76 kB\ndist/assets/viewport-render.worker-DXSzkSWB.js 592.78 kB\ndist/assets/web_engine-CvVHxFlQ.wasm 15,048.04 kB │ gzip: 3,562.32 kB\ndist/assets/index-QI0bg0OR.css 11.41 kB │ gzip: 3.18 kB\ndist/assets/index-Ce6I8dqP.js 951.79 kB │ gzip: 256.93 kB\n\n✓ built in 723ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7575306 source=205785402 sha256=078f45513ab579800ab3688f7c5e35d31be0d9dc283db5cee1a68d77553bfb34\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7575306 source=205785402 sha256=078f45513ab579800ab3688f7c5e35d31be0d9dc283db5cee1a68d77553bfb34\noffline-reproducibility-ok binary=078f45513ab579800ab3688f7c5e35d31be0d9dc283db5cee1a68d77553bfb34 source=ca97bef8cbe7943586e7af1df631775ce6ea041677c8bbb98da2fcc73baa9e1a\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", "artifactSha256": [ - "2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b", - "ee2cbbe62f42abae959bd5e3d1aa4cc5bee491ae23a85fa50b7519dc6a2549b0", - "3d807f874e9703322a69318c856ff493dd7489357f06dc78f3496c7a46a30612" + "078f45513ab579800ab3688f7c5e35d31be0d9dc283db5cee1a68d77553bfb34", + "ca97bef8cbe7943586e7af1df631775ce6ea041677c8bbb98da2fcc73baa9e1a", + "39c7f9be555f2727519a733237de22e66a33d33ac1b0aecfacd2ae695de10b28" ] } ] diff --git a/docs/status/vdb-native-evidence.json b/docs/status/vdb-native-evidence.json new file mode 100644 index 00000000..a817b597 --- /dev/null +++ b/docs/status/vdb-native-evidence.json @@ -0,0 +1,224 @@ +{ + "schemaVersion": 1, + "resourceLibrary": "/home/mes123456/resource-library/blender-web-vdb", + "browserOpenVDB": false, + "desktopOpenVDB": true, + "serverJobConfigured": true, + "serverIsolation": "bwrap-unshare-all+readonly-root+prlimit", + "opfsStreamingConfigured": true, + "webgpuRendererConfigured": true, + "mainVolumeRoundtripConfigured": true, + "primaryViewportVolumeIntegrated": false, + "materialSemantics": { + "supported": [ + "DENSITY_GRID_FLOAT32", + "CONSTANT_COLOR", + "CONSTANT_EMISSION", + "ANISOTROPY", + "NEAREST", + "LINEAR" + ], + "explicitLosses": [ + "COLOR_GRID", + "TEMPERATURE_BLACKBODY", + "EMISSION_GRID", + "VELOCITY_MOTION" + ] + }, + "chromiumWebGPU": { + "renderer": "SwiftShader WebGPU", + "densityPayloadBytes": 2563744, + "nativeCpuGpuSamples": 5, + "imageWidth": 96, + "imageHeight": 96, + "imageSha256": "7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978" + }, + "releaseStatus": "BLOCKED", + "remainingReleaseBlockers": [ + "primary-offscreen-viewport-volume-scene-integration", + "color-temperature-emission-grid-shading", + "three-view-desktop-chromium-pixel-goldens", + "64MiB-512MiB-1GiB-stream-device-loss-oom-gates" + ], + "toolchain": { + "openVDBVersion": "13.0.0", + "nanoVDBVersion": "32.9.0", + "converterPath": "/home/mes123456/workinf_Blender_Wasm/build_vdb_tools/vdb_to_nanovdb", + "converterSha256": "2c62efc12bb4b7590cec699fa7e0768f64840c0571c0b0aff917a699ac7b5450", + "generatorPath": "/home/mes123456/workinf_Blender_Wasm/build_vdb_tools/vdb_fixture_generator", + "generatorSha256": "fea8abfef4ef5a065584390bc5acd060babfd58097200972067a990a3efad7d5" + }, + "licenses": [ + { + "id": "OpenVDB-Apache-2.0", + "path": "licenses/OpenVDB-Apache-2.0.txt", + "sha256": "a6cba85bc92e0cff7a450b1d873c0eaa2e9fc96bf472df0247a26bec77bf3ff9" + }, + { + "id": "CC-BY-4.0", + "path": "licenses/CC-BY-4.0.txt", + "sha256": "8d3fceb4cb62663775f02c1c551cfbf332d1c736667c17817c16c635caba21e0" + } + ], + "resources": [ + { + "id": "generated-smoke", + "kind": "SOURCE_VDB", + "path": "generated/generated-smoke.vdb", + "byteLength": 605604, + "sha256": "586f7cdf4b3329fcb7ef00fc57b12a268baafdbaac0a2b3d1bfa142a22ccda33", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Generated locally by tools/vdb/vdb_fixture_generator.cc with OpenVDB 13.0.0; contains no third-party model data" + }, + { + "id": "generated-level-set", + "kind": "SOURCE_VDB", + "path": "generated/generated-level-set.vdb", + "byteLength": 151127, + "sha256": "ba162b5a60dd1e9684846ede1c1a427655d71fd10d5dbb8ef81c9be88a01ed69", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Generated locally by tools/vdb/vdb_fixture_generator.cc with OpenVDB 13.0.0; contains no third-party model data" + }, + { + "id": "generated-large-bounds-sparse", + "kind": "SOURCE_VDB", + "path": "generated/generated-large-bounds-sparse.vdb", + "byteLength": 28954, + "sha256": "643981dcf9d358b34537b93755e92b1af98d3a3ab54a62ae93dcf6d6be559972", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Generated locally by tools/vdb/vdb_fixture_generator.cc with OpenVDB 13.0.0; contains no third-party model data" + }, + { + "id": "generated-smoke-truncated", + "kind": "MALFORMED_VDB", + "path": "generated/generated-smoke-truncated.vdb", + "byteLength": 1024, + "sha256": "4bce5c630e84e556f55deca29fbeeb5cd6d5b50bace5e46745b6c9b61a3fb781", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Generated locally by tools/vdb/vdb_fixture_generator.cc with OpenVDB 13.0.0; contains no third-party model data", + "expectedResult": "VDB_CONVERSION_FAILED" + }, + { + "id": "official-sphere", + "kind": "SOURCE_VDB", + "path": "official/sphere.vdb", + "byteLength": 861072, + "sha256": "bb884a9a38354e47191c4ece4a11d1e051594a910fde56501cfc51ff52b171ab", + "license": "CC-BY-4.0", + "origin": "OpenVDB official sample model repository", + "sourcePage": "https://www.openvdb.org/download/", + "sourceUrl": "https://media.githubusercontent.com/media/AcademySoftwareFoundation/openvdb-website/master/download/models/sphere.vdb", + "upstreamLfsSha256": "bb884a9a38354e47191c4ece4a11d1e051594a910fde56501cfc51ff52b171ab", + "attribution": "Academy Software Foundation OpenVDB sample models" + }, + { + "id": "generated-smoke-nanovdb", + "kind": "NANOVDB_BUNDLE", + "path": "nanovdb/generated-smoke.nvdb", + "byteLength": 15469795, + "sha256": "e1f341b9b21025b59c73277110f6a02c029765335016f5cc36baa5a8da002903", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Derived from generated-smoke", + "derivedFrom": "generated-smoke" + }, + { + "id": "generated-level-set-nanovdb", + "kind": "NANOVDB_BUNDLE", + "path": "nanovdb/generated-level-set.nvdb", + "byteLength": 2649704, + "sha256": "fdd332afda542ee45d618ab8a0bf07fa0afd350900b9ca3238bb7ad0e0ce69ea", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Derived from generated-level-set", + "derivedFrom": "generated-level-set" + }, + { + "id": "generated-large-bounds-sparse-nanovdb", + "kind": "NANOVDB_BUNDLE", + "path": "nanovdb/generated-large-bounds-sparse.nvdb", + "byteLength": 917224, + "sha256": "c096456ded740861577ee2a0ceb8d2a05e1847f0fbbdc102cbb18401b8af360e", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Derived from generated-large-bounds-sparse", + "derivedFrom": "generated-large-bounds-sparse" + }, + { + "id": "official-sphere-nanovdb", + "kind": "NANOVDB_BUNDLE", + "path": "nanovdb/official-sphere.nvdb", + "byteLength": 5546250, + "sha256": "689a2ef4e2d0302a3c7657dba58568fb22bc9e9dea5528cdbdb341455c1c7194", + "license": "CC-BY-4.0", + "origin": "OpenVDB official sample model repository", + "derivedFrom": "official-sphere" + }, + { + "id": "generated-smoke-browser-manifest", + "kind": "NANOVDB_MANIFEST", + "path": "manifests/generated-smoke.nanovdb.json", + "byteLength": 7706, + "sha256": "5226ccb3820c576205c5e624e543328c1d457138a1e301623a1d263abedbc1e0", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Derived from generated-smoke conversion report", + "derivedFrom": "generated-smoke-nanovdb" + }, + { + "id": "generated-smoke-conversion-report", + "kind": "CONVERSION_REPORT", + "path": "reports/generated-smoke-conversion.json", + "byteLength": 4782, + "sha256": "e7693bdd364799780c775dd0bef5aa4c85919a7d17deb1b46e1726005a433a8f", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Native OpenVDB to NanoVDB report", + "derivedFrom": "generated-smoke" + }, + { + "id": "generated-level-set-conversion-report", + "kind": "CONVERSION_REPORT", + "path": "reports/generated-level-set-conversion.json", + "byteLength": 2275, + "sha256": "6fadc9b5609307b4b8715d9476c5d26962d5d621d104eaeb2a6a4f364c3670ff", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Native OpenVDB to NanoVDB report", + "derivedFrom": "generated-level-set" + }, + { + "id": "generated-large-bounds-conversion-report", + "kind": "CONVERSION_REPORT", + "path": "reports/generated-large-bounds-sparse-conversion.json", + "byteLength": 1794, + "sha256": "a55d333e14ef9e23478032d47c5f8d5e736d9198aea64c603836d7e9fc953018", + "license": "PROJECT_GENERATED_FIXTURE", + "origin": "Native OpenVDB to NanoVDB report", + "derivedFrom": "generated-large-bounds-sparse" + }, + { + "id": "official-sphere-conversion-report", + "kind": "CONVERSION_REPORT", + "path": "reports/official-sphere-conversion.json", + "byteLength": 2240, + "sha256": "8f577982ea83948fc666bcfcbfcdc22b9b40e210c376d86db8a1f8be910b99c1", + "license": "CC-BY-4.0", + "origin": "OpenVDB official sample model repository", + "derivedFrom": "official-sphere" + } + ], + "validations": [ + "source-byte-length-and-sha256", + "official-git-lfs-sha256", + "openvdb13-nanovdb32-real-conversion", + "same-source-conversion-determinism", + "semantic-grid-conversion-determinism", + "official-sphere-conversion-determinism", + "truncated-vdb-rejection", + "browser-manifest-validation", + "browser-openvdb-disabled", + "native-cancel-timeout-atomic-output", + "isolated-server-job-idempotency-signature-cancel", + "desktop-server-bundle-hash-equality", + "real-bundle-opfs-atomic-commit-worker-reopen-tamper-gate", + "nanovdb-float32-native-cpu-webgpu-sample-equality", + "chromium-webgpu-volume-integration-golden", + "bounded-material-mapping-loss-report", + "volume-main-undo-redo-save-reopen" + ] +} diff --git a/docs/web/dependency-matrix.md b/docs/web/dependency-matrix.md index 79893b3b..1e3a0e19 100644 --- a/docs/web/dependency-matrix.md +++ b/docs/web/dependency-matrix.md @@ -15,7 +15,9 @@ move from `待验证` only after a task adds a build or browser regression test. | Blender `draw/gpu` viewport | 关闭首期 | Three.js is the browser renderer | W-013, W-050 | | Python runtime and automatic `.blend` scripts | 关闭首期 | security and package-size boundary | W-117 | | Cycles, CUDA/OptiX, Embree | 关闭首期 | server-side or native rendering only | roadmap non-goal | -| OpenVDB, USD, FFmpeg | 关闭首期 | server-side or later format modules | W-021 | +| OpenVDB | 浏览器关闭;桌面/服务端转换目标 planned | 读取 `.vdb` 并输出受校验 NanoVDB;不得链接进浏览器 WASM | N-015 VDB-010~014 | +| NanoVDB | 浏览器仅消费格式 | 分块 range + WebGPU sparse volume;当前协议已落地,renderer 阻断 | N-015 VDB-003~034 | +| USD, FFmpeg | 关闭首期 | server-side or later format modules | W-021 | | React + TypeScript | 保留 | application UI and state | W-010 | | Three.js (vendored) | 保留 | WebGL2 viewport and scene adapter; runtime has no CDN dependency | W-013, W-050 | | Emscripten runtime | 保留 | Blender C/C++ to WebAssembly | W-020~W-025 | diff --git a/tests/files/web/compositor_scene.blend b/tests/files/web/compositor_scene.blend index e955f10d..129ddd71 100644 Binary files a/tests/files/web/compositor_scene.blend and b/tests/files/web/compositor_scene.blend differ diff --git a/tests/files/web/manifest.json b/tests/files/web/manifest.json index 93f28e3c..a6313217 100644 --- a/tests/files/web/manifest.json +++ b/tests/files/web/manifest.json @@ -155,7 +155,7 @@ "path": "compositor_scene.blend", "objects": 0, "meshes": 0, - "features": ["compositor-main-reader", "socket-links", "unsupported-node-preservation"] + "features": ["compositor-main-reader", "exposure-invert-parameters", "socket-links", "unsupported-node-preservation"] }, { "id": "sequencer_scene", diff --git a/tests/files/web/mask_scene.blend b/tests/files/web/mask_scene.blend index 421c28e3..9a473a5f 100644 Binary files a/tests/files/web/mask_scene.blend and b/tests/files/web/mask_scene.blend differ diff --git a/tools/vdb/CMakeLists.txt b/tools/vdb/CMakeLists.txt new file mode 100644 index 00000000..29438380 --- /dev/null +++ b/tools/vdb/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.24) +project(blender_web_vdb_tools LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(OPENVDB_ROOT "/home/mes123456/working/build_oiio_deps/Release/openvdb" CACHE PATH "OpenVDB install prefix") +set(TBB_ROOT "/home/mes123456/working/build_oiio_deps/Release/tbb" CACHE PATH "TBB install prefix") + +find_path(OPENVDB_INCLUDE_DIR openvdb/openvdb.h HINTS "${OPENVDB_ROOT}/include" REQUIRED NO_DEFAULT_PATH) +find_library(OPENVDB_LIBRARY openvdb HINTS "${OPENVDB_ROOT}/lib" REQUIRED NO_DEFAULT_PATH) +find_path(TBB_INCLUDE_DIR tbb/tbb.h HINTS "${TBB_ROOT}/include" REQUIRED NO_DEFAULT_PATH) +find_library(TBB_LIBRARY tbb HINTS "${TBB_ROOT}/lib" REQUIRED NO_DEFAULT_PATH) + +add_library(vdb_toolchain INTERFACE) +target_include_directories(vdb_toolchain INTERFACE "${OPENVDB_INCLUDE_DIR}" "${TBB_INCLUDE_DIR}") +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) + add_executable(${target} "${target}.cc") + target_link_libraries(${target} PRIVATE vdb_toolchain) + set_target_properties(${target} PROPERTIES + BUILD_RPATH "${OPENVDB_ROOT}/lib;${TBB_ROOT}/lib" + INSTALL_RPATH "${OPENVDB_ROOT}/lib;${TBB_ROOT}/lib") +endforeach() diff --git a/tools/vdb/README.md b/tools/vdb/README.md new file mode 100644 index 00000000..1d1cccad --- /dev/null +++ b/tools/vdb/README.md @@ -0,0 +1,17 @@ +# VDB Desktop/Server Toolchain + +This directory is the native OpenVDB boundary for the Web application. It is not linked into the browser WASM build. + +```bash +tools/vdb/provision-resources.sh +npm --prefix web run test:vdb-native +``` + +The provisioner builds two native executables, generates real bounded OpenVDB fixtures once without overwriting +their UUID-bearing source files, downloads the official +CC-BY-4.0 `sphere.vdb` sample with its Git LFS SHA-256, converts the fixtures with OpenVDB 13.0/NanoVDB 32.9, +and writes the resources under `/home/mes123456/resource-library/blender-web-vdb`. + +`vdb_to_nanovdb` accepts only `.vdb`, limits source/output bytes, grid count and active voxels, supports a grid +allowlist, writes uncompressed standard NanoVDB segments, and emits a machine-readable conversion report. +The current browser renderer remains blocked; these tools establish the conversion and resource boundary only. diff --git a/tools/vdb/build-nanovdb-manifest.mjs b/tools/vdb/build-nanovdb-manifest.mjs new file mode 100644 index 00000000..3fe0b672 --- /dev/null +++ b/tools/vdb/build-nanovdb-manifest.mjs @@ -0,0 +1,149 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + args.set(process.argv[index], process.argv[index + 1]); +} + +function required(name) { + const value = args.get(name); + if (!value) throw new Error(`VDB_MANIFEST_ARGUMENT_MISSING: ${name}`); + return value; +} + +function sha256(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function semantic(name) { + const known = new Map([ + ["density", "DENSITY"], + ["temperature", "TEMPERATURE"], + ["color", "COLOR"], + ["emission", "EMISSION"], + ["flame", "EMISSION"], + ["velocity", "VELOCITY"], + ]); + return known.get(name.toLowerCase()) ?? "CUSTOM"; +} + +const sourceFile = path.resolve(required("--source")); +const bundleFile = path.resolve(required("--bundle")); +const reportFile = path.resolve(required("--report")); +const outputFile = path.resolve(required("--output")); +const converterFile = path.resolve(required("--converter")); +const projectId = args.get("--project-id") ?? "vdb-fixtures"; +const sourcePath = args.get("--source-path") ?? `//volumes/${path.basename(sourceFile)}`; +const bundlePath = args.get("--bundle-path") ?? `//volumes/${path.basename(bundleFile)}`; +const blenderVersion = args.get("--blender-version") ?? "5.2.0"; +const converterTarget = args.get("--converter-target") ?? "DESKTOP"; +const chunkByteLength = Number(args.get("--chunk-bytes") ?? 4 * 1024 * 1024); + +if (converterTarget !== "DESKTOP" && converterTarget !== "SERVER") { + throw new Error("VDB_MANIFEST_ARGUMENT_INVALID: --converter-target"); +} +if (!Number.isSafeInteger(chunkByteLength) || chunkByteLength < 64 * 1024 || chunkByteLength > 16 * 1024 * 1024 || chunkByteLength % 32 !== 0) { + throw new Error("VDB_MANIFEST_ARGUMENT_INVALID: --chunk-bytes"); +} +for (const file of [sourceFile, bundleFile, reportFile, converterFile]) { + if (!fs.statSync(file).isFile()) throw new Error(`VDB_MANIFEST_RESOURCE_MISSING: ${file}`); +} + +const report = JSON.parse(fs.readFileSync(reportFile, "utf8")); +if (report.schemaVersion !== 1 || !Array.isArray(report.grids) || report.grids.length === 0) throw new Error("VDB_CONVERSION_INVALID: native report"); +if (path.resolve(report.input) !== sourceFile || path.resolve(report.output) !== bundleFile) throw new Error("VDB_CONVERSION_INVALID: report paths do not match artifacts"); + +const sourceSha256 = sha256(sourceFile); +const converter = { + target: converterTarget, + blenderVersion, + openVDBVersion: report.openVDBVersion, + nanoVDBVersion: report.nanoVDBVersion, + executableSha256: sha256(converterFile), +}; +const sourceGrids = report.grids.map((grid) => ({ + name: grid.name, + valueType: grid.sourceType.toUpperCase(), + voxelCount: grid.activeVoxelCount, + activeVoxelCount: grid.activeVoxelCount, + bounds: grid.indexBounds, +})); +const conversionRequest = { + schemaVersion: 1, + source: { + byteLength: fs.statSync(sourceFile).size, + sha256: sourceSha256, + grids: sourceGrids, + }, + selectedGrids: report.grids.map((grid) => grid.name), + quantization: report.quantization, + chunkByteLength, + converter, +}; +const conversionRequestSha256 = crypto.createHash("sha256").update(JSON.stringify(conversionRequest)).digest("hex"); + +const bundleBytes = fs.readFileSync(bundleFile); +const chunks = []; +for (let byteOffset = 0, index = 0; byteOffset < bundleBytes.length; byteOffset += chunkByteLength, index += 1) { + const data = bundleBytes.subarray(byteOffset, Math.min(bundleBytes.length, byteOffset + chunkByteLength)); + chunks.push({ index, byteOffset, byteLength: data.length, sha256: crypto.createHash("sha256").update(data).digest("hex") }); +} + +const grids = report.grids.map((grid) => ({ + name: grid.name, + valueType: grid.valueType, + gridClass: grid.gridClass, + semantic: semantic(grid.name), + activeVoxelCount: grid.activeVoxelCount, + segmentByteOffset: grid.segmentByteOffset, + segmentByteLength: grid.segmentByteLength, + byteOffset: grid.byteOffset, + byteLength: grid.byteLength, + indexBounds: grid.indexBounds, + worldBounds: grid.worldBounds, + voxelSize: grid.voxelSize, + indexToWorld: grid.indexToWorld, +})); +const gridBySemantic = new Map(grids.map((grid) => [grid.semantic, grid.name])); +if (!gridBySemantic.has("DENSITY")) throw new Error("NANOVDB_GRID_UNSUPPORTED: a browser volume bundle requires a density grid"); + +const manifest = { + schemaVersion: 1, + projectId, + sourcePath, + sourceSha256, + conversionRequestSha256, + bundlePath, + bundleByteLength: bundleBytes.length, + bundleSha256: crypto.createHash("sha256").update(bundleBytes).digest("hex"), + converter, + grids, + chunks, + material: { + densityGrid: gridBySemantic.get("DENSITY"), + ...(gridBySemantic.has("TEMPERATURE") ? { temperatureGrid: gridBySemantic.get("TEMPERATURE") } : {}), + ...(gridBySemantic.has("COLOR") ? { colorGrid: gridBySemantic.get("COLOR") } : {}), + ...(gridBySemantic.has("EMISSION") ? { emissionGrid: gridBySemantic.get("EMISSION") } : {}), + ...(gridBySemantic.has("VELOCITY") ? { velocityGrid: gridBySemantic.get("VELOCITY") } : {}), + densityScale: 1, + emissionScale: 0, + temperatureScale: 1, + anisotropy: 0, + interpolation: "LINEAR", + }, + gpu: { + representation: "NANOVDB_STORAGE_BUFFER", + byteAlignment: 32, + pageByteLength: chunkByteLength, + maxResidentBytes: 256 * 1024 * 1024, + shaderSemanticVersion: "volume-wgsl-v1", + ...(report.float32TreeLayout ? { float32TreeLayout: report.float32TreeLayout } : {}), + ...(report.vec3fTreeLayout ? { vec3fTreeLayout: report.vec3fTreeLayout } : {}), + }, +}; + +fs.mkdirSync(path.dirname(outputFile), { recursive: true }); +fs.writeFileSync(outputFile, `${JSON.stringify(manifest, null, 2)}\n`); +process.stdout.write(`nanovdb-manifest-ok grids=${grids.length} chunks=${chunks.length} bytes=${bundleBytes.length} sha256=${manifest.bundleSha256}\n`); diff --git a/tools/vdb/build-native-tools.sh b/tools/vdb/build-native-tools.sh new file mode 100755 index 00000000..5f7798d8 --- /dev/null +++ b/tools/vdb/build-native-tools.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +build_dir=${VDB_TOOLS_BUILD_DIR:-"$repo_root/build_vdb_tools"} +openvdb_root=${OPENVDB_ROOT:-/home/mes123456/working/build_oiio_deps/Release/openvdb} +tbb_root=${TBB_ROOT:-/home/mes123456/working/build_oiio_deps/Release/tbb} + +cmake -S "$repo_root/tools/vdb" -B "$build_dir" \ + -DOPENVDB_ROOT="$openvdb_root" \ + -DTBB_ROOT="$tbb_root" \ + -DCMAKE_BUILD_TYPE=Release +cmake --build "$build_dir" --parallel "${VDB_BUILD_JOBS:-4}" + +"$build_dir/vdb_to_nanovdb" --help +printf 'vdb-native-tools-ok build=%s\n' "$build_dir" diff --git a/tools/vdb/catalog-resources.mjs b/tools/vdb/catalog-resources.mjs new file mode 100644 index 00000000..77c35c00 --- /dev/null +++ b/tools/vdb/catalog-resources.mjs @@ -0,0 +1,78 @@ +import crypto from "node:crypto"; +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 resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb"); +const converter = path.join(repoRoot, "build_vdb_tools", "vdb_to_nanovdb"); +const generator = path.join(repoRoot, "build_vdb_tools", "vdb_fixture_generator"); +const openVDBLicense = "/home/mes123456/working/build_oiio_deps/build/openvdb/src/external_openvdb/LICENSE"; +const ccLicense = path.join(resourceRoot, "licenses", "CC-BY-4.0.txt"); + +function sha256(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function entry(id, relativePath, kind, license, origin, extra = {}) { + const file = path.join(resourceRoot, relativePath); + if (!fs.statSync(file).isFile()) throw new Error(`VDB_RESOURCE_MISSING: ${file}`); + return { id, kind, path: relativePath, byteLength: fs.statSync(file).size, sha256: sha256(file), license, origin, ...extra }; +} + +fs.mkdirSync(path.join(resourceRoot, "generated"), { recursive: true }); +fs.mkdirSync(path.join(resourceRoot, "licenses"), { recursive: true }); +fs.copyFileSync(openVDBLicense, path.join(resourceRoot, "licenses", "OpenVDB-Apache-2.0.txt")); +if (!fs.existsSync(ccLicense)) throw new Error("VDB_RESOURCE_LICENSE_MISSING: CC-BY-4.0.txt"); + +const smoke = fs.readFileSync(path.join(resourceRoot, "generated", "generated-smoke.vdb")); +fs.writeFileSync(path.join(resourceRoot, "generated", "generated-smoke-truncated.vdb"), smoke.subarray(0, 1024)); + +const generatedOrigin = "Generated locally by tools/vdb/vdb_fixture_generator.cc with OpenVDB 13.0.0; contains no third-party model data"; +const officialOrigin = "OpenVDB official sample model repository"; +const entries = [ + entry("generated-smoke", "generated/generated-smoke.vdb", "SOURCE_VDB", "PROJECT_GENERATED_FIXTURE", generatedOrigin), + entry("generated-level-set", "generated/generated-level-set.vdb", "SOURCE_VDB", "PROJECT_GENERATED_FIXTURE", generatedOrigin), + entry("generated-large-bounds-sparse", "generated/generated-large-bounds-sparse.vdb", "SOURCE_VDB", "PROJECT_GENERATED_FIXTURE", generatedOrigin), + entry("generated-smoke-truncated", "generated/generated-smoke-truncated.vdb", "MALFORMED_VDB", "PROJECT_GENERATED_FIXTURE", generatedOrigin, { expectedResult: "VDB_CONVERSION_FAILED" }), + entry("official-sphere", "official/sphere.vdb", "SOURCE_VDB", "CC-BY-4.0", officialOrigin, { + sourcePage: "https://www.openvdb.org/download/", + sourceUrl: "https://media.githubusercontent.com/media/AcademySoftwareFoundation/openvdb-website/master/download/models/sphere.vdb", + upstreamLfsSha256: "bb884a9a38354e47191c4ece4a11d1e051594a910fde56501cfc51ff52b171ab", + attribution: "Academy Software Foundation OpenVDB sample models", + }), + entry("generated-smoke-nanovdb", "nanovdb/generated-smoke.nvdb", "NANOVDB_BUNDLE", "PROJECT_GENERATED_FIXTURE", "Derived from generated-smoke", { derivedFrom: "generated-smoke" }), + entry("generated-level-set-nanovdb", "nanovdb/generated-level-set.nvdb", "NANOVDB_BUNDLE", "PROJECT_GENERATED_FIXTURE", "Derived from generated-level-set", { derivedFrom: "generated-level-set" }), + entry("generated-large-bounds-sparse-nanovdb", "nanovdb/generated-large-bounds-sparse.nvdb", "NANOVDB_BUNDLE", "PROJECT_GENERATED_FIXTURE", "Derived from generated-large-bounds-sparse", { derivedFrom: "generated-large-bounds-sparse" }), + entry("official-sphere-nanovdb", "nanovdb/official-sphere.nvdb", "NANOVDB_BUNDLE", "CC-BY-4.0", officialOrigin, { derivedFrom: "official-sphere" }), + entry("generated-smoke-browser-manifest", "manifests/generated-smoke.nanovdb.json", "NANOVDB_MANIFEST", "PROJECT_GENERATED_FIXTURE", "Derived from generated-smoke conversion report", { derivedFrom: "generated-smoke-nanovdb" }), + entry("generated-smoke-conversion-report", "reports/generated-smoke-conversion.json", "CONVERSION_REPORT", "PROJECT_GENERATED_FIXTURE", "Native OpenVDB to NanoVDB report", { derivedFrom: "generated-smoke" }), + entry("generated-level-set-conversion-report", "reports/generated-level-set-conversion.json", "CONVERSION_REPORT", "PROJECT_GENERATED_FIXTURE", "Native OpenVDB to NanoVDB report", { derivedFrom: "generated-level-set" }), + entry("generated-large-bounds-conversion-report", "reports/generated-large-bounds-sparse-conversion.json", "CONVERSION_REPORT", "PROJECT_GENERATED_FIXTURE", "Native OpenVDB to NanoVDB report", { derivedFrom: "generated-large-bounds-sparse" }), + entry("official-sphere-conversion-report", "reports/official-sphere-conversion.json", "CONVERSION_REPORT", "CC-BY-4.0", officialOrigin, { derivedFrom: "official-sphere" }), +]; + +const manifest = { + schemaVersion: 1, + libraryId: "blender-web-vdb", + resourceRoot, + licenses: [ + { id: "OpenVDB-Apache-2.0", path: "licenses/OpenVDB-Apache-2.0.txt", sha256: sha256(path.join(resourceRoot, "licenses", "OpenVDB-Apache-2.0.txt")) }, + { id: "CC-BY-4.0", path: "licenses/CC-BY-4.0.txt", sha256: sha256(ccLicense) }, + ], + toolchain: { + openVDBVersion: "13.0.0", + nanoVDBVersion: "32.9.0", + converterPath: converter, + converterSha256: sha256(converter), + generatorPath: generator, + generatorSha256: sha256(generator), + }, + entries, +}; +fs.writeFileSync(path.join(resourceRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +fs.writeFileSync(path.join(resourceRoot, "SOURCE.md"), `# Blender Web VDB Resource Library\n\n` + + `Generated fixtures are produced locally by \`tools/vdb/vdb_fixture_generator.cc\` and contain no third-party model data.\n\n` + + `\`official/sphere.vdb\` is from the OpenVDB official sample model repository, is covered by CC-BY-4.0, and was verified against Git LFS SHA-256 \`bb884a9a38354e47191c4ece4a11d1e051594a910fde56501cfc51ff52b171ab\`.\n\n` + + `Source page: https://www.openvdb.org/download/\n`); +process.stdout.write(`vdb-resource-catalog-ok entries=${entries.length} root=${resourceRoot}\n`); diff --git a/tools/vdb/provision-resources.sh b/tools/vdb/provision-resources.sh new file mode 100755 index 00000000..c951611c --- /dev/null +++ b/tools/vdb/provision-resources.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +resource_root=${VDB_RESOURCE_ROOT:-/home/mes123456/resource-library/blender-web-vdb} +build_dir=${VDB_TOOLS_BUILD_DIR:-"$repo_root/build_vdb_tools"} +sphere_sha=bb884a9a38354e47191c4ece4a11d1e051594a910fde56501cfc51ff52b171ab +license_sha=8d3fceb4cb62663775f02c1c551cfbf332d1c736667c17817c16c635caba21e0 + +mkdir -p "$resource_root"/{generated,official,nanovdb,reports,manifests,licenses} +"$repo_root/tools/vdb/build-native-tools.sh" +generated_files=(generated-smoke.vdb generated-level-set.vdb generated-large-bounds-sparse.vdb) +existing_generated=0 +for file in "${generated_files[@]}"; do + [[ -f "$resource_root/generated/$file" ]] && existing_generated=$((existing_generated + 1)) +done +if [[ $existing_generated -eq 0 ]]; then + "$build_dir/vdb_fixture_generator" "$resource_root/generated" +elif [[ $existing_generated -ne ${#generated_files[@]} ]]; then + printf 'generated VDB resource set is incomplete; refusing to overwrite existing source hashes\n' >&2 + exit 1 +fi + +download_verified() { + local url=$1 output=$2 expected=$3 + if [[ -f "$output" ]]; then + [[ $(sha256sum "$output" | cut -d' ' -f1) == "$expected" ]] || { printf 'existing resource hash mismatch: %s\n' "$output" >&2; return 1; } + return + fi + curl -L --fail --retry 5 --retry-delay 2 --connect-timeout 20 --max-time 600 --continue-at - -o "$output.part" "$url" + [[ $(sha256sum "$output.part" | cut -d' ' -f1) == "$expected" ]] || { printf 'downloaded resource hash mismatch: %s\n' "$output.part" >&2; return 1; } + mv "$output.part" "$output" +} + +download_verified \ + https://media.githubusercontent.com/media/AcademySoftwareFoundation/openvdb-website/master/download/models/sphere.vdb \ + "$resource_root/official/sphere.vdb" "$sphere_sha" +download_verified \ + https://raw.githubusercontent.com/AcademySoftwareFoundation/openvdb-website/master/LICENSE.txt \ + "$resource_root/licenses/CC-BY-4.0.txt" "$license_sha" + +convert() { + local source=$1 bundle=$2 report=$3 quantization=$4 + shift 4 + "$build_dir/vdb_to_nanovdb" --input "$source" --output "$bundle" --report "$report" --quantization "$quantization" "$@" +} + +convert "$resource_root/generated/generated-smoke.vdb" "$resource_root/nanovdb/generated-smoke.nvdb" "$resource_root/reports/generated-smoke-conversion.json" LOSSLESS \ + --grid density --grid temperature --grid color --grid velocity +convert "$resource_root/generated/generated-level-set.vdb" "$resource_root/nanovdb/generated-level-set.nvdb" "$resource_root/reports/generated-level-set-conversion.json" LOSSLESS --grid surface +convert "$resource_root/generated/generated-large-bounds-sparse.vdb" "$resource_root/nanovdb/generated-large-bounds-sparse.nvdb" "$resource_root/reports/generated-large-bounds-sparse-conversion.json" FP16 --grid density +convert "$resource_root/official/sphere.vdb" "$resource_root/nanovdb/official-sphere.nvdb" "$resource_root/reports/official-sphere-conversion.json" LOSSLESS + +node "$repo_root/tools/vdb/build-nanovdb-manifest.mjs" \ + --source "$resource_root/generated/generated-smoke.vdb" \ + --bundle "$resource_root/nanovdb/generated-smoke.nvdb" \ + --report "$resource_root/reports/generated-smoke-conversion.json" \ + --output "$resource_root/manifests/generated-smoke.nanovdb.json" \ + --converter "$build_dir/vdb_to_nanovdb" \ + --project-id vdb-fixtures \ + --source-path //volumes/generated-smoke.vdb \ + --bundle-path //volumes/generated-smoke.nvdb +VDB_RESOURCE_ROOT="$resource_root" node "$repo_root/tools/vdb/catalog-resources.mjs" +VDB_RESOURCE_ROOT="$resource_root" node "$repo_root/tools/vdb/snapshot-evidence.mjs" +VDB_RESOURCE_ROOT="$resource_root" npm --prefix "$repo_root/web" run test:vdb-native +printf 'vdb-resources-ready root=%s\n' "$resource_root" diff --git a/tools/vdb/server/vdb-job-server.mjs b/tools/vdb/server/vdb-job-server.mjs new file mode 100755 index 00000000..fac9887e --- /dev/null +++ b/tools/vdb/server/vdb-job-server.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { VDBJobService, createVDBJobHttpServer } from "./vdb-job-service.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const host = process.env.VDB_SERVER_HOST ?? "127.0.0.1"; +const port = Number(process.env.VDB_SERVER_PORT ?? 8787); +const service = new VDBJobService({ + converter: process.env.VDB_CONVERTER ?? path.join(root, "build_vdb_tools/vdb_to_nanovdb"), + root: process.env.VDB_SERVER_DATA ?? path.join(root, "build_vdb_server"), +}); +const server = createVDBJobHttpServer(service); +server.listen(port, host, () => process.stdout.write(`vdb-job-server-ready http://${host}:${port}\n`)); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/tools/vdb/server/vdb-job-service.mjs b/tools/vdb/server/vdb-job-service.mjs new file mode 100644 index 00000000..b821878a --- /dev/null +++ b/tools/vdb/server/vdb-job-service.mjs @@ -0,0 +1,293 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const SHA256 = /^[a-f0-9]{64}$/; +const PROJECT_ID = /^[A-Za-z0-9_-]{1,64}$/; +const GRID_NAME = /^[A-Za-z0-9_.:-]{1,255}$/; +const MAX_SOURCE_BYTES = 512 * 1024 * 1024; +const MAX_LOG_BYTES = 1024 * 1024; + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; + return JSON.stringify(value); +} + +function fileSha256(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +async function runProcess(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], ...options }); + let output = ""; + const append = (chunk) => { output = `${output}${chunk}`.slice(-MAX_LOG_BYTES); options.onLog?.(chunk.toString()); }; + child.stdout.on("data", append); + child.stderr.on("data", append); + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal, output })); + options.onChild?.(child); + }); +} + +function parseHeaders(request) { + const projectId = request.headers["x-vdb-project-id"] ?? "vdb-server"; + const sourcePath = request.headers["x-vdb-source-path"] ?? "//volumes/upload.vdb"; + const expectedSha256 = request.headers["x-vdb-source-sha256"]; + const quantization = request.headers["x-vdb-quantization"] ?? "LOSSLESS"; + const chunkByteLength = Number(request.headers["x-vdb-chunk-bytes"] ?? 4 * 1024 * 1024); + const selectedGrids = String(request.headers["x-vdb-grids"] ?? "").split(",").filter(Boolean); + if (typeof projectId !== "string" || !PROJECT_ID.test(projectId)) throw new Error("VDB_CONVERSION_INVALID: project id"); + if (typeof sourcePath !== "string" || !sourcePath.startsWith("//") || !sourcePath.toLowerCase().endsWith(".vdb") || sourcePath.includes("..")) throw new Error("VDB_CONVERSION_INVALID: source path"); + if (expectedSha256 !== undefined && (typeof expectedSha256 !== "string" || !SHA256.test(expectedSha256))) throw new Error("VDB_CONVERSION_INVALID: source SHA-256"); + if (quantization !== "LOSSLESS" && quantization !== "FP16") throw new Error("VDB_CONVERSION_INVALID: quantization"); + if (!Number.isSafeInteger(chunkByteLength) || chunkByteLength < 64 * 1024 || chunkByteLength > 16 * 1024 * 1024 || chunkByteLength % 32 !== 0) throw new Error("VDB_CONVERSION_INVALID: chunk size"); + if (selectedGrids.length > 64 || new Set(selectedGrids).size !== selectedGrids.length || selectedGrids.some((name) => !GRID_NAME.test(name))) throw new Error("VDB_CONVERSION_INVALID: grid allowlist"); + return { projectId, sourcePath, expectedSha256, quantization, chunkByteLength, selectedGrids }; +} + +async function receiveSource(request, file) { + const hash = crypto.createHash("sha256"); + let bytes = 0; + const output = fs.createWriteStream(file, { flags: "wx", mode: 0o600 }); + try { + for await (const chunk of request) { + bytes += chunk.length; + if (bytes > MAX_SOURCE_BYTES) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: source upload"); + hash.update(chunk); + if (!output.write(chunk)) await new Promise((resolve) => output.once("drain", resolve)); + } + await new Promise((resolve, reject) => output.end((error) => error ? reject(error) : resolve())); + } + catch (error) { + output.destroy(); + await fsp.rm(file, { force: true }); + throw error; + } + if (bytes === 0) throw new Error("VDB_CONVERSION_INVALID: empty source upload"); + return { bytes, sha256: hash.digest("hex") }; +} + +function json(response, status, value) { + const body = Buffer.from(`${JSON.stringify(value)}\n`); + response.writeHead(status, { "Content-Type": "application/json", "Content-Length": body.length, "Cache-Control": "no-store" }); + response.end(body); +} + +export class VDBJobService { + constructor(options = {}) { + this.converter = path.resolve(options.converter ?? path.join(moduleRoot, "build_vdb_tools/vdb_to_nanovdb")); + this.manifestBuilder = path.resolve(options.manifestBuilder ?? path.join(moduleRoot, "tools/vdb/build-nanovdb-manifest.mjs")); + this.root = path.resolve(options.root ?? path.join(moduleRoot, "build_vdb_server")); + this.secret = options.secret ?? process.env.VDB_SERVER_SIGNING_KEY; + this.timeoutMs = options.timeoutMs ?? 120_000; + this.jobs = new Map(); + this.byKey = new Map(); + if (!this.secret || Buffer.byteLength(this.secret) < 32) throw new Error("VDB_SERVER_CONFIG_INVALID: signing key must be at least 32 bytes"); + if (!fs.existsSync(this.converter) || !fs.existsSync(this.manifestBuilder)) throw new Error("VDB_SERVER_CONFIG_INVALID: converter or manifest builder is missing"); + fs.mkdirSync(path.join(this.root, "jobs"), { recursive: true, mode: 0o700 }); + fs.mkdirSync(path.join(this.root, "incoming"), { recursive: true, mode: 0o700 }); + this.converterSha256 = fileSha256(this.converter); + } + + summary(job) { + return { + id: job.id, + key: job.key, + state: job.state, + progress: job.progress, + sourceSha256: job.sourceSha256, + sourceBytes: job.sourceBytes, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + error: job.error, + artifacts: job.state === "SUCCEEDED" ? { + manifest: `/v1/vdb/jobs/${job.id}/manifest`, + bundle: `/v1/vdb/jobs/${job.id}/bundle`, + report: `/v1/vdb/jobs/${job.id}/report`, + signature: job.signature, + } : undefined, + sandbox: "bwrap-unshare-all+readonly-root+prlimit", + }; + } + + persist(job) { + job.updatedAt = new Date().toISOString(); + fs.writeFileSync(path.join(job.directory, "job.json"), `${JSON.stringify(this.summary(job), null, 2)}\n`, { mode: 0o600 }); + } + + appendLog(job, value) { + job.logs = `${job.logs}${value}`.slice(-MAX_LOG_BYTES); + } + + async create(request) { + const metadata = parseHeaders(request); + const incoming = path.join(this.root, "incoming", `${crypto.randomUUID()}.vdb`); + const source = await receiveSource(request, incoming); + if (metadata.expectedSha256 && metadata.expectedSha256 !== source.sha256) { + await fsp.rm(incoming, { force: true }); + throw new Error("NANOVDB_HASH_MISMATCH: uploaded source"); + } + const key = crypto.createHash("sha256").update(stableJson({ + schemaVersion: 1, + sourceSha256: source.sha256, + quantization: metadata.quantization, + chunkByteLength: metadata.chunkByteLength, + selectedGrids: metadata.selectedGrids, + converterSha256: this.converterSha256, + })).digest("hex"); + const existing = this.byKey.get(key); + if (existing && existing.state !== "FAILED" && existing.state !== "CANCELLED") { + await fsp.rm(incoming, { force: true }); + return { job: existing, deduplicated: true }; + } + const id = `vdb-${key.slice(0, 24)}`; + const directory = path.join(this.root, "jobs", id); + await fsp.rm(directory, { recursive: true, force: true }); + await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); + await fsp.rename(incoming, path.join(directory, "source.vdb")); + const job = { + id, key, directory, metadata, sourceSha256: source.sha256, sourceBytes: source.bytes, + state: "QUEUED", progress: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + logs: "", error: undefined, child: undefined, timer: undefined, signature: undefined, + }; + this.jobs.set(id, job); + this.byKey.set(key, job); + this.persist(job); + setImmediate(() => void this.run(job)); + return { job, deduplicated: false }; + } + + async run(job) { + if (job.state === "CANCELLED") return; + job.state = "RUNNING"; + job.progress = 0.1; + this.persist(job); + const output = path.join(job.directory, "bundle.nvdb"); + const report = path.join(job.directory, "report.json"); + const cancelFile = path.join(job.directory, "cancel"); + const converterArgs = [ + "--input", path.join(job.directory, "source.vdb"), "--output", output, "--report", report, + "--quantization", job.metadata.quantization, "--cancel-file", cancelFile, "--timeout-ms", String(this.timeoutMs), + ...job.metadata.selectedGrids.flatMap((name) => ["--grid", name]), + ]; + const args = [ + "--die-with-parent", "--new-session", "--unshare-all", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", + "--bind", job.directory, job.directory, "--chdir", job.directory, + "/usr/bin/prlimit", "--as=2147483648", "--cpu=120", "--fsize=1200000000", "--nproc=128", "--", this.converter, ...converterArgs, + ]; + job.timer = setTimeout(() => { + this.appendLog(job, "VDB_SERVER_TIMEOUT: terminating sandbox\n"); + this.kill(job); + }, this.timeoutMs + 2_000); + try { + const converted = await runProcess("/usr/bin/bwrap", args, { + detached: true, + onLog: (value) => this.appendLog(job, value), + onChild: (child) => { job.child = child; }, + }); + job.child = undefined; + if (job.state === "CANCELLED") return; + if (converted.code !== 0) throw new Error(`VDB_CONVERSION_FAILED: sandbox exited ${converted.code ?? converted.signal}`); + job.progress = 0.8; + this.persist(job); + const manifest = path.join(job.directory, "manifest.json"); + const built = await runProcess(process.execPath, [this.manifestBuilder, + "--source", path.join(job.directory, "source.vdb"), "--bundle", output, "--report", report, + "--output", manifest, "--converter", this.converter, "--converter-target", "SERVER", + "--project-id", job.metadata.projectId, "--source-path", job.metadata.sourcePath, + "--bundle-path", `//volumes/${job.key}.nvdb`, "--chunk-bytes", String(job.metadata.chunkByteLength), + ], { onLog: (value) => this.appendLog(job, value) }); + if (built.code !== 0) throw new Error(`VDB_MANIFEST_FAILED: builder exited ${built.code ?? built.signal}`); + const manifestSha256 = fileSha256(manifest); + const bundleSha256 = fileSha256(output); + job.signature = crypto.createHmac("sha256", this.secret).update(`${job.id}:${manifestSha256}:${bundleSha256}`).digest("hex"); + job.state = "SUCCEEDED"; + job.progress = 1; + this.persist(job); + } + catch (error) { + if (job.state !== "CANCELLED") { + job.state = "FAILED"; + job.error = error instanceof Error ? error.message : String(error); + this.persist(job); + } + await Promise.all([fsp.rm(output, { force: true }), fsp.rm(report, { force: true }), fsp.rm(path.join(job.directory, "manifest.json"), { force: true })]); + } + finally { + if (job.timer) clearTimeout(job.timer); + job.timer = undefined; + job.child = undefined; + fs.writeFileSync(path.join(job.directory, "job.log"), job.logs, { mode: 0o600 }); + } + } + + kill(job) { + if (!job.child?.pid) return; + try { process.kill(-job.child.pid, "SIGTERM"); } catch { /* already exited */ } + const pid = job.child.pid; + setTimeout(() => { try { process.kill(-pid, "SIGKILL"); } catch { /* already exited */ } }, 1_000).unref(); + } + + cancel(id) { + const job = this.jobs.get(id); + if (!job) return undefined; + if (["SUCCEEDED", "FAILED", "CANCELLED"].includes(job.state)) return job; + job.state = "CANCELLED"; + job.progress = 0; + fs.writeFileSync(path.join(job.directory, "cancel"), "cancelled\n", { mode: 0o600 }); + this.kill(job); + this.persist(job); + return job; + } + + artifact(job, name) { + const allowed = { manifest: "manifest.json", bundle: "bundle.nvdb", report: "report.json", logs: "job.log" }; + if (job.state !== "SUCCEEDED" && name !== "logs") return undefined; + const filename = allowed[name]; + if (!filename) return undefined; + const file = path.join(job.directory, filename); + return fs.existsSync(file) ? file : undefined; + } +} + +export function createVDBJobHttpServer(service) { + return http.createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "GET" && url.pathname === "/healthz") return json(response, 200, { ok: true, converterSha256: service.converterSha256, sandbox: true }); + if (request.method === "POST" && url.pathname === "/v1/vdb/jobs") { + const created = await service.create(request); + return json(response, created.deduplicated ? 200 : 202, { ...service.summary(created.job), deduplicated: created.deduplicated }); + } + const match = url.pathname.match(/^\/v1\/vdb\/jobs\/([A-Za-z0-9-]+)(?:\/(manifest|bundle|report|logs))?$/); + if (!match) return json(response, 404, { error: "NOT_FOUND" }); + const job = service.jobs.get(match[1]); + if (!job) return json(response, 404, { error: "VDB_JOB_NOT_FOUND" }); + if (request.method === "DELETE" && !match[2]) return json(response, 200, service.summary(service.cancel(job.id))); + if (request.method !== "GET") return json(response, 405, { error: "METHOD_NOT_ALLOWED" }); + if (!match[2]) return json(response, 200, service.summary(job)); + const artifact = service.artifact(job, match[2]); + if (!artifact) return json(response, 409, { error: "VDB_ARTIFACT_NOT_READY", state: job.state }); + const stat = fs.statSync(artifact); + response.writeHead(200, { + "Content-Type": match[2] === "bundle" ? "application/x-nanovdb" : match[2] === "logs" ? "text/plain" : "application/json", + "Content-Length": stat.size, + "X-Content-SHA256": fileSha256(artifact), + "X-VDB-Signature": job.signature ?? "", + "Cache-Control": "private, immutable", + }); + fs.createReadStream(artifact).pipe(response); + } + catch (error) { + if (!response.headersSent) json(response, 400, { error: error instanceof Error ? error.message : String(error) }); + else response.destroy(error instanceof Error ? error : undefined); + } + }); +} diff --git a/tools/vdb/snapshot-evidence.mjs b/tools/vdb/snapshot-evidence.mjs new file mode 100644 index 00000000..4103d92b --- /dev/null +++ b/tools/vdb/snapshot-evidence.mjs @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb"); +const catalog = JSON.parse(fs.readFileSync(path.join(resourceRoot, "manifest.json"), "utf8")); +if (catalog.schemaVersion !== 1 || !Array.isArray(catalog.entries) || catalog.entries.length === 0) throw new Error("VDB_RESOURCE_CATALOG_INVALID"); + +const evidence = { + schemaVersion: 1, + resourceLibrary: resourceRoot, + browserOpenVDB: false, + desktopOpenVDB: true, + serverJobConfigured: true, + serverIsolation: "bwrap-unshare-all+readonly-root+prlimit", + opfsStreamingConfigured: true, + webgpuRendererConfigured: true, + mainVolumeRoundtripConfigured: true, + primaryViewportVolumeIntegrated: false, + materialSemantics: { + supported: ["DENSITY_GRID_FLOAT32", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "NEAREST", "LINEAR"], + explicitLosses: ["COLOR_GRID", "TEMPERATURE_BLACKBODY", "EMISSION_GRID", "VELOCITY_MOTION"], + }, + chromiumWebGPU: { + renderer: "SwiftShader WebGPU", + densityPayloadBytes: 2563744, + nativeCpuGpuSamples: 5, + imageWidth: 96, + imageHeight: 96, + imageSha256: "7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978", + }, + releaseStatus: "BLOCKED", + remainingReleaseBlockers: [ + "primary-offscreen-viewport-volume-scene-integration", + "color-temperature-emission-grid-shading", + "three-view-desktop-chromium-pixel-goldens", + "64MiB-512MiB-1GiB-stream-device-loss-oom-gates", + ], + toolchain: catalog.toolchain, + licenses: catalog.licenses, + resources: catalog.entries, + validations: [ + "source-byte-length-and-sha256", + "official-git-lfs-sha256", + "openvdb13-nanovdb32-real-conversion", + "same-source-conversion-determinism", + "semantic-grid-conversion-determinism", + "official-sphere-conversion-determinism", + "truncated-vdb-rejection", + "browser-manifest-validation", + "browser-openvdb-disabled", + "native-cancel-timeout-atomic-output", + "isolated-server-job-idempotency-signature-cancel", + "desktop-server-bundle-hash-equality", + "real-bundle-opfs-atomic-commit-worker-reopen-tamper-gate", + "nanovdb-float32-native-cpu-webgpu-sample-equality", + "chromium-webgpu-volume-integration-golden", + "bounded-material-mapping-loss-report", + "volume-main-undo-redo-save-reopen", + ], +}; +const output = path.join(root, "docs", "status", "vdb-native-evidence.json"); +fs.writeFileSync(output, `${JSON.stringify(evidence, null, 2)}\n`); +process.stdout.write(`vdb-evidence-snapshot-ok resources=${evidence.resources.length} output=${output}\n`); diff --git a/tools/vdb/vdb_fixture_generator.cc b/tools/vdb/vdb_fixture_generator.cc new file mode 100644 index 00000000..75df4dd6 --- /dev/null +++ b/tools/vdb/vdb_fixture_generator.cc @@ -0,0 +1,96 @@ +#include +#include +#include + +#include +#include +#include + +namespace fs = std::filesystem; + +static void write_grid_file(const fs::path &path, const openvdb::GridPtrVec &grids) +{ + openvdb::io::File file(path.string()); + file.write(grids); + file.close(); +} + +int main(int argc, char **argv) +{ + if (argc != 2) { + std::cerr << "usage: vdb_fixture_generator OUTPUT_DIRECTORY\n"; + return 2; + } + + try { + openvdb::initialize(); + const fs::path output_dir = fs::absolute(argv[1]); + fs::create_directories(output_dir); + + constexpr float voxel_size = 0.2f; + auto surface = openvdb::tools::createLevelSetSphere( + 3.0f, openvdb::Vec3f(0.0f), voxel_size, 3.0f, false); + surface->setName("surface"); + + auto density = surface->deepCopy(); + openvdb::tools::sdfToFogVolume(*density); + density->setName("density"); + density->setGridClass(openvdb::GRID_FOG_VOLUME); + + auto temperature = openvdb::FloatGrid::create(0.0f); + auto color = openvdb::Vec3SGrid::create(openvdb::Vec3f(0.0f)); + auto velocity = openvdb::Vec3SGrid::create(openvdb::Vec3f(0.0f)); + temperature->setTransform(density->transform().copy()); + color->setTransform(density->transform().copy()); + velocity->setTransform(density->transform().copy()); + temperature->setName("temperature"); + color->setName("color"); + velocity->setName("velocity"); + temperature->setGridClass(openvdb::GRID_FOG_VOLUME); + color->setGridClass(openvdb::GRID_FOG_VOLUME); + velocity->setGridClass(openvdb::GRID_STAGGERED); + + const auto density_accessor = density->getConstAccessor(); + auto temperature_accessor = temperature->getAccessor(); + auto color_accessor = color->getAccessor(); + auto velocity_accessor = velocity->getAccessor(); + for (int z = -18; z <= 18; ++z) { + for (int y = -18; y <= 18; ++y) { + for (int x = -18; x <= 18; ++x) { + const openvdb::Coord coord(x, y, z); + const float value = density_accessor.getValue(coord); + if (value <= 0.0f) { + continue; + } + temperature_accessor.setValueOn(coord, 300.0f + 1200.0f * value); + color_accessor.setValueOn( + coord, + openvdb::Vec3f((x + 18.0f) / 36.0f, (y + 18.0f) / 36.0f, (z + 18.0f) / 36.0f)); + velocity_accessor.setValueOn( + coord, openvdb::Vec3f(-0.02f * y, 0.02f * x, 0.1f * value)); + } + } + } + + write_grid_file(output_dir / "generated-smoke.vdb", {density, temperature, color, velocity}); + write_grid_file(output_dir / "generated-level-set.vdb", {surface}); + + auto sparse = openvdb::FloatGrid::create(0.0f); + sparse->setName("density"); + sparse->setGridClass(openvdb::GRID_FOG_VOLUME); + auto sparse_accessor = sparse->getAccessor(); + sparse_accessor.setValueOn(openvdb::Coord(-100000, -100000, -100000), 0.25f); + sparse_accessor.setValueOn(openvdb::Coord(0, 0, 0), 1.0f); + sparse_accessor.setValueOn(openvdb::Coord(100000, 100000, 100000), 0.5f); + write_grid_file(output_dir / "generated-large-bounds-sparse.vdb", {sparse}); + + std::cout << "generated-vdb-fixtures output=" << output_dir.string() + << " openvdb=" << openvdb::getLibraryVersionString() << " files=3\n"; + openvdb::uninitialize(); + return 0; + } + catch (const std::exception &error) { + std::cerr << "VDB_FIXTURE_GENERATION_FAILED: " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/vdb/vdb_to_nanovdb.cc b/tools/vdb/vdb_to_nanovdb.cc new file mode 100644 index 00000000..b843a80e --- /dev/null +++ b/tools/vdb/vdb_to_nanovdb.cc @@ -0,0 +1,484 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace fs = std::filesystem; + +constexpr uint64_t MAX_SOURCE_BYTES = 512ULL * 1024ULL * 1024ULL; +constexpr uint64_t MAX_OUTPUT_BYTES = 1024ULL * 1024ULL * 1024ULL; +constexpr uint64_t MAX_ACTIVE_VOXELS = 64ULL * 1000ULL * 1000ULL; +constexpr size_t MAX_GRIDS = 64; + +struct Options { + fs::path input; + fs::path output; + fs::path report; + std::vector grids; + std::string quantization = "LOSSLESS"; + fs::path cancel_file; + uint64_t timeout_ms = 0; +}; + +static std::atomic interrupted(false); + +static void request_interrupt(int) +{ + interrupted.store(true, std::memory_order_relaxed); +} + +struct GridReport { + std::string name; + std::string source_type; + std::string value_type; + std::string grid_class; + uint64_t active_voxels = 0; + uint64_t segment_offset = 0; + uint64_t segment_length = 0; + uint64_t grid_offset = 0; + uint64_t grid_length = 0; + openvdb::CoordBBox index_bounds; + openvdb::BBoxd world_bounds; + openvdb::Vec3d voxel_size; + std::array index_to_world{}; + struct ScalarSample { + openvdb::Coord coord; + float value; + bool active; + }; + struct VectorSample { + openvdb::Coord coord; + std::array value; + bool active; + }; + std::vector scalar_samples; + std::vector vector_samples; +}; + +static std::string json_string(const std::string &value) +{ + std::ostringstream stream; + stream << '"'; + for (const unsigned char character : value) { + switch (character) { + case '"': stream << "\\\""; break; + case '\\': stream << "\\\\"; break; + case '\b': stream << "\\b"; break; + case '\f': stream << "\\f"; break; + case '\n': stream << "\\n"; break; + case '\r': stream << "\\r"; break; + case '\t': stream << "\\t"; break; + default: + if (character < 0x20) { + stream << "\\u" << std::hex << std::setw(4) << std::setfill('0') << int(character) << std::dec; + } + else { + stream << character; + } + } + } + stream << '"'; + return stream.str(); +} + +static Options parse_options(int argc, char **argv) +{ + Options options; + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + auto value = [&](const char *name) -> std::string { + if (++index >= argc) { + throw std::runtime_error(std::string("missing value for ") + name); + } + return argv[index]; + }; + if (argument == "--input") options.input = value("--input"); + else if (argument == "--output") options.output = value("--output"); + else if (argument == "--report") options.report = value("--report"); + else if (argument == "--grid") options.grids.push_back(value("--grid")); + else if (argument == "--quantization") options.quantization = value("--quantization"); + else if (argument == "--cancel-file") options.cancel_file = value("--cancel-file"); + else if (argument == "--timeout-ms") { + const std::string raw = value("--timeout-ms"); + size_t consumed = 0; + options.timeout_ms = std::stoull(raw, &consumed); + if (consumed != raw.size() || options.timeout_ms < 1 || options.timeout_ms > 60ULL * 60ULL * 1000ULL) { + throw std::runtime_error("timeout must be between 1ms and 1h"); + } + } + else if (argument == "--help") { + std::cout << "usage: vdb_to_nanovdb --input FILE.vdb --output FILE.nvdb --report REPORT.json " + "[--grid NAME] [--quantization LOSSLESS|FP16] [--cancel-file FILE] [--timeout-ms MS]\n"; + std::exit(0); + } + else { + throw std::runtime_error("unknown argument: " + argument); + } + } + if (options.input.empty() || options.output.empty() || options.report.empty()) { + throw std::runtime_error("--input, --output and --report are required"); + } + if (options.input.extension() != ".vdb" || options.output.extension() != ".nvdb" || options.report.extension() != ".json") { + throw std::runtime_error("input/output/report extensions must be .vdb/.nvdb/.json"); + } + if (options.quantization != "LOSSLESS" && options.quantization != "FP16") { + throw std::runtime_error("quantization must be LOSSLESS or FP16"); + } + if (options.grids.size() > MAX_GRIDS || std::set(options.grids.begin(), options.grids.end()).size() != options.grids.size()) { + throw std::runtime_error("selected grid list is duplicated or exceeds the budget"); + } + return options; +} + +static void check_interrupted(const Options &options, + const std::chrono::steady_clock::time_point started, + const char *stage) +{ + if (interrupted.load(std::memory_order_relaxed) || + (!options.cancel_file.empty() && fs::exists(options.cancel_file))) + { + throw std::runtime_error(std::string("conversion cancelled during ") + stage); + } + if (options.timeout_ms > 0) { + const uint64_t elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started) + .count(); + if (elapsed >= options.timeout_ms) { + throw std::runtime_error(std::string("conversion timed out during ") + stage); + } + } +} + +static std::string grid_class_name(openvdb::GridClass grid_class) +{ + switch (grid_class) { + case openvdb::GRID_LEVEL_SET: return "LEVEL_SET"; + case openvdb::GRID_FOG_VOLUME: return "FOG_VOLUME"; + case openvdb::GRID_STAGGERED: return "STAGGERED"; + default: return "UNKNOWN"; + } +} + +static openvdb::BBoxd world_bounds(const openvdb::GridBase &grid, const openvdb::CoordBBox &bbox) +{ + auto corner_point = [&](int corner) { + return openvdb::Vec3d( + corner & 1 ? bbox.max().x() + 1.0 : bbox.min().x(), + corner & 2 ? bbox.max().y() + 1.0 : bbox.min().y(), + corner & 4 ? bbox.max().z() + 1.0 : bbox.min().z()); + }; + const openvdb::Vec3d first = grid.transform().indexToWorld(corner_point(0)); + openvdb::BBoxd result(first, first); + for (int corner = 1; corner < 8; ++corner) { + result.expand(grid.transform().indexToWorld(corner_point(corner))); + } + return result; +} + +static std::array index_to_world(const openvdb::GridBase &grid) +{ + const openvdb::Vec3d origin = grid.transform().indexToWorld(openvdb::Vec3d(0.0)); + const openvdb::Vec3d x = grid.transform().indexToWorld(openvdb::Vec3d(1.0, 0.0, 0.0)) - origin; + const openvdb::Vec3d y = grid.transform().indexToWorld(openvdb::Vec3d(0.0, 1.0, 0.0)) - origin; + const openvdb::Vec3d z = grid.transform().indexToWorld(openvdb::Vec3d(0.0, 0.0, 1.0)) - origin; + return {x.x(), y.x(), z.x(), origin.x(), + x.y(), y.y(), z.y(), origin.y(), + x.z(), y.z(), z.z(), origin.z(), + 0.0, 0.0, 0.0, 1.0}; +} + +static nanovdb::GridHandle convert_grid( + const openvdb::GridBase::Ptr &grid, const std::string &quantization) +{ + if (grid->isType()) { + auto typed = openvdb::GridBase::grid(grid); + if (quantization == "FP16") { + nanovdb::tools::CreateNanoGrid converter(*typed); + converter.setStats(nanovdb::tools::StatsMode::All); + converter.setChecksum(nanovdb::CheckMode::Full); + return converter.getHandle(); + } + } + else if (!grid->isType()) { + throw std::runtime_error("unsupported OpenVDB grid type for " + grid->getName() + ": " + grid->valueType()); + } + if (quantization != "LOSSLESS") { + throw std::runtime_error("FP16 is supported only for FloatGrid: " + grid->getName()); + } + return nanovdb::tools::openToNanoVDB( + grid, nanovdb::tools::StatsMode::All, nanovdb::CheckMode::Full, 0); +} + +static void write_vec3(std::ostream &output, const openvdb::Vec3d &value) +{ + output << '[' << value.x() << ',' << value.y() << ',' << value.z() << ']'; +} + +static void write_coord(std::ostream &output, const openvdb::Coord &value) +{ + output << '[' << value.x() << ',' << value.y() << ',' << value.z() << ']'; +} + +static void write_report(const Options &options, + const fs::path &report_path, + const std::vector &grids) +{ + std::ofstream output(report_path, std::ios::out | std::ios::trunc); + if (!output) throw std::runtime_error("failed to create conversion report"); + char nano_version[16]; + nanovdb::toStr(nano_version, nanovdb::Version()); + output << std::setprecision(17) + << "{\n \"schemaVersion\":1,\n" + << " \"input\":" << json_string(fs::absolute(options.input).string()) << ",\n" + << " \"output\":" << json_string(fs::absolute(options.output).string()) << ",\n" + << " \"quantization\":" << json_string(options.quantization) << ",\n" + << " \"openVDBVersion\":" << json_string(openvdb::getLibraryVersionString()) << ",\n" + << " \"nanoVDBVersion\":" << json_string(nano_version) << ",\n"; + using FloatRootData = nanovdb::RootData>; + using FloatUpperData = nanovdb::InternalData, 5>; + using FloatLowerData = nanovdb::InternalData, 4>; + using FloatLeafData = nanovdb::LeafData; + using Vec3RootData = nanovdb::RootData>; + using Vec3UpperData = nanovdb::InternalData, 5>; + using Vec3LowerData = nanovdb::InternalData, 4>; + using Vec3LeafData = nanovdb::LeafData; + output << " \"float32TreeLayout\":{" + << "\"gridDataBytes\":" << sizeof(nanovdb::GridData) + << ",\"treeDataBytes\":" << sizeof(nanovdb::TreeData) + << ",\"treeRootOffsetOffset\":" << offsetof(nanovdb::TreeData, mNodeOffset[3]) + << ",\"rootDataBytes\":" << sizeof(FloatRootData) + << ",\"rootTableSizeOffset\":" << offsetof(FloatRootData, mTableSize) + << ",\"rootTileBytes\":" << sizeof(FloatRootData::Tile) + << ",\"rootTileKeyOffset\":" << offsetof(FloatRootData::Tile, key) + << ",\"rootTileChildOffset\":" << offsetof(FloatRootData::Tile, child) + << ",\"rootTileStateOffset\":" << offsetof(FloatRootData::Tile, state) + << ",\"rootTileValueOffset\":" << offsetof(FloatRootData::Tile, value) + << ",\"upperNodeBytes\":" << sizeof(FloatUpperData) + << ",\"upperValueMaskOffset\":" << offsetof(FloatUpperData, mValueMask) + << ",\"upperChildMaskOffset\":" << offsetof(FloatUpperData, mChildMask) + << ",\"upperTableOffset\":" << offsetof(FloatUpperData, mTable) + << ",\"lowerNodeBytes\":" << sizeof(FloatLowerData) + << ",\"lowerValueMaskOffset\":" << offsetof(FloatLowerData, mValueMask) + << ",\"lowerChildMaskOffset\":" << offsetof(FloatLowerData, mChildMask) + << ",\"lowerTableOffset\":" << offsetof(FloatLowerData, mTable) + << ",\"leafNodeBytes\":" << sizeof(FloatLeafData) + << ",\"leafValueMaskOffset\":" << offsetof(FloatLeafData, mValueMask) + << ",\"leafValuesOffset\":" << offsetof(FloatLeafData, mValues) + << "},\n" + << " \"vec3fTreeLayout\":{" + << "\"gridDataBytes\":" << sizeof(nanovdb::GridData) + << ",\"treeDataBytes\":" << sizeof(nanovdb::TreeData) + << ",\"treeRootOffsetOffset\":" << offsetof(nanovdb::TreeData, mNodeOffset[3]) + << ",\"rootDataBytes\":" << sizeof(Vec3RootData) + << ",\"rootTableSizeOffset\":" << offsetof(Vec3RootData, mTableSize) + << ",\"rootTileBytes\":" << sizeof(Vec3RootData::Tile) + << ",\"rootTileKeyOffset\":" << offsetof(Vec3RootData::Tile, key) + << ",\"rootTileChildOffset\":" << offsetof(Vec3RootData::Tile, child) + << ",\"rootTileStateOffset\":" << offsetof(Vec3RootData::Tile, state) + << ",\"rootTileValueOffset\":" << offsetof(Vec3RootData::Tile, value) + << ",\"upperNodeBytes\":" << sizeof(Vec3UpperData) + << ",\"upperValueMaskOffset\":" << offsetof(Vec3UpperData, mValueMask) + << ",\"upperChildMaskOffset\":" << offsetof(Vec3UpperData, mChildMask) + << ",\"upperTableOffset\":" << offsetof(Vec3UpperData, mTable) + << ",\"lowerNodeBytes\":" << sizeof(Vec3LowerData) + << ",\"lowerValueMaskOffset\":" << offsetof(Vec3LowerData, mValueMask) + << ",\"lowerChildMaskOffset\":" << offsetof(Vec3LowerData, mChildMask) + << ",\"lowerTableOffset\":" << offsetof(Vec3LowerData, mTable) + << ",\"leafNodeBytes\":" << sizeof(Vec3LeafData) + << ",\"leafValueMaskOffset\":" << offsetof(Vec3LeafData, mValueMask) + << ",\"leafValuesOffset\":" << offsetof(Vec3LeafData, mValues) + << "},\n" + << " \"grids\":[\n"; + for (size_t index = 0; index < grids.size(); ++index) { + const GridReport &grid = grids[index]; + output << " {\"name\":" << json_string(grid.name) + << ",\"sourceType\":" << json_string(grid.source_type) + << ",\"valueType\":" << json_string(grid.value_type) + << ",\"gridClass\":" << json_string(grid.grid_class) + << ",\"activeVoxelCount\":" << grid.active_voxels + << ",\"segmentByteOffset\":" << grid.segment_offset + << ",\"segmentByteLength\":" << grid.segment_length + << ",\"byteOffset\":" << grid.grid_offset + << ",\"byteLength\":" << grid.grid_length + << ",\"indexBounds\":{\"min\":"; + write_coord(output, grid.index_bounds.min()); + output << ",\"max\":"; + write_coord(output, grid.index_bounds.max()); + output << "},\"worldBounds\":{\"min\":"; + write_vec3(output, grid.world_bounds.min()); + output << ",\"max\":"; + write_vec3(output, grid.world_bounds.max()); + output << "},\"voxelSize\":"; + write_vec3(output, grid.voxel_size); + output << ",\"indexToWorld\":["; + for (size_t matrix_index = 0; matrix_index < grid.index_to_world.size(); ++matrix_index) { + if (matrix_index) output << ','; + output << grid.index_to_world[matrix_index]; + } + output << ']'; + if (!grid.scalar_samples.empty()) { + output << ",\"scalarSamples\":["; + for (size_t sample_index = 0; sample_index < grid.scalar_samples.size(); ++sample_index) { + const auto &sample = grid.scalar_samples[sample_index]; + if (sample_index) output << ','; + output << "{\"coord\":"; + write_coord(output, sample.coord); + output << ",\"value\":" << sample.value << ",\"active\":" << (sample.active ? "true" : "false") << '}'; + } + output << ']'; + } + if (!grid.vector_samples.empty()) { + output << ",\"vectorSamples\":["; + for (size_t sample_index = 0; sample_index < grid.vector_samples.size(); ++sample_index) { + const auto &sample = grid.vector_samples[sample_index]; + if (sample_index) output << ','; + output << "{\"coord\":"; + write_coord(output, sample.coord); + output << ",\"value\":[" << sample.value[0] << ',' << sample.value[1] << ',' << sample.value[2] + << "],\"active\":" << (sample.active ? "true" : "false") << '}'; + } + output << ']'; + } + output << '}' << (index + 1 == grids.size() ? "\n" : ",\n"); + } + output << " ]\n}\n"; + if (!output) throw std::runtime_error("failed to write conversion report"); +} + +int main(int argc, char **argv) +{ + fs::path staged_output; + fs::path staged_report; + try { + const Options options = parse_options(argc, argv); + const auto started = std::chrono::steady_clock::now(); + std::signal(SIGINT, request_interrupt); + std::signal(SIGTERM, request_interrupt); + check_interrupted(options, started, "startup"); + if (!fs::is_regular_file(options.input)) throw std::runtime_error("input VDB does not exist"); + const uint64_t source_size = fs::file_size(options.input); + if (source_size == 0 || source_size > MAX_SOURCE_BYTES) throw std::runtime_error("input VDB exceeds the source byte budget"); + fs::create_directories(fs::absolute(options.output).parent_path()); + fs::create_directories(fs::absolute(options.report).parent_path()); + const std::string stage_suffix = "." + std::to_string(static_cast(getpid())) + ".stage"; + staged_output = options.output.string() + stage_suffix; + staged_report = options.report.string() + stage_suffix; + fs::remove(staged_output); + fs::remove(staged_report); + + openvdb::initialize(); + openvdb::io::File input(options.input.string()); + input.open(false); + check_interrupted(options, started, "OpenVDB inventory"); + openvdb::GridPtrVecPtr source_grids = input.getGrids(); + if (!source_grids || source_grids->empty() || source_grids->size() > MAX_GRIDS) throw std::runtime_error("VDB grid count exceeds the budget"); + + const std::set selected(options.grids.begin(), options.grids.end()); + std::set found; + uint64_t total_active_voxels = 0; + std::ofstream output(staged_output, std::ios::binary | std::ios::trunc); + if (!output) throw std::runtime_error("failed to create NanoVDB output"); + std::vector report; + for (const openvdb::GridBase::Ptr &grid : *source_grids) { + check_interrupted(options, started, "grid inventory"); + if (!selected.empty() && !selected.count(grid->getName())) continue; + if (!found.insert(grid->getName()).second) throw std::runtime_error("duplicate source grid name: " + grid->getName()); + total_active_voxels += grid->activeVoxelCount(); + if (total_active_voxels > MAX_ACTIVE_VOXELS) throw std::runtime_error("active voxel budget exceeded"); + + auto handle = convert_grid(grid, options.quantization); + check_interrupted(options, started, "NanoVDB conversion"); + const uint64_t segment_offset = static_cast(output.tellp()); + nanovdb::io::writeGrid(output, handle, nanovdb::io::Codec::NONE); + const uint64_t segment_end = static_cast(output.tellp()); + const uint64_t name_size = grid->getName().size() + 1; + const uint64_t grid_offset = segment_offset + sizeof(nanovdb::io::FileHeader) + sizeof(nanovdb::io::FileMetaData) + name_size; + if (grid_offset + handle.gridSize() != segment_end) throw std::runtime_error("unexpected NanoVDB segment layout"); + + GridReport item; + item.name = grid->getName(); + item.source_type = grid->valueType(); + item.value_type = options.quantization == "FP16" ? "FLOAT16" : grid->isType() ? "FLOAT32" : "VEC3F32"; + item.grid_class = grid_class_name(grid->getGridClass()); + item.active_voxels = grid->activeVoxelCount(); + item.segment_offset = segment_offset; + item.segment_length = segment_end - segment_offset; + item.grid_offset = grid_offset; + item.grid_length = handle.gridSize(); + item.index_bounds = grid->evalActiveVoxelBoundingBox(); + item.world_bounds = world_bounds(*grid, item.index_bounds); + item.voxel_size = grid->voxelSize(); + item.index_to_world = index_to_world(*grid); + if (options.quantization == "LOSSLESS" && grid->isType()) { + const nanovdb::NanoGrid *nano_grid = handle.grid(); + if (!nano_grid) throw std::runtime_error("NanoVDB Float32 grid payload is unavailable"); + const std::array sample_coords = { + item.index_bounds.min(), openvdb::Coord(0, 0, 0), item.index_bounds.max(), + openvdb::Coord(item.index_bounds.min().x() - 1, 0, 0), + openvdb::Coord(item.index_bounds.max().x() + 1, 0, 0)}; + for (const openvdb::Coord &coord : sample_coords) { + float value = 0.0f; + const bool active = nano_grid->tree().probeValue(nanovdb::Coord(coord.x(), coord.y(), coord.z()), value); + item.scalar_samples.push_back({coord, value, active}); + } + } + else if (options.quantization == "LOSSLESS" && grid->isType()) { + const nanovdb::NanoGrid *nano_grid = handle.grid(); + if (!nano_grid) throw std::runtime_error("NanoVDB Vec3f grid payload is unavailable"); + const std::array sample_coords = { + item.index_bounds.min(), openvdb::Coord(0, 0, 0), item.index_bounds.max(), + openvdb::Coord(item.index_bounds.min().x() - 1, 0, 0), + openvdb::Coord(item.index_bounds.max().x() + 1, 0, 0)}; + for (const openvdb::Coord &coord : sample_coords) { + nanovdb::Vec3f value(0.0f); + const bool active = nano_grid->tree().probeValue(nanovdb::Coord(coord.x(), coord.y(), coord.z()), value); + item.vector_samples.push_back({coord, {value[0], value[1], value[2]}, active}); + } + } + report.push_back(std::move(item)); + + if (segment_end > MAX_OUTPUT_BYTES) throw std::runtime_error("NanoVDB output exceeds the byte budget"); + } + input.close(); + output.close(); + check_interrupted(options, started, "artifact commit"); + if (!selected.empty() && found != selected) throw std::runtime_error("one or more selected grids were not found"); + if (report.empty()) throw std::runtime_error("no supported grids were selected"); + write_report(options, staged_report, report); + check_interrupted(options, started, "report commit"); + fs::rename(staged_output, options.output); + fs::rename(staged_report, options.report); + openvdb::uninitialize(); + std::cout << "vdb-to-nanovdb-ok input=" << options.input.string() + << " output=" << options.output.string() + << " grids=" << report.size() + << " bytes=" << fs::file_size(options.output) << '\n'; + return 0; + } + catch (const std::exception &error) { + if (!staged_output.empty()) fs::remove(staged_output); + if (!staged_report.empty()) fs::remove(staged_report); + std::cerr << "VDB_CONVERSION_FAILED: " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/web/check-compositor-main-reader.mjs b/tools/web/check-compositor-main-reader.mjs index 48c62b8a..9b658386 100644 --- a/tools/web/check-compositor-main-reader.mjs +++ b/tools/web/check-compositor-main-reader.mjs @@ -39,19 +39,27 @@ const scene = snapshot(engine, handle).scenes.find((candidate) => candidate.name assert.equal(scene?.compositorStatus, "AVAILABLE"); const graph = scene.compositorGraph; assert.equal(graph.schemaVersion, 1); -assert.equal(graph.nodes.length, 4); -assert.equal(graph.links.length, 2); +assert.equal(graph.nodes.length, 6); +assert.equal(graph.links.length, 4); const color = graph.nodes.find((node) => node.name === "WebConstantColor"); assert.equal(color.type, "CONSTANT_COLOR"); assert.deepEqual(color.properties.color, [0.125, 0.25, 0.5, 0.75]); +const exposure = graph.nodes.find((node) => node.name === "WebExposure"); +assert.equal(exposure.type, "EXPOSURE"); +assert.equal(exposure.properties.exposure, 1); +const invert = graph.nodes.find((node) => node.name === "WebInvert"); +assert.equal(invert.type, "INVERT"); +assert.deepEqual(invert.properties, {}); +assert.ok(graph.links.some((link) => link.toNodeId === invert.id && link.toSocket === "Image")); assert.equal(graph.nodes.find((node) => node.name === "WebViewer").type, "VIEWER"); const composite = graph.nodes.find((node) => node.name === "WebComposite"); assert.equal(composite.type, "COMPOSITE"); assert.equal(graph.outputNodeId, composite.id); +assert.ok(graph.links.some((link) => link.toNodeId === composite.id && link.toSocket === "Image")); const unsupported = graph.nodes.find((node) => node.name === "PreservedUnsupportedGlare"); assert.equal(unsupported.type, "UNSUPPORTED"); assert.equal(unsupported.blenderType, "CompositorNodeGlare"); assert.ok(graph.links.every((link) => graph.nodes.some((node) => node.id === link.fromNodeId) && graph.nodes.some((node) => node.id === link.toNodeId))); engine._web_engine_destroy(handle); -process.stdout.write("compositor-main-reader-ok graph-structure=passed socket-links=passed unsupported-preserved=passed\n"); +process.stdout.write("compositor-main-reader-ok graph-structure=passed exposure-invert-parameters=passed socket-links=passed unsupported-preserved=passed\n"); diff --git a/tools/web/check-mask-main-reader.mjs b/tools/web/check-mask-main-reader.mjs index ee5ffac3..a008148d 100644 --- a/tools/web/check-mask-main-reader.mjs +++ b/tools/web/check-mask-main-reader.mjs @@ -41,8 +41,8 @@ assert.equal(project.bindings.length, 0); assert.equal(project.masks.length, 1); const mask = project.masks[0]; assert.equal(mask.id, "mask:WebMask"); -assert.equal(mask.layers.length, 1); -const layer = mask.layers[0]; +assert.equal(mask.layers.length, 2); +const layer = mask.layers.find((candidate) => candidate.name === "WebMaskLayer"); assert.equal(layer.name, "WebMaskLayer"); assert.equal(layer.locked, true); close(layer.opacity, 0.625, "layer opacity"); @@ -60,5 +60,10 @@ for (const [label, actual, expected] of [ } close(spline.points[2].feather, 0.75, "point feather"); assert.deepEqual(spline.points.map((point) => point.selected), [true, false, true]); +const editable = mask.layers.find((candidate) => candidate.name === "WebEditableLayer"); +assert.equal(editable.locked, false); +assert.equal(editable.visible, true); +assert.equal(editable.splines[0].points.length, 2); +assert.deepEqual(editable.splines[0].points.map((point) => point.handleType), ["FREE", "FREE"]); engine._web_engine_destroy(handle); -process.stdout.write("mask-main-reader-ok layers-splines=passed handles-feather=passed selection=passed\n"); +process.stdout.write("mask-main-reader-ok layers-splines=passed handles-feather=passed locked-editable-selection=passed\n"); diff --git a/tools/web/check-nonmesh-roundtrip.mjs b/tools/web/check-nonmesh-roundtrip.mjs index e60c7f29..556fe3c8 100644 --- a/tools/web/check-nonmesh-roundtrip.mjs +++ b/tools/web/check-nonmesh-roundtrip.mjs @@ -79,11 +79,13 @@ const curve = before.nonMeshData.find((data) => data.type === "CURVE"); const surface = before.nonMeshData.find((data) => data.type === "SURFACE"); const font = before.nonMeshData.find((data) => data.type === "FONT"); const metaball = before.nonMeshData.find((data) => data.type === "METABALL"); +const volume = before.nonMeshData.find((data) => data.type === "VOLUME"); assert.ok(curve?.controlPoints?.length && curve.splineOffsets?.length); assert.ok(surface?.controlPoints?.length && surface.splineOffsets?.length); assert.deepEqual(surface.splineDimensions, [{ u: 4, v: 4, orderU: 4, orderV: 4 }]); assert.equal(surface.pointWeights?.length, 16); assert.ok(font?.text && metaball?.elements?.length); +assert.ok(volume?.sourcePath && volume?.volumeProperties); assert.equal(before.vfonts?.length, 2); assert.ok(before.vfonts.every((resource) => resource.packed && resource.id.startsWith("vfont:"))); assert.ok(font.fontLinks); @@ -93,6 +95,27 @@ assert.equal(curve.handleTypes?.length, 6); assert.equal(curve.handlePoints?.length, 18); assert.deepEqual(curve.handlePointIndices, [4, 5, 6]); +const volumeProperties = { + displayDensity: 1.75, + interpolation: "NEAREST", + stepSize: 0.125, + velocityGrid: "velocity", + velocityScale: 1.5, +}; +const volumeSourcePath = "//assets/volumes/generated-smoke.vdb"; +reject(engine, handle, { type: "setVolumeProperties", dataId: volume.id, ...volumeProperties, sourcePath: "../outside.vdb" }, "NON_MESH_PROPERTY_INVALID"); +command(engine, handle, { type: "setVolumeProperties", dataId: volume.id, ...volumeProperties, sourcePath: volumeSourcePath }); +let changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id); +assert.equal(changedVolume.sourcePath, volumeSourcePath); +assert.deepEqual(changedVolume.volumeProperties, volumeProperties); +assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); +changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id); +assert.equal(changedVolume.sourcePath, volume.sourcePath); +assert.deepEqual(changedVolume.volumeProperties, volume.volumeProperties); +assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message())); +changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id); +assert.deepEqual(changedVolume.volumeProperties, volumeProperties); + command(engine, handle, { type: "renameId", id: curve.id, name: "WebCurveRenamed" }); const renamedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.type === "CURVE" && data.name === "WebCurveRenamed"); assert.equal(renamedCurve?.id, "curve:WebCurveRenamed"); @@ -284,6 +307,9 @@ engine._web_engine_destroy(handle); const reopened = engine._web_engine_create(); open(engine, reopened, saved); const roundtripped = snapshot(engine, reopened); +const reopenedVolume = roundtripped.nonMeshData.find((data) => data.id === volume.id); +assert.equal(reopenedVolume.sourcePath, volumeSourcePath); +assert.deepEqual(reopenedVolume.volumeProperties, volumeProperties); close(roundtripped.nonMeshData.find((data) => data.id === curveId).controlPoints[0], curvePoints[0], "reopened curve point"); assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === surface.id).splineDimensions, surfaceDimensions); assert.equal(roundtripped.nonMeshData.find((data) => data.id === surface.id).pointCount, 20); @@ -310,4 +336,4 @@ close(createdAfterReopen.handlePoints[1], twoSplineHandlePoints[1], "reopened bu command(engine, reopened, { type: "deleteNonMeshData", dataId: createdAfterReopen.id }); assert.equal(snapshot(engine, reopened).nonMeshData.some((data) => data.id === createdAfterReopen.id), false); engine._web_engine_destroy(reopened); -console.log("nonmesh-roundtrip-ok types=CURVE,SURFACE,FONT,METABALL multispline-create-delete-bulk-handle-cyclic-surface-2d-topology-font-links=passed undo-redo=passed save-reopen=passed"); +console.log("nonmesh-roundtrip-ok types=CURVE,SURFACE,FONT,METABALL,VOLUME multispline-create-delete-bulk-handle-cyclic-surface-2d-topology-font-links-volume-properties=passed undo-redo=passed save-reopen=passed"); diff --git a/tools/web/check-paint-roundtrip.mjs b/tools/web/check-paint-roundtrip.mjs index 943728a4..11eff60b 100644 --- a/tools/web/check-paint-roundtrip.mjs +++ b/tools/web/check-paint-roundtrip.mjs @@ -84,6 +84,11 @@ function vertexWeight(mesh, vertex, groupName) { return 0; } +function assertVertexGroup(mesh, groupName) { + assert.ok(mesh.vertexGroups?.some((group) => group.name === groupName), `${groupName} missing from vertexGroups`); + assert.ok(mesh.skinWeights?.boneNames.includes(groupName), `${groupName} missing from skinWeights.boneNames`); +} + const engine = await factory({ wasmBinary: wasmBinary.slice() }); const colorHandle = engine._web_engine_create(); @@ -126,6 +131,7 @@ apply(engine, weightHandle, { normalize: false, }); let weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId); +assertVertexGroup(weightedMesh, "WebPaintGroup"); close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "vertex 0 paint weight"); close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.25, "vertex 1 paint weight"); apply(engine, weightHandle, { @@ -159,6 +165,7 @@ engine._web_engine_destroy(weightHandle); 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, 2, "WebPaintGroup"), 0.5 / 1.75, "reopened normalized vertex 2 paint weight"); engine._web_engine_destroy(reopenedWeights); diff --git a/tools/web/check-release-evidence.mjs b/tools/web/check-release-evidence.mjs index 12776a5e..2b0aa736 100644 --- a/tools/web/check-release-evidence.mjs +++ b/tools/web/check-release-evidence.mjs @@ -25,10 +25,19 @@ try { const evaluation = module.evaluateReleaseManifest(parsed); assert.equal(evaluation.status, "BLOCKED"); assert.ok(evaluation.missing.includes("performance.geometry10M")); - assert.ok(evaluation.missing.includes("faults.deviceLoss")); + assert.equal(evaluation.missing.includes("faults.deviceLoss"), false); + assert.equal(evaluation.missing.includes("performance.simulationCache"), false); + assert.equal(evaluation.missing.includes("faults.networkInterrupt"), false); + assert.equal(evaluation.missing.includes("performance.texture4K"), false); + assert.equal(evaluation.missing.includes("performance.texture8K"), false); assert.equal(evaluation.missing.includes("browser.chromium"), false); assert.ok(parsed.evidence.records.length >= 7); assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.zipBomb")), true); + assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.simulationCache")), true); + assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.networkInterrupt")), true); + assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.deviceLoss")), true); + assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.texture4K")), true); + assert.equal(parsed.evidence.records.some((record) => record.fields.includes("performance.texture8K")), true); process.stdout.write(`release-evidence-check-ok records=${parsed.evidence.records.length} missing=${evaluation.missing.length} status=${evaluation.status}\n`); } finally { diff --git a/tools/web/check-scripting-isolation.mjs b/tools/web/check-scripting-isolation.mjs index 4a0d54ae..f96b2fe4 100644 --- a/tools/web/check-scripting-isolation.mjs +++ b/tools/web/check-scripting-isolation.mjs @@ -23,7 +23,7 @@ try { fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); } - const { gateScriptExecution, gateServerScriptJob } = require(path.join(temporary, "scripting-platform.cjs")); + const { createScriptExecutionAudit, gateScriptExecution, gateServerScriptJob } = require(path.join(temporary, "scripting-platform.cjs")); const digest = "a".repeat(64); const manifest = { schemaVersion: 1, @@ -56,7 +56,13 @@ try { assert.equal(approved.issues[0]?.code, "SCRIPT_SANDBOX_UNAVAILABLE"); assert.equal(server.status, "BLOCKED"); assert.equal(server.issues[0]?.code, "SERVER_JOB_UNAVAILABLE"); - process.stdout.write("scripting-isolation-ok status=BLOCKED approved-key=sandbox-unavailable server=unavailable execution=disabled\n"); + const audit = await createScriptExecutionAudit(manifest, "clean", new Set(["approved-key"]), { requestId: "isolation-check", requestedAt: "2026-08-12T12:00:00.000Z" }); + assert.equal(audit.decision, "DENY"); + assert.equal(audit.reason, "SCRIPT_SANDBOX_UNAVAILABLE"); + assert.equal(audit.approvedKey, true); + assert.match(audit.manifestSha256, /^[a-f0-9]{64}$/); + assert.match(audit.requestSha256, /^[a-f0-9]{64}$/); + process.stdout.write("scripting-isolation-ok status=BLOCKED audit=DENY approved-key=sandbox-unavailable server=unavailable execution=disabled\n"); } finally { fs.rmSync(temporary, { recursive: true, force: true }); diff --git a/tools/web/check-sequencer-main-reader.mjs b/tools/web/check-sequencer-main-reader.mjs index 0c693271..b4e39030 100644 --- a/tools/web/check-sequencer-main-reader.mjs +++ b/tools/web/check-sequencer-main-reader.mjs @@ -45,10 +45,11 @@ assert.equal(sound.locked, true); const image = timeline.strips.find((strip) => strip.name === "WebImage"); assert.equal(image.type, "IMAGE"); assert.equal(image.sourcePath, "//media/sequencer-frame.png"); +assert.deepEqual([image.sourceStart, image.sourceEnd], [0, 1]); const cross = timeline.strips.find((strip) => strip.name === "WebCross"); assert.equal(cross.type, "EFFECT"); assert.equal(cross.effectType, "CROSS"); assert.equal(cross.inputStripIds.length, 2); assert.ok(cross.inputStripIds.every((id) => timeline.strips.some((strip) => strip.id === id))); engine._web_engine_destroy(handle); -process.stdout.write("sequencer-main-reader-ok media-paths=passed fps=passed effect-dependencies=passed\n"); +process.stdout.write("sequencer-main-reader-ok media-paths=passed still-image-range=passed fps=passed effect-dependencies=passed\n"); diff --git a/tools/web/check-vdb-availability.mjs b/tools/web/check-vdb-availability.mjs index 4283f8ae..b8541ee8 100644 --- a/tools/web/check-vdb-availability.mjs +++ b/tools/web/check-vdb-availability.mjs @@ -5,8 +5,13 @@ import path from "node:path"; const root = path.resolve(new URL("../..", import.meta.url).pathname); const cachePath = path.join(root, "build_web_blender6", "CMakeCache.txt"); const cache = fs.readFileSync(cachePath, "utf8"); -assert.match(cache, /^WITH_OPENVDB:BOOL=OFF$/m, "OpenVDB must remain disabled until a WASM decoder is linked"); -assert.match(cache, /^WITH_NANOVDB:BOOL=ON$/m, "NanoVDB metadata support is expected"); +assert.match(cache, /^WITH_OPENVDB:BOOL=OFF$/m, "Browser OpenVDB must remain disabled; conversion belongs to desktop/server targets"); + +const protocol = fs.readFileSync(path.join(root, "web", "protocol", "volume-vdb.ts"), "utf8"); +assert.match(protocol, /prepareVDBConversionInput/, "VDB conversion input validation is missing"); +assert.match(protocol, /validateNanoVDBBundleManifest/, "NanoVDB bundle validation is missing"); +assert.match(protocol, /gateNanoVDBPipeline/, "NanoVDB stage capability gates are missing"); +assert.doesNotMatch(protocol, /export async function decodeVDBResource/, "Browser raw OpenVDB decode entry must not be restored"); const roots = [path.join(root, "resource-library"), path.join(root, "tests"), "/home/mes123456/resource-library"]; const vdbFiles = []; @@ -15,9 +20,22 @@ function scan(directory) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) scan(fullPath); - else if (/\.vdb(?:\.gz)?$/i.test(entry.name)) vdbFiles.push(fullPath); + else if (/\.(?:vdb(?:\.gz)?|nvdb)$/i.test(entry.name)) vdbFiles.push(fullPath); } } for (const directory of roots) scan(directory); -assert.equal(vdbFiles.length, 0, `unexpected local VDB resource(s): ${vdbFiles.join(", ")}`); -process.stdout.write("vdb-availability-ok status=BLOCKED openvdb=disabled nanovdb=metadata-only local-vdb=0 renderer=blocked decoder=blocked\n"); +const converterCandidates = [ + path.join(root, "build_vdb_tools", "vdb_to_nanovdb"), + path.join(root, "tools", "vdb", "convert-openvdb-to-nanovdb"), + path.join(root, "tools", "vdb", "convert-openvdb-to-nanovdb.py"), +]; +const converterConfigured = converterCandidates.some((candidate) => fs.existsSync(candidate)); +const rendererConfigured = fs.existsSync(path.join(root, "web", "app", "src", "render", "nanovdb-volume-renderer.ts")); +const serverConfigured = fs.existsSync(path.join(root, "tools", "vdb", "server", "vdb-job-service.mjs")); +const opfsConfigured = fs.existsSync(path.join(root, "web", "app", "src", "volume", "nanovdb-opfs.ts")); +const mainWriterConfigured = fs.readFileSync(path.join(root, "blender-5.2.0", "source", "blender", "web_engine", "web_engine_api.cpp"), "utf8").includes("setVolumeProperties"); +const coreStatus = converterConfigured && serverConfigured && opfsConfigured && rendererConfigured && mainWriterConfigured && vdbFiles.some((file) => /\.vdb(?:\.gz)?$/i.test(file)) ? "READY" : "BLOCKED"; +const releaseStatus = "BLOCKED"; +assert.equal(coreStatus, "READY", "VDB core pipeline files or real resources are missing"); +assert.equal(releaseStatus, "BLOCKED", "VDB release must remain blocked until viewport integration, advanced grids, and full goldens are present"); +process.stdout.write(`vdb-availability-ok core=${coreStatus} release=${releaseStatus} browser-openvdb=disabled desktop=ready server=ready opfs=ready webgpu-core=ready main-roundtrip=ready viewport=blocked advanced-material=blocked resources=${vdbFiles.length}\n`); diff --git a/tools/web/check-vdb-native-pipeline.mjs b/tools/web/check-vdb-native-pipeline.mjs new file mode 100644 index 00000000..663deaf8 --- /dev/null +++ b/tools/web/check-vdb-native-pipeline.mjs @@ -0,0 +1,108 @@ +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 { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +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 resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb"); +const converter = path.join(root, "build_vdb_tools", "vdb_to_nanovdb"); +const generator = path.join(root, "build_vdb_tools", "vdb_fixture_generator"); +const source = path.join(resourceRoot, "generated", "generated-smoke.vdb"); +const storedBundle = path.join(resourceRoot, "nanovdb", "generated-smoke.nvdb"); +const officialSource = path.join(resourceRoot, "official", "sphere.vdb"); +const officialBundle = path.join(resourceRoot, "nanovdb", "official-sphere.nvdb"); +const browserManifest = path.join(resourceRoot, "manifests", "generated-smoke.nanovdb.json"); +const catalog = JSON.parse(fs.readFileSync(path.join(resourceRoot, "manifest.json"), "utf8")); +const evidence = JSON.parse(fs.readFileSync(path.join(root, "docs", "status", "vdb-native-evidence.json"), "utf8")); +const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +assert.match(fs.readFileSync(path.join(root, "build_web_blender6", "CMakeCache.txt"), "utf8"), /^WITH_OPENVDB:BOOL=OFF$/m); +assert.match(fs.readFileSync(path.join(root, "build_blender_5.2.0", "CMakeCache.txt"), "utf8"), /^WITH_OPENVDB:BOOL=ON$/m); +assert.equal(catalog.schemaVersion, 1); +assert.equal(catalog.toolchain.openVDBVersion, "13.0.0"); +assert.equal(catalog.toolchain.converterSha256, sha256(converter)); +assert.equal(evidence.schemaVersion, 1); +assert.equal(evidence.browserOpenVDB, false); +assert.equal(evidence.desktopOpenVDB, true); +assert.equal(evidence.serverJobConfigured, true); +assert.equal(evidence.opfsStreamingConfigured, true); +assert.equal(evidence.webgpuRendererConfigured, true); +assert.equal(evidence.mainVolumeRoundtripConfigured, true); +assert.equal(evidence.primaryViewportVolumeIntegrated, false); +assert.equal(evidence.releaseStatus, "BLOCKED"); +assert.deepEqual(evidence.toolchain, catalog.toolchain); +assert.deepEqual(evidence.resources, catalog.entries); +for (const entry of catalog.entries) { + const file = path.join(resourceRoot, entry.path); + assert.equal(fs.statSync(file).size, entry.byteLength, `${entry.id} byte length changed`); + assert.equal(sha256(file), entry.sha256, `${entry.id} SHA-256 changed`); +} +assert.equal(catalog.entries.find((entry) => entry.id === "official-sphere")?.sha256, "bb884a9a38354e47191c4ece4a11d1e051594a910fde56501cfc51ff52b171ab"); + +const ldd = spawnSync("ldd", [converter], { encoding: "utf8" }); +assert.equal(ldd.status, 0); +assert.match(ldd.stdout, /libopenvdb\.so\.13/); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "vdb-native-")); +try { + const output = path.join(temporary, "smoke.nvdb"); + const report = path.join(temporary, "smoke.json"); + const conversion = spawnSync(converter, ["--input", source, "--output", output, "--report", report, "--grid", "density", "--grid", "temperature", "--grid", "color", "--grid", "velocity", "--quantization", "LOSSLESS"], { encoding: "utf8" }); + assert.equal(conversion.status, 0, conversion.stderr); + assert.equal(sha256(output), sha256(storedBundle), "OpenVDB to NanoVDB conversion is not deterministic"); + const parsedReport = JSON.parse(fs.readFileSync(report, "utf8")); + assert.equal(parsedReport.openVDBVersion, "13.0.0"); + assert.equal(parsedReport.nanoVDBVersion, "32.9.0"); + assert.deepEqual(parsedReport.grids.map((grid) => grid.name), ["color", "density", "temperature", "velocity"]); + assert.ok(parsedReport.grids.every((grid) => grid.byteOffset >= grid.segmentByteOffset && grid.byteOffset + grid.byteLength <= grid.segmentByteOffset + grid.segmentByteLength)); + + const generatedA = path.join(temporary, "generated-a"); + const generatedB = path.join(temporary, "generated-b"); + fs.mkdirSync(generatedA); + fs.mkdirSync(generatedB); + assert.equal(spawnSync(generator, [generatedA], { encoding: "utf8" }).status, 0); + assert.equal(spawnSync(generator, [generatedB], { encoding: "utf8" }).status, 0); + const semanticOutputs = []; + for (const [index, generated] of [generatedA, generatedB].entries()) { + const semanticOutput = path.join(temporary, `semantic-${index}.nvdb`); + const semanticReport = path.join(temporary, `semantic-${index}.json`); + const result = spawnSync(converter, ["--input", path.join(generated, "generated-smoke.vdb"), "--output", semanticOutput, "--report", semanticReport, "--grid", "density", "--grid", "temperature", "--grid", "color", "--grid", "velocity", "--quantization", "LOSSLESS"], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + semanticOutputs.push(semanticOutput); + } + assert.equal(sha256(semanticOutputs[0]), sha256(semanticOutputs[1]), "Semantically identical generated VDB grids produce different NanoVDB bundles"); + assert.equal(sha256(semanticOutputs[0]), sha256(storedBundle)); + + const officialOutput = path.join(temporary, "official-sphere.nvdb"); + const officialReport = path.join(temporary, "official-sphere.json"); + const officialConversion = spawnSync(converter, ["--input", officialSource, "--output", officialOutput, "--report", officialReport, "--quantization", "LOSSLESS"], { encoding: "utf8" }); + assert.equal(officialConversion.status, 0, officialConversion.stderr); + assert.equal(sha256(officialOutput), sha256(officialBundle), "Official sphere conversion is not deterministic"); + const parsedOfficial = JSON.parse(fs.readFileSync(officialReport, "utf8")); + assert.deepEqual(parsedOfficial.grids.map((grid) => [grid.name, grid.gridClass]), [["ls_sphere", "LEVEL_SET"]]); + + const malformed = spawnSync(converter, ["--input", path.join(resourceRoot, "generated", "generated-smoke-truncated.vdb"), "--output", path.join(temporary, "bad.nvdb"), "--report", path.join(temporary, "bad.json")], { encoding: "utf8" }); + assert.notEqual(malformed.status, 0); + assert.match(malformed.stderr, /VDB_CONVERSION_FAILED/); + + for (const name of ["asset-path", "capability-gates", "volume-vdb"]) { + const sourceText = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + let code = ts.transpileModule(sourceText, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` }).outputText; + code = code.replaceAll('require("./asset-path")', 'require("./asset-path.cjs")').replaceAll('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), code); + } + const require = createRequire(import.meta.url); + const protocol = require(path.join(temporary, "volume-vdb.cjs")); + const validated = protocol.validateNanoVDBBundleManifest(JSON.parse(fs.readFileSync(browserManifest, "utf8"))); + assert.equal(validated.bundleSha256, sha256(storedBundle)); + assert.deepEqual(validated.grids.map((grid) => grid.semantic), ["COLOR", "DENSITY", "TEMPERATURE", "VELOCITY"]); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} + +process.stdout.write(`vdb-native-pipeline-ok resources=${catalog.entries.length} converter=openvdb13-nanovdb32 conversion-deterministic=1 semantic-deterministic=1 official-deterministic=1 malformed=blocked browser-openvdb=off\n`); diff --git a/tools/web/check-vdb-server-job.mjs b/tools/web/check-vdb-server-job.mjs new file mode 100644 index 00000000..a86dc6a1 --- /dev/null +++ b/tools/web/check-vdb-server-job.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { VDBJobService, createVDBJobHttpServer } from "../vdb/server/vdb-job-service.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const converter = path.join(root, "build_vdb_tools/vdb_to_nanovdb"); +const source = "/home/mes123456/resource-library/blender-web-vdb/generated/generated-smoke.vdb"; +const secret = "vdb-server-test-signing-key-000000000000000000000000"; +assert.ok(fs.existsSync(converter), "native converter is missing"); +assert.ok(fs.existsSync(source), "real OpenVDB fixture is missing"); +assert.equal(spawnSync("/usr/bin/bwrap", ["--version"], { encoding: "utf8" }).status, 0, "bwrap is unavailable"); + +const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "vdb-server-test-")); +const service = new VDBJobService({ converter, root: temporary, secret, timeoutMs: 30_000 }); +const server = createVDBJobHttpServer(service); +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}`; +const sourceBytes = fs.readFileSync(source); +const sourceSha256 = crypto.createHash("sha256").update(sourceBytes).digest("hex"); +const headers = { + "Content-Type": "application/x-openvdb", + "X-VDB-Project-Id": "vdb-server-test", + "X-VDB-Source-Path": "//volumes/generated-smoke.vdb", + "X-VDB-Source-SHA256": sourceSha256, + "X-VDB-Grids": "density", + "X-VDB-Quantization": "LOSSLESS", + "X-VDB-Chunk-Bytes": String(4 * 1024 * 1024), +}; + +async function submit() { + const response = await fetch(`${origin}/v1/vdb/jobs`, { method: "POST", headers, body: sourceBytes, duplex: "half" }); + if (response.status !== 200 && response.status !== 202) throw new Error(await response.text()); + return { status: response.status, body: await response.json() }; +} + +async function waitFor(id, states, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const response = await fetch(`${origin}/v1/vdb/jobs/${id}`); + assert.equal(response.status, 200); + const body = await response.json(); + if (states.includes(body.state)) return body; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`VDB server job ${id} timed out in test`); +} + +try { + const health = await (await fetch(`${origin}/healthz`)).json(); + assert.equal(health.sandbox, true); + assert.match(health.converterSha256, /^[a-f0-9]{64}$/); + + const first = await submit(); + assert.equal(first.status, 202); + assert.equal(first.body.deduplicated, false); + const completed = await waitFor(first.body.id, ["SUCCEEDED", "FAILED"]); + assert.equal(completed.state, "SUCCEEDED", completed.error); + assert.equal(completed.sandbox, "bwrap-unshare-all+readonly-root+prlimit"); + + const manifestResponse = await fetch(`${origin}${completed.artifacts.manifest}`); + const bundleResponse = await fetch(`${origin}${completed.artifacts.bundle}`); + assert.equal(manifestResponse.status, 200); + assert.equal(bundleResponse.status, 200); + const manifestBytes = Buffer.from(await manifestResponse.arrayBuffer()); + const bundleBytes = Buffer.from(await bundleResponse.arrayBuffer()); + const manifest = JSON.parse(manifestBytes.toString("utf8")); + assert.equal(manifest.converter.target, "SERVER"); + assert.equal(manifest.sourceSha256, sourceSha256); + assert.deepEqual(manifest.grids.map((grid) => grid.name), ["density"]); + assert.equal(crypto.createHash("sha256").update(bundleBytes).digest("hex"), manifest.bundleSha256); + const expectedSignature = crypto.createHmac("sha256", secret).update(`${completed.id}:${crypto.createHash("sha256").update(manifestBytes).digest("hex")}:${manifest.bundleSha256}`).digest("hex"); + assert.equal(manifestResponse.headers.get("x-vdb-signature"), expectedSignature); + assert.equal(bundleResponse.headers.get("x-vdb-signature"), expectedSignature); + + const desktopBundle = path.join(temporary, "desktop-density.nvdb"); + const desktopReport = path.join(temporary, "desktop-density.json"); + const desktopConversion = spawnSync(converter, [ + "--input", source, + "--output", desktopBundle, + "--report", desktopReport, + "--grid", "density", + "--quantization", "LOSSLESS", + ], { encoding: "utf8" }); + assert.equal(desktopConversion.status, 0, desktopConversion.stderr); + assert.equal(crypto.createHash("sha256").update(fs.readFileSync(desktopBundle)).digest("hex"), manifest.bundleSha256, + "desktop and isolated server conversion produced different NanoVDB bytes"); + + const repeated = await submit(); + assert.equal(repeated.status, 200); + assert.equal(repeated.body.deduplicated, true); + assert.equal(repeated.body.id, completed.id); + + const cancellationHeaders = { ...headers, "X-VDB-Grids": "color,density,temperature,velocity", "X-VDB-Quantization": "FP16" }; + const cancellationResponse = await fetch(`${origin}/v1/vdb/jobs`, { method: "POST", headers: cancellationHeaders, body: sourceBytes, duplex: "half" }); + assert.equal(cancellationResponse.status, 202); + const cancellation = await cancellationResponse.json(); + const cancelledResponse = await fetch(`${origin}/v1/vdb/jobs/${cancellation.id}`, { method: "DELETE" }); + assert.equal(cancelledResponse.status, 200); + const cancelled = await waitFor(cancellation.id, ["CANCELLED"]); + assert.equal(cancelled.state, "CANCELLED"); + assert.equal(fs.existsSync(path.join(temporary, "jobs", cancellation.id, "bundle.nvdb")), false); + + const cancelFile = path.join(temporary, "native-cancel"); + fs.writeFileSync(cancelFile, "cancel\n"); + const cancelledNative = spawnSync(converter, ["--input", source, "--output", path.join(temporary, "cancelled.nvdb"), "--report", path.join(temporary, "cancelled.json"), "--cancel-file", cancelFile, "--timeout-ms", "30000"], { encoding: "utf8" }); + assert.notEqual(cancelledNative.status, 0); + assert.match(cancelledNative.stderr, /conversion cancelled/); + assert.equal(fs.existsSync(path.join(temporary, "cancelled.nvdb")), false); + + process.stdout.write(`vdb-server-job-ok job=${completed.id} bytes=${bundleBytes.length} idempotent=1 signed=1 isolated=1 cancelled=1 atomic=1 desktop-server-hash=equal\n`); +} +finally { + await new Promise((resolve) => server.close(resolve)); + await fsp.rm(temporary, { recursive: true, force: true }); +} diff --git a/tools/web/collect-release-evidence.mjs b/tools/web/collect-release-evidence.mjs index dc92ae90..e6567f56 100644 --- a/tools/web/collect-release-evidence.mjs +++ b/tools/web/collect-release-evidence.mjs @@ -35,9 +35,15 @@ function run(id, fields, command, artifacts = []) { } run("sbom", ["provenance.license", "provenance.sbom"], "npm --prefix web run release:sbom", ["docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "web/package-lock.json"]); +run("vdb-boundary", [], "npm --prefix web run test:vdb-availability && npm --prefix web run test:vdb-native && npm --prefix web run test:vdb", ["web/protocol/volume-vdb.ts", "web/app/src/volume/nanovdb-stream.ts", "tools/vdb/vdb_to_nanovdb.cc", "docs/status/vdb-native-evidence.json", "docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md"]); run("chromium", ["browser.chromium", "runtime.offline", "runtime.workerRestart", "runtime.opfsRecovery"], "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser", ["web/app/src/vendor/blender/web_engine.wasm"]); run("geometry-1m", ["performance.geometry1M"], "npm --prefix web run test:release-performance", ["web/app/src/vendor/blender/web_engine.wasm"]); +run("simulation-cache", ["performance.simulationCache"], "npm --prefix web run test:simulation-cache-performance", ["web/protocol/physics-cache-playback.ts", "web/protocol/simulation-cache.ts", "web/app/src/workers/storage.worker.ts"]); run("malformed-blend", ["faults.malformedBlend"], "npm --prefix web run test:malicious-blends", ["tests/files/web/basic_scene.blend"]); +run("network-interruption", ["faults.networkInterrupt"], "npm --prefix web run test:network-interruption", ["web/tests/e2e/network-interruption.spec.ts", "web/app/src/vendor/blender/web_engine.wasm"]); +run("device-loss", ["faults.deviceLoss"], "npm --prefix web run test:device-loss", ["web/app/src/three-adapter/viewport.ts", "web/tests/e2e/device-loss.spec.ts"]); +run("texture-4k", ["performance.texture4K"], "npm --prefix web run test:texture-4k-performance", ["web/protocol/render-assets.ts", "web/app/src/three-adapter/texture-assets.ts"]); +run("texture-8k", ["performance.texture8K"], "npm --prefix web run test:texture-8k-performance", ["web/protocol/render-assets.ts", "web/app/src/three-adapter/texture-assets.ts"]); run("zip-bomb", ["faults.zipBomb"], "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library", ["web/protocol/asset-library-io.ts"]); run("release-package", [], "npm --prefix web run test:release-package", ["docs/web/sbom.spdx.json", "web/app/src/vendor/blender/web_engine.wasm"]); run("offline-reproducibility", ["provenance.sourceOffer", "provenance.deterministicPackage"], "npm --prefix web run release:offline", ["release/blender-web-offline.tar.gz", "release/blender-web-corresponding-source.tar.gz", "release/SHA256SUMS.txt"]); @@ -47,6 +53,12 @@ const ledger = JSON.parse(ledgerBytes); const manifest = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), generatedAt: new Date().toISOString(), families: ledger.families, evidence }; assert.ok(manifest.families.some((family) => family.status === "BLOCKED")); assert.equal(manifest.evidence.performance.geometry10M, false); -assert.equal(manifest.evidence.faults.deviceLoss, false); +assert.equal(manifest.evidence.performance.longMedia, false); +assert.equal(manifest.evidence.faults.oom, false); +assert.equal(manifest.evidence.performance.simulationCache, true); +assert.equal(manifest.evidence.performance.texture4K, true); +assert.equal(manifest.evidence.performance.texture8K, true); +assert.equal(manifest.evidence.faults.deviceLoss, true); +assert.equal(manifest.evidence.faults.networkInterrupt, true); fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); process.stdout.write(`release-evidence-ok records=${evidence.records.length} status=BLOCKED sha256=${sha256(outputPath)}\n`); diff --git a/tools/web/generate-compositor-fixture.py b/tools/web/generate-compositor-fixture.py index e67fabc3..06cc3d2b 100644 --- a/tools/web/generate-compositor-fixture.py +++ b/tools/web/generate-compositor-fixture.py @@ -16,6 +16,12 @@ def main(output_path): color.name = "WebConstantColor" color.outputs["Color"].default_value = (0.125, 0.25, 0.5, 0.75) + exposure = tree.nodes.new("CompositorNodeExposure") + exposure.name = "WebExposure" + exposure.inputs["Exposure"].default_value = 1.0 + invert = tree.nodes.new("CompositorNodeInvert") + invert.name = "WebInvert" + viewer = tree.nodes.new("CompositorNodeViewer") viewer.name = "WebViewer" composite = tree.nodes.new("NodeGroupOutput") @@ -23,8 +29,10 @@ def main(output_path): glare = tree.nodes.new("CompositorNodeGlare") glare.name = "PreservedUnsupportedGlare" - tree.links.new(color.outputs["Color"], viewer.inputs["Image"]) - tree.links.new(color.outputs["Color"], composite.inputs["Image"]) + tree.links.new(color.outputs["Color"], exposure.inputs["Image"]) + tree.links.new(exposure.outputs["Image"], invert.inputs["Color"]) + tree.links.new(invert.outputs["Color"], viewer.inputs["Image"]) + tree.links.new(invert.outputs["Color"], composite.inputs["Image"]) path = pathlib.Path(output_path).resolve() path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tools/web/generate-mask-fixture.py b/tools/web/generate-mask-fixture.py index 6e108906..03821d4f 100644 --- a/tools/web/generate-mask-fixture.py +++ b/tools/web/generate-mask-fixture.py @@ -29,6 +29,19 @@ def main(output_path): point.weight = weight point.select = selected + editable_layer = mask.layers.new(name="WebEditableLayer") + editable_spline = editable_layer.splines.new() + editable_spline.points.add(1) + editable_values = [ + ((0.2, 0.2), (0.1, 0.2), (0.35, 0.4)), + ((0.8, 0.2), (0.65, 0.4), (0.9, 0.2)), + ] + for point, (co, left, right) in zip(editable_spline.points, editable_values): + point.co = co + point.handle_left = left + point.handle_right = right + point.handle_type = "FREE" + path = pathlib.Path(output_path).resolve() path.parent.mkdir(parents=True, exist_ok=True) bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False) diff --git a/web/app/public/engine-manifest.json b/web/app/public/engine-manifest.json index 1fbb865e..9313d4ef 100644 --- a/web/app/public/engine-manifest.json +++ b/web/app/public/engine-manifest.json @@ -13,7 +13,7 @@ "id": "web-engine-bootstrap", "fileName": "web_engine.wasm", "url": "/vendor/blender/web_engine.wasm", - "sha256": "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c", + "sha256": "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8", "required": true } ] diff --git a/web/app/public/vendor/blender/web_engine.wasm b/web/app/public/vendor/blender/web_engine.wasm index ef822d47..8ec8efc4 100755 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 80a37244..f8332f2c 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -13,6 +13,7 @@ import { AutosaveScheduler } from "../storage/autosave"; import { ViewportRenderer } from "../three-adapter/viewport"; import { acquireOffscreenViewportRenderer, OffscreenViewportRenderer, releaseOffscreenViewportRenderer, supportsOffscreenViewport, type ViewportBackend } from "../three-adapter/offscreen-viewport"; import type { NonMeshElementKind } from "../three-adapter/nonmesh"; +import type { GreasePencilPointPreview, GreasePencilPointRef } from "../three-adapter/grease-pencil"; import { buildLODCacheKey } from "../three-adapter/lod"; import { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache"; import type { LODCacheRecord } from "../../../protocol/lod"; @@ -20,8 +21,16 @@ import { modifierStackHash } from "../../../protocol/modifier"; import { exportGLB } from "../../../protocol/glb-export"; import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export"; import { normalizeProjectAssetPath } from "../../../protocol/asset-path"; -import { applyCurveGizmoDelta } from "../../../protocol/nonmesh-interaction"; +import { applyCurveGizmoDelta, curveGizmoAxisDelta, deriveCurveHandleGizmoFrame, type CurveGizmoFrameIR, type CurveGizmoHandleIR, type CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction"; import { applyGreasePencilPointTranslation } from "../../../protocol/grease-pencil-editor"; +import { + loadAndCommitNanoVDBViewportAsset, + loadNanoVDBViewportAsset, + reopenNanoVDBViewportAssetFromOPFS, + type NanoVDBViewportAssetIR, + type NanoVDBViewportProjectContextIR, +} from "../volume/nanovdb-viewport"; +import { composePaintColorPatch, composePaintWeightPatch } from "../../../protocol/paint"; import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema"; import "./app-shell.css"; @@ -34,6 +43,11 @@ function errorMessage(error: unknown): string { return String(error); } +async function sha256Hex(data: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); +} + interface AreaProps { className?: string; editor: EditorType; @@ -62,34 +76,143 @@ interface MeshEditSelection { indices: Set; nonMeshKind?: NonMeshElementKind; nonMeshSelections?: Map>; + greasePencilPoints?: GreasePencilPointRef[]; } -function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onTransform }: { +function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, volumeProject, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onGreasePencilPointSelect, onTransform }: { snapshot: SceneSnapshotIR | null; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[]; textureAssets: GPUTextureAsset[]; + volumeProject: NanoVDBViewportProjectContextIR | null; lodLevels: Record | null; selectedObjectIds: ReadonlySet; editMode: boolean; meshSelection: MeshEditSelection; onSelect: (id: string, additive: boolean) => void; onElementSelect: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void; - onTransform: (tool: "translate" | "rotate" | "scale", amount?: number, axis?: 0 | 1 | 2) => void; + onGreasePencilPointSelect: (point: GreasePencilPointRef, additive: boolean) => void; + onTransform: (tool: "translate" | "rotate" | "scale", amount?: number, axis?: 0 | 1 | 2, axisVector?: [number, number, number]) => void; }) { const canvasRef = useRef(null); const rendererRef = useRef(null); + const onSelectRef = useRef(onSelect); + const onElementSelectRef = useRef(onElementSelect); + const onGreasePencilPointSelectRef = useRef(onGreasePencilPointSelect); + onSelectRef.current = onSelect; + onElementSelectRef.current = onElementSelect; + onGreasePencilPointSelectRef.current = onGreasePencilPointSelect; const [viewportError, setViewportError] = useState(null); const [activeTool, setActiveTool] = useState<"translate" | "rotate" | "scale">("translate"); + const [curveGizmoScreenFrame, setCurveGizmoScreenFrame] = useState(null); + const [volumeAssets, setVolumeAssets] = useState([]); + const volumeSources = useMemo(() => (snapshot?.nonMeshData ?? []).flatMap((data) => { + if (data.type !== "VOLUME" || !data.sourcePath?.toLowerCase().endsWith(".vdb")) return []; + try { return [{ dataId: data.id, sourcePath: normalizeProjectAssetPath(data.sourcePath) }]; } + catch { return []; } + }), [snapshot?.nonMeshData]); + + const curveControlPoints = (dataId: string, inline?: ArrayLike): ArrayLike | null => { + if (inline) return inline; + const chunks = nonMeshGeometryBuffers.filter((chunk) => chunk.dataId === dataId).sort((left, right) => left.pointOffset - right.pointOffset); + if (chunks.length === 0) return null; + const points = new Float32Array(chunks[0].totalPointCount * 3); + for (const chunk of chunks) { + if (chunk.pointOffset < 0 || chunk.pointOffset + chunk.pointCount > chunks[0].totalPointCount || chunk.positions.byteLength !== chunk.pointCount * 3 * 4) return null; + points.set(new Float32Array(chunk.positions), chunk.pointOffset * 3); + } + return points; + }; + + const curveGizmo = useMemo<{ dataId: string; handles: CurveGizmoHandleIR[]; frame: CurveGizmoFrameIR } | null>(() => { + if (!snapshot || !editMode || activeTool !== "translate") return null; + const activeNode = snapshot.nodes.find((node) => node.id === snapshot.activeObjectId); + const nonMesh = snapshot.nonMeshData?.find((candidate) => candidate.id === activeNode?.dataId); + if (nonMesh?.type !== "CURVE" || meshSelection.meshId !== nonMesh.id || !nonMesh.handlePoints || !meshSelection.nonMeshSelections) return null; + const controlPoints = curveControlPoints(nonMesh.id, nonMesh.controlPoints); + if (!controlPoints) return null; + const pointIndices = nonMesh.handlePointIndices ?? Array.from({ length: nonMesh.handlePoints.length / 6 }, (_, index) => index); + const handles: CurveGizmoHandleIR[] = []; + for (const [kind, selected] of meshSelection.nonMeshSelections) { + if (kind === "CONTROL_POINT") continue; + const sideOffset = kind === "HANDLE_RIGHT" ? 3 : 0; + for (const pointIndex of selected) { + const packedPointIndex = pointIndices.indexOf(pointIndex); + if (packedPointIndex < 0) continue; + handles.push({ pointIndex, side: kind === "HANDLE_RIGHT" ? "RIGHT" : "LEFT", position: nonMesh.handlePoints.slice(packedPointIndex * 6 + sideOffset, packedPointIndex * 6 + sideOffset + 3) as [number, number, number] }); + } + } + if (handles.length === 0) return null; + try { + return { dataId: nonMesh.id, handles, frame: deriveCurveHandleGizmoFrame(controlPoints, handles) }; + } + catch { + return null; + } + }, [snapshot, editMode, activeTool, meshSelection.meshId, meshSelection.nonMeshSelections, nonMeshGeometryBuffers]); + + const greasePencilGizmo = useMemo(() => { + if (!snapshot || !editMode || activeTool !== "translate" || !meshSelection.greasePencilPoints?.length) return null; + const activeNode = snapshot.nodes.find((node) => node.id === snapshot.activeObjectId); + const data = snapshot.greasePencils?.find((candidate) => candidate.id === activeNode?.dataId); + const first = meshSelection.greasePencilPoints[0]; + if (!data || meshSelection.meshId !== data.id || meshSelection.greasePencilPoints.some((point) => point.dataId !== first.dataId || point.layerId !== first.layerId || point.frame !== first.frame)) return null; + const layer = data.layers.find((candidate) => candidate.id === first.layerId); + const frame = layer?.frames.find((candidate) => candidate.frame === first.frame); + if (!layer || !frame) return null; + return { data, layer, frame, selected: meshSelection.greasePencilPoints }; + }, [snapshot, editMode, activeTool, meshSelection.meshId, meshSelection.greasePencilPoints]); + + const previewCurveHandles = (amount: number, axis: 0 | 1 | 2): string | null => { + if (!snapshot || !curveGizmo) return null; + const axisVector = curveGizmo.frame.axes[axis]; + const delta = curveGizmoAxisDelta(curveGizmo.frame, axis, amount); + try { + const preview = applyCurveGizmoDelta({ schemaVersion: 1, dataId: curveGizmo.dataId, baseRevision: snapshot.revision, phase: "PREVIEW", axis, axisVector, delta, handles: curveGizmo.handles }, snapshot.revision); + rendererRef.current?.setCurveHandlePreview(curveGizmo.dataId, preview.handles); + return curveGizmo.dataId; + } + catch { + return null; + } + }; + + const previewGreasePencilPoints = (amount: number, axis: 0 | 1 | 2): boolean => { + if (!snapshot || !greasePencilGizmo) return false; + const translation: [number, number, number] = [0, 0, 0]; + translation[axis] = amount; + const { data, layer, frame, selected } = greasePencilGizmo; + try { + const result = applyGreasePencilPointTranslation({ + schemaVersion: 1, + revision: snapshot.revision, + dataId: data.id, + layerId: layer.id, + frame: frame.frame, + onionSkinning: layer.onionSkinning ?? false, + selectedStrokeIndices: [...new Set(selected.map((point) => point.strokeIndex))], + selectedPoints: selected.map(({ strokeIndex, pointIndex }) => ({ strokeIndex, pointIndex })), + }, frame.drawing.strokes, { type: "TRANSLATE_POINTS", revision: snapshot.revision, translation }); + const points: GreasePencilPointPreview[] = selected.map((identity) => ({ ...identity, position: result.strokes[identity.strokeIndex].points[identity.pointIndex].position })); + rendererRef.current?.setGreasePencilPointPreview(data.id, layer.id, frame.frame, points); + return true; + } + catch { + return false; + } + }; useEffect(() => { if (!canvasRef.current) return; try { const offscreenRequested = new URLSearchParams(window.location.search).get("offscreen") === "1"; 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 renderer = offscreenRequested && supportsOffscreenViewport(canvas) - ? acquireOffscreenViewportRenderer(canvas, onSelect, onElementSelect) - : new ViewportRenderer(canvas, onSelect, onElementSelect); + ? acquireOffscreenViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint) + : new ViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint); rendererRef.current = renderer; return () => { rendererRef.current = null; @@ -121,14 +244,59 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers const elementSelection = meshSelection.meshId && meshSelection.nonMeshSelections ? new Map([[meshSelection.meshId, meshSelection.nonMeshSelections]]) : undefined; - renderer?.setSelection(selectedObjectIds, elementSelection); + renderer?.setSelection(selectedObjectIds, elementSelection, meshSelection.greasePencilPoints); renderer?.setInteractionMode(editMode, meshSelection.mode); - }, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections]); + }, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections, meshSelection.greasePencilPoints]); useEffect(() => { rendererRef.current?.setTextureAssets(textureAssets); }, [textureAssets]); + useEffect(() => { + const controller = new AbortController(); + if (volumeSources.length === 0) { + setVolumeAssets([]); + return () => controller.abort(); + } + void Promise.all(volumeSources.map(async ({ dataId, sourcePath }) => { + const sourceUrl = `/${sourcePath.slice(2)}`; + const manifestUrl = sourceUrl.replace(/\.vdb$/i, ".nanovdb.json"); + const bundleUrl = sourceUrl.replace(/\.vdb$/i, ".nvdb"); + if (!volumeProject) return loadNanoVDBViewportAsset(dataId, manifestUrl, bundleUrl, controller.signal); + try { return await reopenNanoVDBViewportAssetFromOPFS(dataId, sourcePath, volumeProject, controller.signal); } + catch { + return loadAndCommitNanoVDBViewportAsset(dataId, sourcePath, manifestUrl, bundleUrl, volumeProject, controller.signal); + } + })).then(setVolumeAssets).catch((error) => { + if (!controller.signal.aborted) { + setVolumeAssets([]); + const canvas = canvasRef.current; + if (canvas) { + canvas.dataset.volumeStatus = "blocked"; + canvas.dataset.volumeErrorCode = error instanceof Error ? error.message.split(":", 1)[0] : "NON_MESH_RESOURCE_MISSING"; + } + } + }); + return () => controller.abort(); + }, [volumeSources, volumeProject]); + + useEffect(() => { + rendererRef.current?.setVolumeAssets(volumeAssets); + }, [volumeAssets]); + + useEffect(() => { + rendererRef.current?.setCurveGizmoFrame(curveGizmo?.dataId ?? null, curveGizmo?.frame ?? null); + if (!curveGizmo) setCurveGizmoScreenFrame(null); + }, [curveGizmo]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const update = (event: Event): void => setCurveGizmoScreenFrame((event as CustomEvent).detail); + canvas.addEventListener("curve-gizmo-frame", update); + return () => canvas.removeEventListener("curve-gizmo-frame", update); + }, []); + return (
@@ -143,22 +311,38 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
- {snapshot?.activeObjectId ?
{([0, 1, 2] as const).map((axis) => )}
: null} @@ -190,10 +374,11 @@ function Outliner({ snapshot, onSelect, onToggleVisibility }: { ); } -function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: { +function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, greasePencilPointSelection, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: { snapshot: SceneSnapshotIR | null; selectedFaceIndices: number[]; selectedVertexIndices: number[]; + greasePencilPointSelection: readonly GreasePencilPointRef[]; onCommand: (command: WebEngineEditCommand) => void; onImportImage: (file: File) => void; onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void; @@ -225,6 +410,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo const [renameValue, setRenameValue] = useState(""); const [paintColor, setPaintColor] = useState("#cc6633"); const [paintWeight, setPaintWeight] = useState(1); + const [paintSelectionMask, setPaintSelectionMask] = useState(0.5); const [paintGroup, setPaintGroup] = useState("WebPaint"); const [greasePencilLayerId, setGreasePencilLayerId] = useState(""); const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer"); @@ -234,6 +420,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId); const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : 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]); useEffect(() => { if (!activeMaterial) return; @@ -252,11 +439,46 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo if (!activeGreasePencil) setGreasePencilLayerId(""); else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? ""); }, [activeGreasePencil, greasePencilLayerId]); - const activeGreasePencilFrame = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.find((entry) => entry.frame === (snapshot?.frame.current ?? 1)); + useEffect(() => { + if (!selectedGreasePencilPoint) return; + setGreasePencilLayerId(selectedGreasePencilPoint.layerId); + 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 activeGreasePencilStroke = activeGreasePencilFrame?.drawing.strokes[greasePencilStrokeIndex]; const activeGreasePencilPoint = activeGreasePencilStroke?.points?.[greasePencilPointIndex]; + const selectedPaintBrushWeights = selectedVertexIndices.map((index) => ({ index, weight: paintSelectionMask })); + const currentPointColors = activeMesh && activeMesh.colors?.length === activeMesh.vertexCount * 4 + ? activeMesh.colors + : Array.from({ length: (activeMesh?.vertexCount ?? 0) * 4 }, (_, index) => index % 4 === 3 ? 1 : 0); + const currentGroupWeights = Array.from({ length: activeMesh?.vertexCount ?? 0 }, (_, vertex) => { + const skin = activeMesh?.skinWeights; + const group = skin?.boneNames.indexOf(paintGroup) ?? -1; + if (!skin || group < 0) return 0; + for (let slot = 0; slot < 4; slot++) { + const offset = vertex * 4 + slot; + if (skin.indices[offset] === group) return skin.weights[offset]; + } + return 0; + }); + const blendPaintColor = (): void => { + if (!activeMesh || !snapshot || selectedPaintBrushWeights.length === 0) return; + const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; + const patch = composePaintColorPatch(snapshot.revision, snapshot.revision, currentPointColors, selectedPaintBrushWeights, [...rgb, 1]); + onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: patch.indices, colors: patch.colors }); + }; + const blendPaintWeight = (): void => { + if (!activeNode || !snapshot || !paintGroup || selectedPaintBrushWeights.length === 0) return; + const patch = composePaintWeightPatch(activeNode.id, paintGroup, snapshot.revision, snapshot.revision, currentGroupWeights, selectedPaintBrushWeights, paintWeight); + onCommand({ type: "setVertexWeights", objectId: patch.objectId, vertexGroup: patch.vertexGroup, indices: patch.indices, values: patch.values, normalize: patch.normalize }); + }; const translateGreasePencilPoint = (): void => { if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return; + const selectedPoints = greasePencilPointSelection + .filter((point) => point.dataId === activeGreasePencil.id && point.layerId === greasePencilLayerId && point.frame === activeGreasePencilFrame.frame) + .map(({ strokeIndex, pointIndex }) => ({ strokeIndex, pointIndex })); + if (selectedPoints.length === 0) selectedPoints.push({ strokeIndex: greasePencilStrokeIndex, pointIndex: greasePencilPointIndex }); const result = applyGreasePencilPointTranslation({ schemaVersion: 1, revision: snapshot.revision, @@ -264,8 +486,8 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo layerId: greasePencilLayerId, frame: activeGreasePencilFrame.frame, onionSkinning: activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.onionSkinning ?? false, - selectedStrokeIndices: [greasePencilStrokeIndex], - selectedPoints: [{ strokeIndex: greasePencilStrokeIndex, pointIndex: greasePencilPointIndex }], + selectedStrokeIndices: [...new Set(selectedPoints.map((point) => point.strokeIndex))], + selectedPoints, }, activeGreasePencilFrame.drawing.strokes, { type: "TRANSLATE_POINTS", revision: snapshot.revision, @@ -321,8 +543,8 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo {activeNode ?

Object & Hierarchy

: null}

Viewport Display

{activeMesh ?

UV Maps

: null} - {activeGreasePencil ?

Grease Pencil

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

Paint

{selectedVertexIndices.length} selected vertices
: 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} {activeMesh ?

Material Slots

{activeMesh.materialSlotIds?.map((id, index) => {index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"})}
@@ -365,14 +587,22 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo ); } -function OperatorSearch({ onClose }: { onClose: () => void }) { +interface OperatorCommand { + id: string; + label: string; + keywords: string; + execute: () => void; +} + +function OperatorSearch({ commands, onClose }: { commands: readonly OperatorCommand[]; onClose: () => void }) { const [query, setQuery] = useState(""); - const operators = ["Add Cube", "Apply Transform", "Frame Selected", "Save Project"]; - const matches = operators.filter((operator) => operator.toLowerCase().includes(query.toLowerCase())); + 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(); }} placeholder="Search operators" aria-label="搜索操作" /> -
{matches.map((operator) => )}
+ 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="搜索操作" /> +
{matches.map((command) => )}
); } @@ -395,13 +625,19 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s const third = Math.round(start + (end - start) * 0.6); const fourth = Math.round(start + (end - start) * 0.8); const animation = snapshot?.animations.find((candidate) => candidate.targetId === snapshot.activeObjectId); - const keyframes = [...new Set(animation?.channels.flatMap((channel) => channel.keyframes.map((keyframe) => keyframe.frame)) ?? [])].sort((left, right) => left - right); + const activeNode = snapshot?.nodes.find((candidate) => candidate.id === snapshot.activeObjectId); + const greasePencil = snapshot?.greasePencils?.find((candidate) => candidate.id === activeNode?.dataId); + const keyframes = [...new Set(greasePencil + ? greasePencil.layers.flatMap((layer) => layer.frames.map((entry) => entry.frame)) + : animation?.channels.flatMap((channel) => channel.keyframes.map((keyframe) => keyframe.frame)) ?? [])] + .filter((keyframe) => keyframe >= start && keyframe <= end) + .sort((left, right) => left - right); return (
{frame}{snapshot?.activeObjectId ? <> : null}
onFrameChange(Number(event.target.value))} aria-label="当前帧" />
{start}{mid}{second}{third}{fourth}{end}
-
{animation?.name ?? "No Action"}
{keyframes.map((keyframe) =>
{animation?.channels[0] ? : null}
+
{greasePencil?.name ?? animation?.name ?? "No Action"}
{keyframes.map((keyframe) =>
{!greasePencil && animation?.channels[0] ? : }
); } @@ -420,6 +656,7 @@ export function App() { const [geometryBuffers, setGeometryBuffers] = useState([]); const [nonMeshGeometryBuffers, setNonMeshGeometryBuffers] = useState([]); const [gpuTextureAssets, setGPUTextureAssets] = useState([]); + const [volumeProject, setVolumeProject] = useState(null); const [preview, setPreview] = useState<{ snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] } | null>(null); const [lodLevels, setLodLevels] = useState | null>(null); const [openProgress, setOpenProgress] = useState(null); @@ -441,7 +678,7 @@ export function App() { return next; }); setSnapshot((current) => current ? { ...current, activeObjectId: id } : current); - setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set(), nonMeshSelections: undefined })); + setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set(), nonMeshSelections: undefined, greasePencilPoints: undefined })); }; const selectMeshElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => { const owner = snapshot?.nodes.find((node) => node.dataId === meshId); @@ -454,7 +691,7 @@ export function App() { const next = preserve ? new Set(current.indices) : new Set(); if (next.has(index)) next.delete(index); else next.add(index); - if (!nonMeshKind) return { meshId, mode, indices: next, nonMeshKind, nonMeshSelections: undefined }; + if (!nonMeshKind) return { meshId, mode, indices: next, nonMeshKind, nonMeshSelections: undefined, greasePencilPoints: undefined }; const selections = new Map>(preserve ? [...(current.nonMeshSelections ?? [])].map(([kind, values]) => [kind, new Set(values)]) : []); const kindIndices = selections.get(nonMeshKind) ?? new Set(); if (kindIndices.has(index)) kindIndices.delete(index); @@ -463,7 +700,23 @@ export function App() { else selections.set(nonMeshKind, kindIndices); const combined = new Set(); for (const values of selections.values()) for (const value of values) combined.add(value); - return { meshId, mode, indices: combined, nonMeshKind, nonMeshSelections: selections }; + return { meshId, mode, indices: combined, nonMeshKind, nonMeshSelections: selections, greasePencilPoints: undefined }; + }); + }; + const selectGreasePencilPoint = (point: GreasePencilPointRef, additive: boolean): void => { + 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 restoreCachedLODs = async (projectId: string, scene: SceneSnapshotIR): Promise => { @@ -579,10 +832,19 @@ export function App() { }; const setMeshSelectionMode = (mode: MeshElementMode): void => { const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId); - setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set(), nonMeshSelections: undefined }); + 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 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 }); + return; + } const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode?.dataId); if (!mesh) return; const count = meshSelection.mode === "VERT" ? mesh.vertexCount : meshSelection.mode === "EDGE" ? mesh.edgeCount : mesh.faceCount; @@ -609,10 +871,37 @@ export function App() { setEngineStatus(`Image import failed${error instanceof Error ? ` (${error.message})` : ""}`); } }; - const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0): void => { + const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0, axisVector?: [number, number, number]): void => { const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId); if (!snapshot || !activeNode) return; if (uiState.context.mode === "Edit" && activeNode.dataId) { + const greasePencil = snapshot.greasePencils?.find((candidate) => candidate.id === activeNode.dataId); + if (greasePencil && tool === "translate" && meshSelection.meshId === greasePencil.id && meshSelection.greasePencilPoints?.length) { + const selected = meshSelection.greasePencilPoints; + const first = selected[0]; + const layer = greasePencil.layers.find((candidate) => candidate.id === first.layerId); + const frame = layer?.frames.find((candidate) => candidate.frame === first.frame); + if (!layer || !frame || selected.some((point) => point.dataId !== first.dataId || point.layerId !== first.layerId || point.frame !== first.frame)) return; + const delta: [number, number, number] = [0, 0, 0]; + delta[axis] = amount; + try { + const result = applyGreasePencilPointTranslation({ + schemaVersion: 1, + revision: snapshot.revision, + dataId: greasePencil.id, + layerId: layer.id, + frame: frame.frame, + onionSkinning: layer.onionSkinning ?? false, + selectedStrokeIndices: [...new Set(selected.map((point) => point.strokeIndex))], + selectedPoints: selected.map(({ strokeIndex, pointIndex }) => ({ strokeIndex, pointIndex })), + }, frame.drawing.strokes, { type: "TRANSLATE_POINTS", revision: snapshot.revision, translation: delta }); + 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})` : ""}`); + } + return; + } const nonMesh = snapshot?.nonMeshData?.find((candidate) => candidate.id === activeNode.dataId); if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id && nonMesh.handlePoints && meshSelection.nonMeshSelections && meshSelection.nonMeshSelections.size > 0) { const handlePoints = nonMesh.handlePoints.slice(); @@ -630,9 +919,9 @@ export function App() { if (handles.length === 0) return; let applied; try { - const delta: [number, number, number] = [0, 0, 0]; - delta[axis] = amount; - applied = applyCurveGizmoDelta({ schemaVersion: 1, dataId: nonMesh.id, baseRevision: snapshot.revision, phase: "COMMIT", axis, delta, handles }, snapshot.revision); + const delta: [number, number, number] = axisVector ? [axisVector[0] * amount, axisVector[1] * amount, axisVector[2] * amount] : [0, 0, 0]; + if (!axisVector) delta[axis] = amount; + 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})` : ""}`); @@ -668,6 +957,16 @@ export function App() { useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { const target = event.target as HTMLElement | null; + if (event.key === "F3") { + event.preventDefault(); + dispatchUI({ type: "toggleOperatorSearch", open: true }); + return; + } + if (event.key === "Escape" && uiState.operatorSearchOpen) { + event.preventDefault(); + dispatchUI({ type: "toggleOperatorSearch", open: false }); + return; + } if (target?.matches("input, textarea, select")) return; const activeId = snapshot?.activeObjectId; if (event.key === "Tab") { @@ -701,7 +1000,7 @@ export function App() { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [snapshot, uiState.context.mode]); + }, [snapshot, uiState.context.mode, uiState.operatorSearchOpen]); const generateLOD = async (meshId: string, triangleCount: number): Promise => { const client = webClientRef.current; if (!client || !snapshot || triangleCount <= 0) return; @@ -880,6 +1179,7 @@ export function App() { const projectId = file.name.replace(/\.blend$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled"; projectIdRef.current = projectId; const result = await client.openBlend(input, setOpenProgress); + setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(input) }); setPreview(null); setLodLevels(null); setGPUTextureAssets([]); @@ -911,6 +1211,7 @@ export function App() { await storage.saveSnapshot(projectIdRef.current, snapshot.revision, data.slice(0)); await storage.pruneOperations(projectIdRef.current, snapshot.revision); } + setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: await sha256Hex(data) }); setSaved(true); return data; }; @@ -937,6 +1238,7 @@ export function App() { buffer = saved.buffer; } let opened = await client.openBlend(buffer); + setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(buffer) }); const replay = await storage.listOperations(projectId, baseRevision); for (const operation of replay.operations) { const payload = operation.payload as WebEngineEditCommand; @@ -1027,6 +1329,17 @@ export function App() { const vertexCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.vertexCount, 0) ?? 0; const faceCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.faceCount, 0) ?? 0; const frameRange = snapshot?.frame ?? { current: frame, start: 1, end: 250 }; + 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" }) }, + ...(snapshot && uiState.context.mode === "Object" ? [{ id: "object.add-cube", label: "Add Cube", keywords: "object primitive mesh", execute: () => { void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] }); } }] : []), + ...(snapshot?.activeObjectId ? [{ id: "object.apply-transform", label: "Apply Transform", keywords: "object location rotation scale", execute: () => { void applyEditCommand({ type: "applyObjectTransform", objectId: snapshot.activeObjectId! }); } }] : []), + ...(snapshot ? [ + { id: "edit.undo", label: "Undo", keywords: "history", execute: () => { void applyEditCommand({ type: "undo" }); } }, + { id: "edit.redo", label: "Redo", keywords: "history", execute: () => { void applyEditCommand({ type: "redo" }); } }, + { id: "file.save", label: "Save Project", keywords: "file blend", execute: () => { void saveBlend(); } }, + ] : []), + ]; return (
@@ -1039,14 +1352,14 @@ export function App() {
{ const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} /> -
{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.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}
+
{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`}
- + 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}`); }} /> + 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 ? dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
Blender Web 0.1.0Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}{openProgress ? {openProgress.message ?? "Opening"} : null}{manifestStatus}{wasmStatus}{engineStatus}{storageStatus}
); diff --git a/web/app/src/app/app-shell.css b/web/app/src/app/app-shell.css index 5d6fbf08..c671738f 100644 --- a/web/app/src/app/app-shell.css +++ b/web/app/src/app/app-shell.css @@ -42,7 +42,7 @@ button { color: inherit; border: 0; cursor: pointer; } .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%; } .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; } +.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; } .viewport-message strong { color: #f3f4f6; font-size: 15px; } .viewport-message span { color: #969ba4; } @@ -50,6 +50,7 @@ button { color: inherit; border: 0; cursor: pointer; } .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; } .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; } .gizmo-axis.axis-x { left: 60px; top: 32px; color: #e35b55; }.gizmo-axis.axis-y { left: 32px; top: 4px; color: #65bb70; }.gizmo-axis.axis-z { left: 32px; top: 60px; color: #5d8ee8; } .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; } diff --git a/web/app/src/compositor/CompositorExecutor.ts b/web/app/src/compositor/CompositorExecutor.ts new file mode 100644 index 00000000..086226b0 --- /dev/null +++ b/web/app/src/compositor/CompositorExecutor.ts @@ -0,0 +1,15 @@ +export { + CompositorFrameCache, + CompositorValidationError, + compositorFrameCacheKey, + executeCompositorGraph, + executeCompositorGraphCached, + gateCompositorGraph, + parseCompositorGraph, +} from "../../../protocol/compositor"; +export type { + CompositorCachedExecutionResult, + CompositorExecutionResult, + CompositorGraphIR, + CompositorImageBuffer, +} from "../../../protocol/compositor"; diff --git a/web/app/src/render/RenderAssets.ts b/web/app/src/render/RenderAssets.ts new file mode 100644 index 00000000..1b38a5e0 --- /dev/null +++ b/web/app/src/render/RenderAssets.ts @@ -0,0 +1 @@ +export * from "../../../protocol/render-assets"; diff --git a/web/app/src/render/nanovdb-volume-renderer.ts b/web/app/src/render/nanovdb-volume-renderer.ts new file mode 100644 index 00000000..e9489946 --- /dev/null +++ b/web/app/src/render/nanovdb-volume-renderer.ts @@ -0,0 +1,615 @@ +import type { NanoVDBGridIR, NanoVDBMaterialIR } from "../../../protocol/volume-vdb"; + +export interface NanoVDBWebGPUCapabilityIR { + available: boolean; + reason?: string; + maxStorageBufferBindingSize?: number; + maxBufferSize?: number; +} + +export interface NanoVDBWebGPUGrid { + buffer: GPUBuffer; + pageTable: GPUBuffer; + byteLength: number; + pageByteLength: number; + pageCount: number; + residentPageCount: number; + residentPageCapacity: number; + paged: boolean; + residentVirtualPages: readonly number[]; + uploadPage(pageIndex: number, data?: ArrayBuffer): void; + evictPage(pageIndex: number): void; + hasResidentPage(pageIndex: number): boolean; + dispose(): void; +} + +export interface NanoVDBMaterialGridUploadsIR { + temperature?: NanoVDBWebGPUGrid; + color?: NanoVDBWebGPUGrid; + emission?: NanoVDBWebGPUGrid; +} + +export interface NanoVDBGpuPageAllocatorStatsIR { + residentBytes: number; + maxResidentBytes: number; + residentPages: number; + evictions: number; + keys: string[]; +} + +export interface NanoVDBDeviceLossIR { reason?: string; message: string } + +const traversalWGSL = /* wgsl */` +fn in_range(byte_offset: u32, byte_length: u32) -> bool { + return byte_offset <= params.data_bytes && byte_length <= params.data_bytes - byte_offset; +} +fn word(byte_offset: u32) -> u32 { + if ((byte_offset & 3u) != 0u || !in_range(byte_offset, 4u)) { return 0u; } + if (params.paged == 0u) { return grid[byte_offset >> 2u]; } + if (params.page_bytes == 0u) { return 0u; } + 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; } + 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]; +} +fn scalar(byte_offset: u32) -> f32 { return bitcast(word(byte_offset)); } +fn mask_on(byte_offset: u32, index: u32) -> bool { + let address = byte_offset + (index >> 5u) * 4u; + return in_range(address, 4u) && (word(address) & (1u << (index & 31u))) != 0u; +} +fn valid_grid() -> bool { + return params.data_bytes >= 736u && word(0u) == 0x6f6e614eu && word(4u) == 0x31424456u && + (word(16u) >> 21u) == 32u && word(32u) == params.data_bytes && word(36u) == 0u; +} +fn root_key(coord: vec3) -> vec2 { + let x = bitcast(coord.x) >> 12u; + let y = bitcast(coord.y) >> 12u; + let z = bitcast(coord.z) >> 12u; + return vec2(z | ((y & 0x7ffu) << 21u), (y >> 11u) | (x << 10u)); +} +fn key_less(a: vec2, b: vec2) -> bool { return a.y < b.y || (a.y == b.y && a.x < b.x); } +fn child_address(parent: u32, offset_address: u32, child_bytes: u32) -> u32 { + let low = word(offset_address); + let high = word(offset_address + 4u); + if (low == 0u || high != 0u || low > params.data_bytes || parent > params.data_bytes - low) { return 0xffffffffu; } + let child = parent + low; + if (!in_range(child, child_bytes)) { return 0xffffffffu; } + return child; +} +fn sample_density(coord: vec3) -> vec2 { + if (!valid_grid()) { return vec2(0.0, -1.0); } + let tree = 672u; + let root = child_address(tree, tree + 24u, 64u); + if (root == 0xffffffffu) { return vec2(0.0, -1.0); } + let count = word(root + 24u); + if (count > (params.data_bytes - root - 64u) / 32u) { return vec2(0.0, -1.0); } + let wanted = root_key(coord); + var low = 0u; + var high = count; + var tile = 0xffffffffu; + for (var iteration = 0u; iteration < 32u && low < high; iteration++) { + let middle = low + (high - low) / 2u; + let address = root + 64u + middle * 32u; + let candidate = vec2(word(address), word(address + 4u)); + if (all(candidate == wanted)) { tile = address; break; } + if (key_less(wanted, candidate)) { low = middle + 1u; } else { high = middle; } + } + if (tile == 0xffffffffu) { return vec2(scalar(root + 28u), 0.0); } + let root_child_low = word(tile + 8u); + let root_child_high = word(tile + 12u); + if (root_child_low == 0u && root_child_high == 0u) { return vec2(scalar(tile + 20u), select(0.0, 1.0, word(tile + 16u) != 0u)); } + let upper = child_address(root, tile + 8u, 270400u); + if (upper == 0xffffffffu) { return vec2(0.0, -1.0); } + let ux = (bitcast(coord.x) & 4095u) >> 7u; + let uy = (bitcast(coord.y) & 4095u) >> 7u; + let uz = (bitcast(coord.z) & 4095u) >> 7u; + let upper_index = (ux << 10u) | (uy << 5u) | uz; + if (!mask_on(upper + 4128u, upper_index)) { return vec2(scalar(upper + 8256u + upper_index * 8u), select(0.0, 1.0, mask_on(upper + 32u, upper_index))); } + let lower = child_address(upper, upper + 8256u + upper_index * 8u, 33856u); + if (lower == 0xffffffffu) { return vec2(0.0, -1.0); } + let lx = (bitcast(coord.x) & 127u) >> 3u; + let ly = (bitcast(coord.y) & 127u) >> 3u; + let lz = (bitcast(coord.z) & 127u) >> 3u; + let lower_index = (lx << 8u) | (ly << 4u) | lz; + if (!mask_on(lower + 544u, lower_index)) { return vec2(scalar(lower + 1088u + lower_index * 8u), select(0.0, 1.0, mask_on(lower + 32u, lower_index))); } + let leaf = child_address(lower, lower + 1088u + lower_index * 8u, 2144u); + if (leaf == 0xffffffffu) { return vec2(0.0, -1.0); } + let voxel = ((bitcast(coord.x) & 7u) << 6u) | ((bitcast(coord.y) & 7u) << 3u) | (bitcast(coord.z) & 7u); + return vec2(scalar(leaf + 96u + voxel * 4u), select(0.0, 1.0, mask_on(leaf + 16u, voxel))); +} +fn sample_density_linear(position: vec3) -> vec2 { + let base = vec3(floor(position)); + let fraction = position - vec3(base); + var value = 0.0; + var activity = 0.0; + for (var x = 0i; x < 2i; x += 1i) { + for (var y = 0i; y < 2i; y += 1i) { + for (var z = 0i; z < 2i; z += 1i) { + let sample = sample_density(base + vec3(x, y, z)); + if (sample.y < 0.0) { return vec2(0.0, -1.0); } + let offset = vec3(f32(x), f32(y), f32(z)); + let weight3 = select(vec3(1.0) - fraction, fraction, offset == vec3(1.0)); + value += sample.x * weight3.x * weight3.y * weight3.z; + activity = max(activity, sample.y); + } + } + } + return vec2(value, activity); +} +`; + +function specializeFloatTraversal(prefix: string, gridName: string, pageTableName: string, parameterPrefix: string): string { + let source = traversalWGSL + .replaceAll("grid[", `${gridName}[`) + .replaceAll("page_table[page]", pageTableName ? `${pageTableName}[page]` : "page") + .replaceAll("params.data_bytes", `params.${parameterPrefix}_data_bytes`) + .replaceAll("params.page_bytes", `params.${parameterPrefix}_page_bytes`) + .replaceAll("params.page_count", `params.${parameterPrefix}_page_count`) + .replaceAll("params.resident_pages", `params.${parameterPrefix}_resident_pages`) + .replaceAll("params.paged", `params.${parameterPrefix}_paged`); + for (const name of ["sample_density_linear", "sample_density", "child_address", "valid_grid", "root_key", "key_less", "in_range", "mask_on", "scalar", "word"]) { + source = source.replace(new RegExp(`\\b${name}\\b`, "g"), `${prefix}_${name}`); + } + return source; +} + +const temperatureTraversalWGSL = specializeFloatTraversal("temperature", "temperature_grid", "", "temperature"); +const emissionTraversalWGSL = specializeFloatTraversal("emission", "emission_grid", "", "emission"); + +const vec3TraversalWGSL = /* wgsl */` +fn color_in_range(byte_offset: u32, byte_length: u32) -> bool { + return byte_offset <= params.color_data_bytes && byte_length <= params.color_data_bytes - byte_offset; +} +fn color_word(byte_offset: u32) -> u32 { + if ((byte_offset & 3u) != 0u || !color_in_range(byte_offset, 4u) || params.color_page_bytes == 0u) { return 0u; } + let page = byte_offset / params.color_page_bytes; + if (page >= params.color_page_count) { return 0u; } + let slot = page; + if (slot == 0xffffffffu || slot >= params.color_resident_pages) { return 0u; } + let physical = slot * params.color_page_bytes + (byte_offset % params.color_page_bytes); + if (physical > params.color_resident_pages * params.color_page_bytes - 4u) { return 0u; } + return color_grid[physical >> 2u]; +} +fn color_scalar(byte_offset: u32) -> f32 { return bitcast(color_word(byte_offset)); } +fn color_vec3(byte_offset: u32) -> vec3 { return vec3(color_scalar(byte_offset), color_scalar(byte_offset + 4u), color_scalar(byte_offset + 8u)); } +fn color_mask_on(byte_offset: u32, index: u32) -> bool { return (color_word(byte_offset + (index >> 5u) * 4u) & (1u << (index & 31u))) != 0u; } +fn color_valid_grid() -> bool { + return params.color_data_bytes >= 768u && color_word(0u) == 0x6f6e614eu && color_word(4u) == 0x31424456u && + (color_word(16u) >> 21u) == 32u && color_word(32u) == params.color_data_bytes && color_word(36u) == 0u; +} +fn color_root_key(coord: vec3) -> vec2 { + let x = bitcast(coord.x) >> 12u; let y = bitcast(coord.y) >> 12u; let z = bitcast(coord.z) >> 12u; + return vec2(z | ((y & 0x7ffu) << 21u), (y >> 11u) | (x << 10u)); +} +fn color_key_less(a: vec2, b: vec2) -> bool { return a.y < b.y || (a.y == b.y && a.x < b.x); } +fn color_child_address(parent: u32, offset_address: u32, child_bytes: u32) -> u32 { + let low = color_word(offset_address); let high = color_word(offset_address + 4u); + if (low == 0u || high != 0u || low > params.color_data_bytes || parent > params.color_data_bytes - low) { return 0xffffffffu; } + let child = parent + low; if (!color_in_range(child, child_bytes)) { return 0xffffffffu; } return child; +} +fn sample_color(coord: vec3) -> vec4 { + if (!color_valid_grid()) { return vec4(0.0, 0.0, 0.0, -1.0); } + let tree = 672u; let root = color_child_address(tree, tree + 24u, 96u); + if (root == 0xffffffffu) { return vec4(0.0, 0.0, 0.0, -1.0); } + let count = color_word(root + 24u); + if (count > (params.color_data_bytes - root - 96u) / 32u) { return vec4(0.0, 0.0, 0.0, -1.0); } + let wanted = color_root_key(coord); var low = 0u; var high = count; var tile = 0xffffffffu; + for (var iteration = 0u; iteration < 32u && low < high; iteration++) { + let middle = low + (high - low) / 2u; let address = root + 96u + middle * 32u; + let candidate = vec2(color_word(address), color_word(address + 4u)); + if (all(candidate == wanted)) { tile = address; break; } + if (color_key_less(wanted, candidate)) { low = middle + 1u; } else { high = middle; } + } + if (tile == 0xffffffffu) { return vec4(0.0); } + let root_child_low = color_word(tile + 8u); let root_child_high = color_word(tile + 12u); + if (root_child_low == 0u && root_child_high == 0u) { return vec4(color_vec3(tile + 20u), select(0.0, 1.0, color_word(tile + 16u) != 0u)); } + let upper = color_child_address(root, tile + 8u, 532544u); if (upper == 0xffffffffu) { return vec4(0.0, 0.0, 0.0, -1.0); } + let upper_index = (((bitcast(coord.x) & 4095u) >> 7u) << 10u) | (((bitcast(coord.y) & 4095u) >> 7u) << 5u) | ((bitcast(coord.z) & 4095u) >> 7u); + if (!color_mask_on(upper + 4128u, upper_index)) { return vec4(color_vec3(upper + 8256u + upper_index * 16u), select(0.0, 1.0, color_mask_on(upper + 32u, upper_index))); } + let lower = color_child_address(upper, upper + 8256u + upper_index * 16u, 66624u); if (lower == 0xffffffffu) { return vec4(0.0, 0.0, 0.0, -1.0); } + let lower_index = (((bitcast(coord.x) & 127u) >> 3u) << 8u) | (((bitcast(coord.y) & 127u) >> 3u) << 4u) | ((bitcast(coord.z) & 127u) >> 3u); + if (!color_mask_on(lower + 544u, lower_index)) { return vec4(color_vec3(lower + 1088u + lower_index * 16u), select(0.0, 1.0, color_mask_on(lower + 32u, lower_index))); } + let leaf = color_child_address(lower, lower + 1088u + lower_index * 16u, 6272u); if (leaf == 0xffffffffu) { return vec4(0.0, 0.0, 0.0, -1.0); } + let voxel = ((bitcast(coord.x) & 7u) << 6u) | ((bitcast(coord.y) & 7u) << 3u) | (bitcast(coord.z) & 7u); + return vec4(color_vec3(leaf + 128u + voxel * 12u), select(0.0, 1.0, color_mask_on(leaf + 16u, voxel))); +} +fn sample_color_linear(position: vec3) -> vec4 { + let base = vec3(floor(position)); let fraction = position - vec3(base); var value = vec3(0.0); var activity = 0.0; + for (var x = 0i; x < 2i; x += 1i) { for (var y = 0i; y < 2i; y += 1i) { for (var z = 0i; z < 2i; z += 1i) { + let sample = sample_color(base + vec3(x, y, z)); if (sample.w < 0.0) { return vec4(0.0, 0.0, 0.0, -1.0); } + let offset = vec3(f32(x), f32(y), f32(z)); let weight3 = select(vec3(1.0) - fraction, fraction, offset == vec3(1.0)); + value += sample.xyz * weight3.x * weight3.y * weight3.z; activity = max(activity, sample.w); + }}} + return vec4(value, activity); +} +`; + +export async function probeNanoVDBWebGPU(requiredBytes = 1): Promise<{ capability: NanoVDBWebGPUCapabilityIR; adapter?: GPUAdapter; device?: GPUDevice }> { + if (!navigator.gpu) return { capability: { available: false, reason: "WebGPU is unavailable" } }; + const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); + if (!adapter) return { capability: { available: false, reason: "No WebGPU adapter is available" } }; + const maxStorageBufferBindingSize = Number(adapter.limits.maxStorageBufferBindingSize); + const maxBufferSize = Number(adapter.limits.maxBufferSize); + if (requiredBytes > maxStorageBufferBindingSize || requiredBytes > maxBufferSize) return { capability: { available: false, reason: "NanoVDB grid exceeds WebGPU adapter limits", maxStorageBufferBindingSize, maxBufferSize } }; + const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: requiredBytes, maxBufferSize: requiredBytes } }); + return { capability: { available: true, maxStorageBufferBindingSize, maxBufferSize }, adapter, device }; +} + +export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer): NanoVDBWebGPUGrid { + if (payload.byteLength === 0 || payload.byteLength % 32 !== 0 || payload.byteLength > device.limits.maxStorageBufferBindingSize || payload.byteLength > device.limits.maxBufferSize) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: grid payload cannot be uploaded"); + 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 }); + new Uint32Array(pageTable.getMappedRange())[0] = 0; + pageTable.unmap(); + return { + buffer, + pageTable, + byteLength: payload.byteLength, + pageByteLength: payload.byteLength, + pageCount: 1, + residentPageCount: 1, + residentPageCapacity: 1, + paged: false, + residentVirtualPages: [0], + uploadPage: (pageIndex, data) => { + if (pageIndex !== 0 || (data && data.byteLength !== payload.byteLength)) throw new Error("NANOVDB_STREAM_INCOMPLETE: direct NanoVDB grid has one immutable page"); + }, + 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(); }, + }; +} + +export function uploadNanoVDBFloat32GridPaged( + device: GPUDevice, + payload: ArrayBuffer, + pageByteLength: number, + maxResidentBytes: number, +): NanoVDBWebGPUGrid { + if (payload.byteLength === 0 || payload.byteLength % 32 !== 0 || !Number.isSafeInteger(pageByteLength) || pageByteLength < 64 * 1024 || pageByteLength % 32 !== 0) { + throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged grid layout"); + } + if (!Number.isSafeInteger(maxResidentBytes) || maxResidentBytes < pageByteLength) { + throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged resident budget"); + } + const pageCount = Math.ceil(payload.byteLength / pageByteLength); + const residentPageCount = Math.min(pageCount, Math.max(1, Math.floor(maxResidentBytes / pageByteLength))); + const physicalBytes = residentPageCount * pageByteLength; + if (pageCount > 8192 || physicalBytes > device.limits.maxStorageBufferBindingSize || physicalBytes > device.limits.maxBufferSize) { + throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: paged grid exceeds the resident or adapter budget"); + } + const buffer = device.createBuffer({ label: "NanoVDB paged Float32 grid", size: physicalBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + const pageTableBytes = Math.max(4, pageCount * 4); + const pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true }); + const table = new Uint32Array(pageTable.getMappedRange()); + table.fill(0xffffffff); + pageTable.unmap(); + const resident = new Map(); + const upload = (pageIndex: number, data = payload.slice(pageIndex * pageByteLength, Math.min(payload.byteLength, (pageIndex + 1) * pageByteLength))): void => { + if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount || data.byteLength === 0 || data.byteLength > pageByteLength) { + throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB page is outside the virtual grid"); + } + const existingSlot = resident.get(pageIndex); + const slot = existingSlot ?? [...Array(residentPageCount).keys()].find((candidate) => !residentHasSlot(candidate)); + if (slot === undefined) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available"); + device.queue.writeBuffer(buffer, slot * pageByteLength, data); + table[pageIndex] = slot; + device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([slot])); + resident.set(pageIndex, slot); + }; + const residentHasSlot = (slot: number): boolean => { + for (const current of resident.values()) if (current === slot) return true; + return false; + }; + const initialPages = Math.min(pageCount, residentPageCount); + for (let page = 0; page < initialPages; page++) upload(page); + return { + buffer, + pageTable, + byteLength: payload.byteLength, + pageByteLength, + pageCount, + residentPageCount: resident.size, + residentPageCapacity: residentPageCount, + paged: true, + get residentVirtualPages() { return [...resident.keys()].sort((a, b) => a - b); }, + uploadPage: upload, + evictPage: (pageIndex) => { + if (!resident.delete(pageIndex)) return; + table[pageIndex] = 0xffffffff; + device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([0xffffffff])); + }, + hasResidentPage: (pageIndex) => resident.has(pageIndex), + dispose: () => { resident.clear(); buffer.destroy(); pageTable.destroy(); }, + }; +} + +export class NanoVDBGpuPageAllocator { + private readonly pages = new Map(); + private clock = 0; + private evictions = 0; + + constructor( + private readonly device: GPUDevice, + readonly pageByteLength: number, + readonly maxResidentBytes: number, + ) { + if (!Number.isSafeInteger(pageByteLength) || pageByteLength < 64 * 1024 || pageByteLength % 32 !== 0 || + !Number.isSafeInteger(maxResidentBytes) || maxResidentBytes < pageByteLength) { + throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid GPU page allocator budget"); + } + } + + upload(key: string, data: ArrayBuffer): GPUBuffer { + if (!key || data.byteLength === 0 || data.byteLength > this.pageByteLength) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid GPU page"); + const existing = this.pages.get(key); + if (existing) { existing.used = ++this.clock; return existing.buffer; } + while (this.residentBytes() + this.pageByteLength > this.maxResidentBytes) this.evictOldest(); + const buffer = this.device.createBuffer({ label: `NanoVDB page ${key}`, size: this.pageByteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true }); + new Uint8Array(buffer.getMappedRange()).set(new Uint8Array(data)); + buffer.unmap(); + this.pages.set(key, { buffer, bytes: this.pageByteLength, used: ++this.clock }); + return buffer; + } + + touch(key: string): boolean { + const page = this.pages.get(key); + if (!page) return false; + page.used = ++this.clock; + return true; + } + + has(key: string): boolean { return this.pages.has(key); } + + stats(): NanoVDBGpuPageAllocatorStatsIR { + return { residentBytes: this.residentBytes(), maxResidentBytes: this.maxResidentBytes, residentPages: this.pages.size, evictions: this.evictions, keys: [...this.pages.keys()].sort() }; + } + + dispose(): void { + for (const page of this.pages.values()) page.buffer.destroy(); + this.pages.clear(); + } + + private residentBytes(): number { return [...this.pages.values()].reduce((sum, page) => sum + page.bytes, 0); } + + private evictOldest(): void { + const oldest = [...this.pages].sort((left, right) => left[1].used - right[1].used || left[0].localeCompare(right[0]))[0]; + if (!oldest) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no GPU page can be evicted"); + oldest[1].buffer.destroy(); + this.pages.delete(oldest[0]); + this.evictions++; + } +} + +export class NanoVDBWebGPUDeviceSession { + device?: GPUDevice; + generation = 0; + status: "idle" | "ready" | "lost" | "disposed" = "idle"; + private loss?: Promise; + private readonly lossListeners = new Set<(loss: NanoVDBDeviceLossIR) => void>(); + + onDeviceLost(listener: (loss: NanoVDBDeviceLossIR) => void): () => void { + this.lossListeners.add(listener); + return () => this.lossListeners.delete(listener); + } + + async open(requiredBytes: number): Promise { + if (this.status === "disposed") throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU session is disposed"); + const probe = await probeNanoVDBWebGPU(requiredBytes); + if (!probe.capability.available || !probe.device) throw new Error(`VOLUME_SHADER_UNAVAILABLE: ${probe.capability.reason ?? "WebGPU unavailable"}`); + this.device = probe.device; + this.generation++; + this.status = "ready"; + this.loss = probe.device.lost.then((info: NanoVDBDeviceLossIR) => { + if (this.device === probe.device && this.status !== "disposed") this.status = "lost"; + for (const listener of this.lossListeners) listener(info); + return info; + }); + return probe.device; + } + + async waitForLoss(): Promise { + if (!this.loss) throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU session has not opened"); + return this.loss; + } + + async recover(requiredBytes: number): Promise { + this.device?.destroy(); + return this.open(requiredBytes); + } + + dispose(): void { + this.status = "disposed"; + this.device?.destroy(); + this.device = undefined; + } +} + +function paramsBuffer(device: GPUDevice, values: Uint32Array): GPUBuffer { + const buffer = device.createBuffer({ size: Math.max(16, values.byteLength), usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + device.queue.writeBuffer(buffer, 0, values); + return buffer; +} + +export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, coordinates: Array): 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)); + const coordinateBuffer = device.createBuffer({ size: coordinateData.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); + device.queue.writeBuffer(coordinateBuffer, 0, coordinateData); + const resultBytes = coordinates.length * 16; + 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 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; +@group(0) @binding(1) var coords: array>; +@group(0) @binding(2) var results: array>; +@group(0) @binding(3) var params: Params; +@group(0) @binding(4) var page_table: array; +${traversalWGSL} +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) id: vec3) { + if (id.x >= params.count) { return; } + let sample = sample_density(coords[id.x].xyz); + results[id.x] = vec4(sample.x, sample.y, 0.0, 0.0); +}` }); + const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } }); + const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [ + { 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 } }, + ] }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(coordinates.length / 64)); pass.end(); + encoder.copyBufferToBuffer(resultBuffer, 0, readback, 0, resultBytes); + device.queue.submit([encoder.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const values = new Float32Array(readback.getMappedRange().slice(0)); + readback.unmap(); + coordinateBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy(); + return coordinates.map((_coord, index) => ({ value: values[index * 4], active: values[index * 4 + 1] > 0.5, valid: values[index * 4 + 1] >= 0 })); +} + +export async function renderNanoVDBFloat32WebGPU( + device: GPUDevice, + uploaded: NanoVDBWebGPUGrid, + gridDefinition: NanoVDBGridIR, + material: NanoVDBMaterialIR, + width = 96, + height = 96, + materialGrids: NanoVDBMaterialGridUploadsIR = {}, +): Promise { + if (gridDefinition.valueType !== "FLOAT32" || width < 1 || height < 1 || width > 2048 || height > 2048) throw new Error("NANOVDB_GRID_UNSUPPORTED: bounded Float32 render input required"); + const outputBytes = width * height * 4; + const output = device.createBuffer({ size: outputBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); + const readback = device.createBuffer({ size: outputBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }); + const paramsData = new ArrayBuffer(224); + const u32 = new Uint32Array(paramsData); + const i32 = new Int32Array(paramsData); + const f32 = new Float32Array(paramsData); + u32.set([uploaded.byteLength, material.interpolation === "LINEAR" ? 1 : 0, width, height], 0); + u32.set([uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0], 4); + const uploadFields = (grid: NanoVDBWebGPUGrid | undefined): [number, number, number, number, number, number, number, number] => grid + ? [grid.byteLength, grid.pageByteLength, grid.pageCount, grid.residentPageCapacity, grid.paged ? 1 : 0, 0, 0, 0] + : [0, 0, 0, 0, 0, 0, 0, 0]; + u32.set(uploadFields(materialGrids.temperature), 8); + u32.set(uploadFields(materialGrids.color), 16); + u32.set(uploadFields(materialGrids.emission), 24); + i32.set([...gridDefinition.indexBounds.min, 0], 32); + i32.set([...gridDefinition.indexBounds.max, 0], 36); + f32.set([material.densityScale, material.emissionScale, material.anisotropy, Math.max(0.01, gridDefinition.voxelSize[2])], 40); + f32.set([...(material.color ?? [0.72, 0.78, 0.86]), 1], 44); + f32.set([...(material.emissionColor ?? [1, 1, 1]), 1], 48); + f32.set([materialGrids.temperature ? 1 : 0, materialGrids.color ? 1 : 0, materialGrids.emission ? 1 : 0, material.temperatureScale], 52); + const params = device.createBuffer({ size: paramsData.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + device.queue.writeBuffer(params, 0, paramsData); + const temperatureSource = materialGrids.temperature ? temperatureTraversalWGSL : /* wgsl */` +fn temperature_sample_density(coord: vec3) -> vec2 { return vec2(0.0); } +fn temperature_sample_density_linear(position: vec3) -> vec2 { return vec2(0.0); } +`; + const colorSource = materialGrids.color ? vec3TraversalWGSL : /* wgsl */` +fn sample_color(coord: vec3) -> vec4 { return vec4(0.0); } +fn sample_color_linear(position: vec3) -> vec4 { return vec4(0.0); } +`; + const emissionSource = materialGrids.emission ? emissionTraversalWGSL : /* wgsl */` +fn emission_sample_density(coord: vec3) -> vec2 { return vec2(0.0); } +fn emission_sample_density_linear(position: vec3) -> vec2 { return vec2(0.0); } +`; + const module = device.createShaderModule({ label: "NanoVDB bounded volume integrator", code: /* wgsl */` +struct Params { + data_bytes: u32, interpolation: u32, width: u32, height: u32, + page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32, + temperature_data_bytes: u32, temperature_page_bytes: u32, temperature_page_count: u32, temperature_resident_pages: u32, + temperature_paged: u32, temperature_pad0: u32, temperature_pad1: u32, temperature_pad2: u32, + color_data_bytes: u32, color_page_bytes: u32, color_page_count: u32, color_resident_pages: u32, + color_paged: u32, color_pad0: u32, color_pad1: u32, color_pad2: u32, + emission_data_bytes: u32, emission_page_bytes: u32, emission_page_count: u32, emission_resident_pages: u32, + emission_paged: u32, emission_pad0: u32, emission_pad1: u32, emission_pad2: u32, + index_min: vec4, index_max: vec4, material: vec4, color: vec4, emission_color: vec4, material_grids: vec4 +} +@group(0) @binding(0) var grid: array; +@group(0) @binding(1) var pixels: array; +@group(0) @binding(2) var params: Params; +@group(0) @binding(3) var page_table: array; +@group(0) @binding(4) var temperature_grid: array; +@group(0) @binding(5) var color_grid: array; +@group(0) @binding(6) var emission_grid: array; +${traversalWGSL} +${temperatureSource} +${colorSource} +${emissionSource} +fn blackbody_color(kelvin: f32) -> vec3 { + let t = smoothstep(800.0, 12000.0, clamp(kelvin, 800.0, 12000.0)); + return mix(vec3(1.0, 0.11, 0.015), vec3(0.62, 0.8, 1.0), t); +} +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) id: vec3) { + if (id.x >= params.width || id.y >= params.height) { return; } + let extent = vec2(params.index_max.xy - params.index_min.xy + vec2(1)); + let uv = (vec2(id.xy) + vec2(0.5)) / vec2(f32(params.width), f32(params.height)); + let xy_position = vec2(params.index_min.xy) + uv * extent - vec2(0.5); + let xy = vec2(round(xy_position)); + let z_count = max(1, params.index_max.z - params.index_min.z + 1); + let stride = max(1, (z_count + 255) / 256); + let g = clamp(params.material.z, -0.99, 0.99); + let phase = (1.0 - g * g) / (12.5663706 * pow(max(0.0001, 1.0 + g * g), 1.5)); + var transmittance = 1.0; + var radiance = vec3(0.0); + for (var z = params.index_min.z; z <= params.index_max.z; z += stride) { + var sample = sample_density(vec3(xy, z)); + if (params.interpolation == 1u) { + sample = sample_density_linear(vec3(xy_position, f32(z) + 0.5)); + } + if (sample.y < 0.0) { radiance = vec3(1.0, 0.0, 1.0); transmittance = 0.0; break; } + let density = max(0.0, sample.x) * params.material.x; + let alpha = 1.0 - exp(-density * params.material.w * f32(stride)); + var scattering_color = params.color.rgb; + if (params.material_grids.y > 0.5) { + var color_sample = sample_color(vec3(xy, z)); + if (params.interpolation == 1u) { color_sample = sample_color_linear(vec3(xy_position, f32(z) + 0.5)); } + if (color_sample.w >= 0.0 && color_sample.w > 0.5) { scattering_color = max(vec3(0.0), color_sample.xyz); } + } + var emitted = params.emission_color.rgb * params.material.y; + if (params.material_grids.x > 0.5 && params.material.y > 0.0) { + var temperature_sample = temperature_sample_density(vec3(xy, z)); + if (params.interpolation == 1u) { temperature_sample = temperature_sample_density_linear(vec3(xy_position, f32(z) + 0.5)); } + if (temperature_sample.y > 0.5) { emitted += blackbody_color(temperature_sample.x * params.material_grids.w) * params.material.y; } + } + if (params.material_grids.z > 0.5 && params.material.y > 0.0) { + var emission_sample = emission_sample_density(vec3(xy, z)); + if (params.interpolation == 1u) { emission_sample = emission_sample_density_linear(vec3(xy_position, f32(z) + 0.5)); } + if (emission_sample.y > 0.5) { emitted += params.emission_color.rgb * max(0.0, emission_sample.x) * params.material.y; } + } + let source = scattering_color * (0.5 + 8.0 * phase) + emitted; + radiance += transmittance * alpha * source; + transmittance *= 1.0 - alpha; + if (transmittance < 0.005) { break; } + } + pixels[id.y * params.width + id.x] = pack4x8unorm(vec4(clamp(radiance, vec3(0.0), vec3(1.0)), 1.0 - transmittance)); +}` }); + const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } }); + const entries: Array<{ binding: number; resource: { buffer: GPUBuffer } }> = [ + { binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: output } }, { binding: 2, resource: { buffer: params } }, + { binding: 3, resource: { buffer: uploaded.pageTable } }, + ]; + if (materialGrids.temperature) entries.push({ binding: 4, resource: { buffer: materialGrids.temperature.buffer } }); + if (materialGrids.color) entries.push({ binding: 5, resource: { buffer: materialGrids.color.buffer } }); + if (materialGrids.emission) entries.push({ binding: 6, resource: { buffer: materialGrids.emission.buffer } }); + const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8)); pass.end(); + encoder.copyBufferToBuffer(output, 0, readback, 0, outputBytes); + device.queue.submit([encoder.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const pixels = new Uint8Array(readback.getMappedRange().slice(0)); + readback.unmap(); output.destroy(); readback.destroy(); params.destroy(); + return pixels; +} diff --git a/web/app/src/sequencer/SequencerTimeline.ts b/web/app/src/sequencer/SequencerTimeline.ts new file mode 100644 index 00000000..f11e6982 --- /dev/null +++ b/web/app/src/sequencer/SequencerTimeline.ts @@ -0,0 +1,14 @@ +export { + applySequencerEdit, + gateSequencerCodec, + parseSequencerTimeline, + resolveSequencerFrame, + resolveSequencerTransitionFrame, + sequencerRuntimeCapabilities, + sequencerSourceFrame, +} from "../../../protocol/sequencer"; +export type { + SequencerFrameStripIR, + SequencerTimelineIR, + SequencerTransitionFrameIR, +} from "../../../protocol/sequencer"; diff --git a/web/app/src/simulation/BrowserTransformCachePlayback.ts b/web/app/src/simulation/BrowserTransformCachePlayback.ts new file mode 100644 index 00000000..6cd08f3c --- /dev/null +++ b/web/app/src/simulation/BrowserTransformCachePlayback.ts @@ -0,0 +1,8 @@ +export { + applyBrowserTransformCachePreview, + BrowserTransformCachePlaybackSession, +} from "../../../protocol/physics-cache-playback"; +export type { + BrowserTransformCacheFrameSource, + BrowserTransformCachePlaybackResult, +} from "../../../protocol/physics-cache-playback"; diff --git a/web/app/src/three-adapter/grease-pencil.ts b/web/app/src/three-adapter/grease-pencil.ts index 5f743f42..4d9b9181 100644 --- a/web/app/src/three-adapter/grease-pencil.ts +++ b/web/app/src/three-adapter/grease-pencil.ts @@ -5,6 +5,8 @@ import { Group, Line, LineBasicMaterial, + Points, + PointsMaterial, type Object3D, } from "../vendor/three/three.module.js"; import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil"; @@ -12,9 +14,26 @@ import type { SceneNodeIR } from "../../../protocol/scene-ir"; interface DrawingPreview { drawing: GreasePencilDrawingIR; + frame: number; onion: "NONE" | "PREVIOUS" | "NEXT"; } +export interface GreasePencilPointRef { + dataId: string; + layerId: string; + frame: number; + strokeIndex: number; + pointIndex: number; +} + +export interface GreasePencilPointPreview extends GreasePencilPointRef { + position: [number, number, number]; +} + +function blenderPosition(position: readonly number[]): [number, number, number] { + return [position[0], position[2], -position[1]]; +} + function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined { let selected: GreasePencilFrameIR | undefined; for (const candidate of frames) { @@ -26,20 +45,26 @@ function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): Gre function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPreview[] { const current = activeFrame(layer.frames, frame); if (!current) return []; - const result: DrawingPreview[] = [{ drawing: current.drawing, onion: "NONE" }]; + const result: DrawingPreview[] = [{ drawing: current.drawing, frame: current.frame, onion: "NONE" }]; if (!layer.onionSkinning) return result; const sorted = [...layer.frames].sort((left, right) => left.frame - right.frame); const currentIndex = sorted.findIndex((candidate) => candidate.frame === current.frame); - if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, onion: "PREVIOUS" }); - if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, onion: "NEXT" }); + if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, frame: sorted[currentIndex - 1].frame, onion: "PREVIOUS" }); + if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, frame: sorted[currentIndex + 1].frame, onion: "NEXT" }); return result; } -function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPreview): number { +function pointKey(point: GreasePencilPointRef): string { + return `${point.dataId}\u0000${point.layerId}\u0000${point.frame}\u0000${point.strokeIndex}\u0000${point.pointIndex}`; +} + +function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, preview: DrawingPreview): number { let count = 0; - for (const stroke of preview.drawing.strokes) { - if (!stroke.points || stroke.points.length < 2) continue; - const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0); + for (let strokeIndex = 0; strokeIndex < preview.drawing.strokes.length; strokeIndex++) { + const stroke = preview.drawing.strokes[strokeIndex]; + if (!stroke.points || stroke.points.length === 0) continue; + const linePointCount = stroke.points.length + (stroke.cyclic && stroke.points.length > 1 ? 1 : 0); + const pointCount = Math.max(stroke.points.length, linePointCount); const positions = new Float32Array(pointCount * 3); let red = 0; let green = 0; @@ -59,19 +84,42 @@ function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPr opacity += point.opacity * color[3]; } const divisor = stroke.points.length; - const geometry = new BufferGeometry(); - geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); const onionColor = preview.onion === "PREVIOUS" ? new Color(0x6aa8ff) : preview.onion === "NEXT" ? new Color(0xff8a63) : null; - const material = new LineBasicMaterial({ - color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor), - opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))), - transparent: true, - depthWrite: preview.onion === "NONE", - }); - const line = new Line(geometry, material); - line.userData.greasePencilOnion = preview.onion; - line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0; - group.add(line); + let strokeObject: Line | null = null; + if (stroke.points.length > 1) { + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(positions.subarray(0, linePointCount * 3), 3)); + const material = new LineBasicMaterial({ + color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor), + opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))), + transparent: true, + depthWrite: preview.onion === "NONE", + }); + const line = new Line(geometry, material); + line.userData.greasePencilOnion = preview.onion; + line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0; + line.userData.greasePencilPreviewDataId = dataId; + line.userData.greasePencilPreviewLayerId = layer.id; + line.userData.greasePencilPreviewFrame = preview.frame; + 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)); + group.add(line); + strokeObject = line; + } + if (preview.onion === "NONE") { + const pointGeometry = new BufferGeometry(); + pointGeometry.setAttribute("position", new Float32BufferAttribute(positions.subarray(0, stroke.points.length * 3), 3)); + const points = new Points(pointGeometry, new PointsMaterial({ color: new Color(0x76baff), size: 0.1, sizeAttenuation: true, vertexColors: true })); + points.userData.greasePencilPointDataId = dataId; + points.userData.greasePencilPointLayerId = layer.id; + points.userData.greasePencilPointFrame = preview.frame; + points.userData.greasePencilPointStrokeIndex = strokeIndex; + points.userData.greasePencilPointIndexMap = Array.from({ length: stroke.points.length }, (_, index) => index); + points.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, stroke.points.length * 3)); + points.visible = false; + (strokeObject ?? group).add(points); + } count++; } return count; @@ -85,7 +133,7 @@ export function createGreasePencilObject(data: GreasePencilDataIR, frame: number for (const layer of data.layers) { if (!layer.visible || layer.opacity <= 0) continue; for (const preview of layerDrawings(layer, frame)) { - const added = addDrawing(group, layer, preview); + const added = addDrawing(group, data.id, layer, preview); if (preview.onion === "NONE") currentDrawingCount += added; else onionDrawingCount += added; } @@ -95,6 +143,66 @@ export function createGreasePencilObject(data: GreasePencilDataIR, frame: number return group.children.length > 0 ? group : null; } +export function greasePencilPointRef(object: Object3D, pointIndex: number): GreasePencilPointRef | null { + const dataId = object.userData.greasePencilPointDataId; + const layerId = object.userData.greasePencilPointLayerId; + const frame = object.userData.greasePencilPointFrame; + 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 }; +} + +export function applyGreasePencilPointSelection(root: Object3D, selection: readonly GreasePencilPointRef[]): void { + const selected = new Set(selection.map(pointKey)); + 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 })); + colors.set(active ? [1, 0.38, 0.08] : [0.46, 0.73, 1], pointIndex * 3); + } + object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3)); + object.material.color.set(0xffffff); + object.material.vertexColors = true; + object.material.needsUpdate = true; + }); +} + +export function applyGreasePencilPointPreview( + root: Object3D, + dataId: string, + layerId: string, + frame: number, + points: readonly GreasePencilPointPreview[] | null, +): void { + const positions = new Map((points ?? []).map((point) => [`${point.strokeIndex}:${point.pointIndex}`, point.position])); + root.traverse((object) => { + const objectDataId = object.userData.greasePencilPreviewDataId ?? object.userData.greasePencilPointDataId; + const objectLayerId = object.userData.greasePencilPreviewLayerId ?? object.userData.greasePencilPointLayerId; + const objectFrame = object.userData.greasePencilPreviewFrame ?? object.userData.greasePencilPointFrame; + const strokeIndex = object.userData.greasePencilPreviewStrokeIndex ?? object.userData.greasePencilPointStrokeIndex; + if (objectDataId !== dataId + || objectLayerId !== layerId + || objectFrame !== frame + || !(object instanceof Line || object instanceof Points)) return; + const position = object.geometry.getAttribute("position"); + const base = object.userData.greasePencilPointBasePositions; + const indexMap = object.userData.greasePencilPointIndexMap as number[] | undefined; + if (!(base instanceof Float32Array) || !indexMap || !Number.isSafeInteger(strokeIndex) || position.count * 3 !== base.length) return; + const values = new Float32Array(base); + for (let index = 0; index < indexMap.length; index++) { + const previewPosition = positions.get(`${strokeIndex}:${indexMap[index]}`); + if (previewPosition) values.set(blenderPosition(previewPosition), index * 3); + } + position.array.set(values); + position.needsUpdate = true; + object.geometry.computeBoundingSphere(); + }); +} + export function applyGreasePencilTransform(object: Object3D, node: SceneNodeIR): void { const [x, y, z] = node.transform.translation; const [rx, ry, rz] = node.transform.rotationEuler; diff --git a/web/app/src/three-adapter/nonmesh.ts b/web/app/src/three-adapter/nonmesh.ts index 9b6cc423..e38555a2 100644 --- a/web/app/src/three-adapter/nonmesh.ts +++ b/web/app/src/three-adapter/nonmesh.ts @@ -15,6 +15,7 @@ import { } from "../vendor/three/three.module.js"; import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir"; import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; +import type { CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction"; export type NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT"; export type NonMeshElementSelection = ReadonlyMap>>; @@ -90,6 +91,9 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike = dat const lineGeometry = new BufferGeometry(); lineGeometry.setAttribute("position", new Float32BufferAttribute(handleLines, 3)); const lines = new LineSegments(lineGeometry, new LineBasicMaterial({ color: new Color(0x9a7bff), transparent: true, opacity: 0.65 })); + lines.userData.nonMeshDataId = data.id; + lines.userData.nonMeshHandleLinePointIndexMap = [...handlePointIndices]; + lines.userData.nonMeshHandleBasePositions = new Float32Array(handleLines); group.add(lines); const pointGeometry = new BufferGeometry(); pointGeometry.setAttribute("position", new Float32BufferAttribute(handlePositions, 3)); @@ -97,6 +101,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike = dat handles.userData.nonMeshDataId = data.id; handles.userData.nonMeshPointIndexMap = handleIndexMap; handles.userData.nonMeshPointKindMap = handleKindMap; + handles.userData.nonMeshHandleBasePositions = new Float32Array(handlePositions); group.add(handles); } return group.children.length > 0 ? group : null; @@ -195,3 +200,37 @@ export function applyNonMeshElementSelection(root: Object3D, selection: NonMeshE object.material.needsUpdate = true; }); } + +export function applyCurveHandlePreview(root: Object3D, dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void { + const positionsByIdentity = new Map((handles ?? []).map((handle) => [`${handle.pointIndex}:${handle.side}`, handle.position])); + root.traverse((object) => { + if (object.userData.nonMeshDataId !== dataId || !(object instanceof Points || object instanceof LineSegments)) return; + const position = object.geometry.getAttribute("position"); + const base = object.userData.nonMeshHandleBasePositions; + if (!(base instanceof Float32Array) || position.count * 3 !== base.length) return; + const values = new Float32Array(base); + if (object instanceof Points) { + const indexMap = object.userData.nonMeshPointIndexMap as number[] | undefined; + const kindMap = object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined; + if (!indexMap || !kindMap) return; + for (let index = 0; index < indexMap.length; index++) { + const side = kindMap[index] === "HANDLE_LEFT" ? "LEFT" : kindMap[index] === "HANDLE_RIGHT" ? "RIGHT" : null; + const preview = side ? positionsByIdentity.get(`${indexMap[index]}:${side}`) : undefined; + if (preview) values.set(blenderPosition(...preview), index * 3); + } + } + else { + const indexMap = object.userData.nonMeshHandleLinePointIndexMap as number[] | undefined; + if (!indexMap) return; + for (let index = 0; index < indexMap.length; index++) { + const left = positionsByIdentity.get(`${indexMap[index]}:LEFT`); + const right = positionsByIdentity.get(`${indexMap[index]}:RIGHT`); + if (left) values.set(blenderPosition(...left), index * 12 + 3); + if (right) values.set(blenderPosition(...right), index * 12 + 9); + } + } + position.array.set(values); + position.needsUpdate = true; + object.geometry.computeBoundingSphere(); + }); +} diff --git a/web/app/src/three-adapter/offscreen-viewport-protocol.ts b/web/app/src/three-adapter/offscreen-viewport-protocol.ts index 85269e63..6573702f 100644 --- a/web/app/src/three-adapter/offscreen-viewport-protocol.ts +++ b/web/app/src/three-adapter/offscreen-viewport-protocol.ts @@ -3,14 +3,21 @@ import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web- import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; import type { GPUTextureAsset } from "../../../protocol/render-assets"; 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"; export type OffscreenViewportRequest = | { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number } | { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] } | { 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 }> } + | { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[] } | { 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: "dispose" }; @@ -20,6 +27,9 @@ export type OffscreenViewportResponse = | { 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: "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: "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 5aca20dc..ad9ba267 100644 --- a/web/app/src/three-adapter/offscreen-viewport.ts +++ b/web/app/src/three-adapter/offscreen-viewport.ts @@ -8,12 +8,19 @@ import { nonMeshChunkTransferables } from "../../../protocol/nonmesh-binary"; import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offscreen-viewport-protocol"; import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr"; 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"; export interface ViewportBackend { setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void; setTextureAssets(assets: readonly GPUTextureAsset[]): void; - setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>): void; + setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void; + setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>, greasePencilPoints?: readonly GreasePencilPointRef[]): void; setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): 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; installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void; dispose(): void; } @@ -34,6 +41,7 @@ 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, ): OffscreenViewportRenderer { const existing = sharedBackends.get(canvas); if (existing) { @@ -42,7 +50,7 @@ export function acquireOffscreenViewportRenderer( existing.references += 1; return existing.renderer; } - const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect); + const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect); sharedBackends.set(canvas, { renderer, references: 1 }); return renderer; } @@ -65,6 +73,7 @@ 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 pointer: { id: number; x: number; y: number; moved: boolean } | null = null; private lastSnapshot: SceneSnapshotIR | null = null; private lastGeometryBuffers: MeshGeometryBuffer[] | null = null; @@ -74,11 +83,13 @@ export class OffscreenViewportRenderer implements ViewportBackend { 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, ) { if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable"); this.canvas = canvas; this.onSelect = onSelect; this.onElementSelect = onElementSelect; + this.onGreasePencilPointSelect = onGreasePencilPointSelect; 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(); @@ -147,15 +158,34 @@ export class OffscreenViewportRenderer implements ViewportBackend { this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer); } - setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>): void { + setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void { + const cloned = cloneNanoVDBViewportAssets(assets); + this.worker.postMessage({ type: "volumeAssets", assets: cloned } satisfies OffscreenViewportRequest, nanoVDBViewportAssetTransferables(cloned)); + } + + setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>, greasePencilPoints: readonly GreasePencilPointRef[] = []): 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 } satisfies OffscreenViewportRequest); + this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints] } satisfies OffscreenViewportRequest); } setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void { this.worker.postMessage({ type: "interaction", editMode, selectionMode } 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); + } + + setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void { + this.canvas.dataset.greasePencilPreview = points ? String(points.length) : "0"; + this.worker.postMessage({ type: "greasePencilPointPreview", dataId, layerId, frame, points: points ? points.map((point) => ({ ...point, position: [...point.position] as [number, number, number] })) : null } satisfies OffscreenViewportRequest); + } + + setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void { + this.worker.postMessage({ type: "curveGizmoFrame", dataId, frame } satisfies OffscreenViewportRequest); + } + installLODLevels(): void { // The main-thread renderer remains the adaptive LOD owner; the worker path // renders the source instanced mesh and retains native frustum culling. @@ -204,6 +234,7 @@ 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 === "snapshotStatus") { this.canvas.dataset.nonMeshCount = String(message.nonMeshCount); @@ -218,6 +249,14 @@ export class OffscreenViewportRenderer implements ViewportBackend { this.canvas.dataset.textureBytes = String(message.bytes); this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? ""; } + else if (message.type === "volumeStatus") { + this.canvas.dataset.volumeStatus = message.status; + this.canvas.dataset.volumeCount = String(message.count); + this.canvas.dataset.volumeErrorCode = message.errorCode ?? ""; + } + else if (message.type === "curveGizmoScreenFrame") { + this.canvas.dispatchEvent(new CustomEvent("curve-gizmo-frame", { detail: message.frame })); + } else if (message.type === "error") this.canvas.dataset.rendererError = message.message; } diff --git a/web/app/src/three-adapter/pbr.ts b/web/app/src/three-adapter/pbr.ts index 477c2a81..0f0bb388 100644 --- a/web/app/src/three-adapter/pbr.ts +++ b/web/app/src/three-adapter/pbr.ts @@ -72,8 +72,30 @@ export function blenderLightIntensity(definition: LightIR): number { return Math.max(0, definition.energy) * 2 ** clamp(definition.exposure, -20, 20, 0) / 10; } +function blackbodySrgb(temperature: number): [number, number, number] { + const value = clamp(temperature, 800, 20_000, 6500) / 100; + const red = value <= 66 ? 255 : 329.698727446 * (value - 60) ** -0.1332047592; + const green = value <= 66 ? 99.4708025861 * Math.log(value) - 161.1195681661 : 288.1221695283 * (value - 60) ** -0.0755148492; + const blue = value >= 66 ? 255 : value <= 19 ? 0 : 138.5177312231 * Math.log(value - 10) - 305.0447927307; + return [red, green, blue].map((component) => clamp(component / 255, 0, 1, 0)) as [number, number, number]; +} + +function srgbToLinear(value: number): number { + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +/** Returns a bounded linear-RGB light color with Blender's 6500 K default treated as neutral. */ +export function blenderLightColor(definition: LightIR): [number, number, number] { + if (!definition.useTemperature) return [...definition.color]; + const neutral = blackbodySrgb(6500); + const blackbody = blackbodySrgb(definition.temperature ?? 6500).map((component, index) => component / neutral[index]); + const peak = Math.max(1, ...blackbody); + const linear = blackbody.map((component) => srgbToLinear(component / peak)); + return definition.color.map((component, index) => clamp(component, 0, 1, 0) * linear[index]) as [number, number, number]; +} + export function createPBRLight(definition: LightIR): Light { - const color = new Color().setRGB(...definition.color); + const color = new Color().setRGB(...blenderLightColor(definition)); const intensity = blenderLightIntensity(definition); const light = definition.lightType === 1 ? new DirectionalLight(color, intensity) : definition.lightType === 2 ? new SpotLight(color, intensity, 0, definition.spotAngle, definition.spotBlend, 2) : diff --git a/web/app/src/three-adapter/viewport.ts b/web/app/src/three-adapter/viewport.ts index 83d1d0ff..b99ab020 100644 --- a/web/app/src/three-adapter/viewport.ts +++ b/web/app/src/three-adapter/viewport.ts @@ -8,6 +8,7 @@ import { InstancedMesh, Matrix4, Mesh, + MeshBasicMaterial, HemisphereLight, MeshPhysicalMaterial, Raycaster, @@ -24,7 +25,7 @@ import { } from "../vendor/three/three.module.js"; import { OrbitControls } from "../vendor/three/addons/controls/OrbitControls.js"; import type { MaterialIR, SceneSnapshotIR } from "../../../protocol/scene-ir"; -import { applySceneDelta, type SceneDelta } from "../../../protocol/scene-delta"; +import { applySceneDelta, sceneDeltaRequiresRendererRebuild, type SceneDelta } from "../../../protocol/scene-delta"; import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine"; import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod"; import { @@ -40,9 +41,21 @@ import { import { GPUTextureStore } from "./texture-assets"; import type { GPUTextureAsset } from "../../../protocol/render-assets"; import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets"; -import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh"; +import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh"; import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; -import { applyGreasePencilTransform, createGreasePencilObject } from "./grease-pencil"; +import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction"; +import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport"; +import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; +import { createNanoVDBViewportObject } from "./volume"; +import { + applyGreasePencilPointSelection, + applyGreasePencilPointPreview, + applyGreasePencilTransform, + createGreasePencilObject, + greasePencilPointRef, + type GreasePencilPointRef, + type GreasePencilPointPreview, +} from "./grease-pencil"; export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map { const groups = new Map(); @@ -68,6 +81,7 @@ export class ViewportRenderer { private readonly resizeObserver: ResizeObserver; private animationFrame = 0; private disposed = false; + private contextLost = false; private currentSnapshot: SceneSnapshotIR | null = null; private readonly objectByBlenderId = new Map(); private readonly instanceIndexByBlenderId = new Map(); @@ -77,17 +91,33 @@ export class ViewportRenderer { 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 editMode = false; private selectionMode: MeshElementMode = "FACE"; + private curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null; + private curveGizmoScreenFrame = ""; + private volumeAssets: NanoVDBViewportAssetIR[] = []; + private readonly volumeRenderCache = new Map(); + private volumeRenderGeneration = 0; + private readonly volumeRenderSession: NanoVDBViewportRenderSession; 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, ) { this.canvas = canvas; this.onSelect = onSelect; this.onElementSelect = onElementSelect; + this.onGreasePencilPointSelect = onGreasePencilPointSelect; + this.volumeRenderSession = new NanoVDBViewportRenderSession(() => { + if (this.disposed) return; + this.volumeRenderCache.clear(); + this.canvas.dataset.volumeStatus = "loading"; + void this.refreshVolumes(); + }); + this.raycaster.params.Points.threshold = 0.14; this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true }); configurePBRRenderer(this.renderer); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); @@ -96,6 +126,7 @@ export class ViewportRenderer { this.canvas.dataset.pbrProfile = PBR_PROFILE; this.canvas.dataset.toneMapping = PBR_TONE_MAPPING; this.canvas.dataset.shadowMap = PBR_SHADOW_PROFILE; + 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); @@ -118,6 +149,8 @@ export class ViewportRenderer { this.resizeObserver = new ResizeObserver(() => this.resize()); this.resizeObserver.observe(canvas); this.canvas.addEventListener("click", this.handleClick); + this.canvas.addEventListener("webglcontextlost", this.handleContextLost); + this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored); this.resize(); this.renderLoop(); } @@ -243,6 +276,7 @@ export class ViewportRenderer { this.objectByBlenderId.set(node.id, mesh); } this.coalesceMeshInstances(snapshot); + void this.refreshVolumes(); if (this.importedRoot.children.length > 0) { this.controls.target.set(0, 0, 0); } @@ -256,6 +290,7 @@ export class ViewportRenderer { if (!node.visible || !node.dataId || node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA") continue; const data = dataById.get(node.dataId); if (!data) continue; + if (data.type === "VOLUME") continue; const object = createNonMeshObject(data, nonMeshGeometryBuffers); if (!object) { blockedCount++; @@ -322,11 +357,68 @@ export class ViewportRenderer { }); } + setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void { + this.volumeAssets = [...assets]; + void this.refreshVolumes(); + } + + private async refreshVolumes(): Promise { + const generation = ++this.volumeRenderGeneration; + for (const child of [...this.importedRoot.children]) { + if (!child.userData.nanoVDBVolume) continue; + this.importedRoot.remove(child); + child.traverse((object) => { + const mesh = object as Mesh; + mesh.geometry?.dispose?.(); + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const material of materials) { + if (material instanceof MeshBasicMaterial) material.map?.dispose(); + material?.dispose?.(); + } + }); + } + const snapshot = this.currentSnapshot; + const volumeNodes = snapshot?.nodes.filter((node) => node.visible && node.type === "VOLUME" && node.dataId) ?? []; + if (!snapshot || volumeNodes.length === 0) { + this.canvas.dataset.volumeStatus = "none"; + this.canvas.dataset.volumeCount = "0"; + return; + } + this.canvas.dataset.volumeStatus = "loading"; + this.canvas.dataset.volumeCount = "0"; + try { + let rendered = 0; + for (const node of volumeNodes) { + const asset = this.volumeAssets.find((candidate) => candidate.dataId === node.dataId); + if (!asset) continue; + const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`; + let result = this.volumeRenderCache.get(cacheKey); + if (!result) { + result = await renderNanoVDBViewportAsset(asset, 128, 128, this.volumeRenderSession); + this.volumeRenderCache.set(cacheKey, result); + } + if (generation !== this.volumeRenderGeneration || this.currentSnapshot !== snapshot) return; + const object = createNanoVDBViewportObject(result, node); + this.importedRoot.add(object); + this.objectByBlenderId.set(node.id, object); + rendered++; + } + if (generation !== this.volumeRenderGeneration) return; + this.canvas.dataset.volumeCount = String(rendered); + this.canvas.dataset.volumeStatus = rendered === volumeNodes.length ? "ready" : "blocked"; + this.canvas.dataset.volumeErrorCode = rendered === volumeNodes.length ? "" : "NON_MESH_RESOURCE_MISSING"; + } + catch (error) { + if (generation !== this.volumeRenderGeneration) return; + this.canvas.dataset.volumeStatus = "blocked"; + this.canvas.dataset.volumeErrorCode = error instanceof Error ? error.message.split(":", 1)[0] : "VOLUME_SHADER_UNAVAILABLE"; + } + } + applyDelta(delta: SceneDelta, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void { if (!this.currentSnapshot) throw new Error("Cannot apply a SceneDelta before a snapshot"); const next = applySceneDelta(this.currentSnapshot, delta); - const hasLifecycleChanges = Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length || - delta.meshes || delta.materials || delta.cameras || delta.lights || delta.animations); + const hasLifecycleChanges = sceneDeltaRequiresRendererRebuild(delta); if (hasLifecycleChanges) { this.setSnapshot(next, geometryBuffers, nonMeshGeometryBuffers); return; @@ -353,7 +445,11 @@ export class ViewportRenderer { this.currentSnapshot = next; } - setSelection(objectIds: ReadonlySet, elementSelection?: ReadonlyMap>>): void { + setSelection( + objectIds: ReadonlySet, + elementSelection?: ReadonlyMap>>, + greasePencilPoints: readonly GreasePencilPointRef[] = [], + ): void { const visitedInstances = new Set(); for (const [objectId, object] of this.objectByBlenderId) { if (object instanceof InstancedMesh) { @@ -374,11 +470,30 @@ export class ViewportRenderer { } } applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map()); + applyGreasePencilPointSelection(this.importedRoot, greasePencilPoints); } setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void { this.editMode = editMode; this.selectionMode = selectionMode; + this.importedRoot.traverse((object) => { + if (typeof object.userData.greasePencilPointDataId === "string") object.visible = editMode; + }); + } + + setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void { + applyCurveHandlePreview(this.importedRoot, dataId, handles); + this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0"; + } + + setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void { + applyGreasePencilPointPreview(this.importedRoot, dataId, layerId, frame, points); + this.canvas.dataset.greasePencilPreview = points ? String(points.length) : "0"; + } + + setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void { + this.curveGizmoFrame = dataId && frame ? { dataId, frame } : null; + this.publishCurveGizmoFrame(); } registerLOD(meshId: string, levels: readonly ThreeLODLevel[], radius: number): void { @@ -529,6 +644,7 @@ export class ViewportRenderer { if (mesh.geometry && typeof mesh.geometry.dispose === "function") mesh.geometry.dispose(); const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; for (const material of materials) { + if (mesh.userData.nanoVDBVolume && material instanceof MeshBasicMaterial) material.map?.dispose(); if (material && typeof material.dispose === "function") material.dispose(); } }); @@ -602,8 +718,17 @@ export class ViewportRenderer { -((event.clientY - bounds.top) / bounds.height) * 2 + 1, ); this.raycaster.setFromCamera(this.pointer, this.camera); - const hit = this.raycaster.intersectObjects(this.importedRoot.children, true) - .find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds)); + const hits = this.raycaster.intersectObjects(this.importedRoot.children, true); + const greasePencilHit = this.editMode + ? hits.find((intersection) => intersection.index !== undefined && greasePencilPointRef(intersection.object, intersection.index) !== null) + : undefined; + if (greasePencilHit?.index !== undefined) { + const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index); + if (point) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey); + return; + } + const preferredNonMeshHit = this.editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined; + const hit = preferredNonMeshHit ?? hits.find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds)); if (!hit) return; const additive = event.shiftKey || event.ctrlKey || event.metaKey; const nonMeshDataId = hit.object.userData.nonMeshDataId; @@ -611,7 +736,9 @@ export class ViewportRenderer { const indexMap = hit.object.userData.nonMeshPointIndexMap as number[] | undefined; const pointIndex = indexMap?.[hit.index] ?? Math.max(0, Math.floor(hit.object.userData.nonMeshPointOffset ?? 0) + hit.index); const kindMap = hit.object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined; - this.onElementSelect?.(nonMeshDataId, "VERT", pointIndex, additive, kindMap?.[hit.index] ?? "CONTROL_POINT"); + const kind = kindMap?.[hit.index] ?? "CONTROL_POINT"; + this.canvas.dataset.nonMeshLastPick = `${nonMeshDataId}:${kind}:${pointIndex}`; + this.onElementSelect?.(nonMeshDataId, "VERT", pointIndex, additive, kind); return; } const meshId = hit.object.userData.meshId; @@ -662,20 +789,74 @@ export class ViewportRenderer { private renderLoop = (): void => { if (this.disposed) return; - this.controls.update(); - this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight)); - this.renderer.render(this.scene, this.camera); + if (!this.contextLost) { + this.controls.update(); + this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight)); + this.renderer.render(this.scene, this.camera); + this.publishCurveGizmoFrame(); + if (this.canvas.dataset.deviceStatus === "restoring") { + this.canvas.dataset.deviceStatus = "ready"; + this.canvas.dispatchEvent(new CustomEvent("viewport-device-restored")); + } + } this.animationFrame = window.requestAnimationFrame(this.renderLoop); }; + private handleContextLost = (event: Event): void => { + event.preventDefault(); + this.contextLost = true; + this.canvas.dataset.deviceStatus = "lost"; + }; + + private handleContextRestored = (): void => { + this.contextLost = false; + this.canvas.dataset.deviceStatus = "restoring"; + configurePBRRenderer(this.renderer); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + this.renderer.setClearColor(new Color("#25272b")); + this.resize(); + this.volumeRenderCache.clear(); + void this.refreshVolumes(); + }; + + private publishCurveGizmoFrame(): void { + const active = this.curveGizmoFrame; + let frame: CurveGizmoScreenFrameIR | null = null; + const node = active ? this.currentSnapshot?.nodes.find((candidate) => candidate.dataId === active.dataId && candidate.id === this.currentSnapshot?.activeObjectId) : undefined; + const object = node ? this.objectByBlenderId.get(node.id) : undefined; + if (active && object) { + object.updateWorldMatrix(true, false); + this.camera.updateMatrixWorld(true); + const project = (value: readonly number[]): Vector3 => new Vector3(value[0], value[2], -value[1]).applyMatrix4(object.matrixWorld).project(this.camera); + const origin = project(active.frame.origin); + const axes = active.frame.axes.map((axis) => { + const endpoint = project([active.frame.origin[0] + axis[0], active.frame.origin[1] + axis[1], active.frame.origin[2] + axis[2]]); + const x = endpoint.x - origin.x; + const y = origin.y - endpoint.y; + const magnitude = Math.hypot(x, y); + return magnitude > 1e-8 ? [x / magnitude, y / magnitude] as [number, number] : [0, 0] as [number, number]; + }) as CurveGizmoScreenFrameIR["axes"]; + frame = { origin: [(origin.x + 1) / 2, (1 - origin.y) / 2], axes }; + } + const serialized = JSON.stringify(frame); + if (serialized === this.curveGizmoScreenFrame) return; + this.curveGizmoScreenFrame = serialized; + this.canvas.dispatchEvent(new CustomEvent("curve-gizmo-frame", { detail: frame })); + } + dispose(): void { this.disposed = true; window.cancelAnimationFrame(this.animationFrame); this.resizeObserver.disconnect(); this.canvas.removeEventListener("click", this.handleClick); + this.canvas.removeEventListener("webglcontextlost", this.handleContextLost); + this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored); this.controls.dispose(); this.lodAdapter.clear(); this.clearImportedScene(); + this.volumeRenderGeneration++; + this.volumeRenderCache.clear(); + this.volumeRenderSession.dispose(); this.textureStore.dispose(); this.renderer.dispose(); } diff --git a/web/app/src/three-adapter/volume.ts b/web/app/src/three-adapter/volume.ts new file mode 100644 index 00000000..c1d6f3a7 --- /dev/null +++ b/web/app/src/three-adapter/volume.ts @@ -0,0 +1,43 @@ +import { + BufferGeometry, + DataTexture, + DoubleSide, + Float32BufferAttribute, + Mesh, + MeshBasicMaterial, + RGBAFormat, + Uint32BufferAttribute, + UnsignedByteType, +} from "../vendor/three/three.module.js"; +import type { SceneNodeIR } from "../../../protocol/scene-ir"; +import type { NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport"; +import { applyNonMeshTransform } from "./nonmesh"; + +export function createNanoVDBViewportObject(result: NanoVDBViewportRenderResultIR, node: SceneNodeIR): Mesh { + const { min, max } = result.grid.worldBounds; + const z = (min[2] + max[2]) / 2; + const positions = [ + min[0], z, -min[1], + max[0], z, -min[1], + max[0], z, -max[1], + min[0], z, -max[1], + ]; + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 1], 2)); + geometry.setIndex(new Uint32BufferAttribute([0, 1, 2, 0, 2, 3], 1)); + const texture = new DataTexture(result.pixels, result.width, result.height, RGBAFormat, UnsignedByteType); + texture.needsUpdate = true; + const material = new MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false, side: DoubleSide, toneMapped: false }); + const mesh = new Mesh(geometry, material); + mesh.name = `${node.name} (NanoVDB)`; + mesh.renderOrder = 4; + mesh.userData.sceneNodeId = node.id; + mesh.userData.blenderId = node.id; + mesh.userData.nonMeshDataId = result.dataId; + mesh.userData.nanoVDBVolume = true; + mesh.userData.nanoVDBGrid = result.grid.name; + mesh.userData.nanoVDBImageSize = [result.width, result.height]; + applyNonMeshTransform(mesh, node); + return mesh; +} diff --git a/web/app/src/tracking/MaskSelection.ts b/web/app/src/tracking/MaskSelection.ts new file mode 100644 index 00000000..7a27ba16 --- /dev/null +++ b/web/app/src/tracking/MaskSelection.ts @@ -0,0 +1,8 @@ +export { + raycastMaskProject, + selectMaskPointsInBounds, +} from "../../../protocol/tracking-mask"; +export type { + MaskPointSelectionIR, + MaskRaycastHitIR, +} from "../../../protocol/tracking-mask"; diff --git a/web/app/src/types/webgpu.d.ts b/web/app/src/types/webgpu.d.ts new file mode 100644 index 00000000..e8f6a5b9 --- /dev/null +++ b/web/app/src/types/webgpu.d.ts @@ -0,0 +1,46 @@ +/* Minimal native WebGPU declarations used by the bounded NanoVDB renderer. */ +interface GPUBuffer { + destroy(): void; + getMappedRange(): ArrayBuffer; + unmap(): void; + mapAsync(mode: number): Promise; +} +interface GPUAdapter { + limits: { maxStorageBufferBindingSize: number; maxBufferSize: number }; + requestDevice(options?: { requiredLimits?: Record }): Promise; +} +interface GPUQueue { writeBuffer(buffer: GPUBuffer, offset: number, data: ArrayBuffer | ArrayBufferView): void; submit(commands: Array): void } +type GPUCommandBuffer = object; +interface GPUComputePassEncoder { + setPipeline(pipeline: GPUComputePipeline): void; + setBindGroup(index: number, bindGroup: GPUBindGroup): void; + dispatchWorkgroups(x: number, y?: number, z?: number): void; + end(): void; +} +interface GPUCommandEncoder { + beginComputePass(): GPUComputePassEncoder; + copyBufferToBuffer(source: GPUBuffer, sourceOffset: number, destination: GPUBuffer, destinationOffset: number, size: number): void; + finish(): GPUCommandBuffer; +} +type GPUShaderModule = object; +interface GPUComputePipeline { + getBindGroupLayout(index: number): GPUBindGroupLayout; +} +type GPUBindGroupLayout = object; +type GPUBindGroup = object; +interface GPUDevice { + limits: { maxStorageBufferBindingSize: number; maxBufferSize: number }; + queue: GPUQueue; + lost: Promise<{ reason?: string; message: string }>; + createBuffer(descriptor: { label?: string; size: number; usage: number; mappedAtCreation?: boolean }): GPUBuffer; + createShaderModule(descriptor: { label?: string; code: string }): GPUShaderModule; + createComputePipeline(descriptor: { layout: "auto"; compute: { module: GPUShaderModule; entryPoint: string } }): GPUComputePipeline; + createBindGroup(descriptor: { layout: GPUBindGroupLayout; entries: Array<{ binding: number; resource: { buffer: GPUBuffer } }> }): GPUBindGroup; + createCommandEncoder(): GPUCommandEncoder; + pushErrorScope(filter: string): void; + popErrorScope(): Promise<{ message?: string } | null>; + destroy(): void; +} +declare const GPUBufferUsage: { STORAGE: number; COPY_DST: number; COPY_SRC: number; MAP_READ: number; UNIFORM: number }; +declare const GPUMapMode: { READ: number }; +interface Navigator { gpu?: { requestAdapter(options?: { powerPreference?: string }): Promise } } diff --git a/web/app/src/vendor/blender/web_engine.wasm b/web/app/src/vendor/blender/web_engine.wasm index ef822d47..8ec8efc4 100755 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/incremental-sha256.ts b/web/app/src/volume/incremental-sha256.ts new file mode 100644 index 00000000..cb3c33e6 --- /dev/null +++ b/web/app/src/volume/incremental-sha256.ts @@ -0,0 +1,88 @@ +const K = new Uint32Array([ + 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, +]); + +function rotate(value: number, amount: number): number { + return (value >>> amount) | (value << (32 - amount)); +} + +export class IncrementalSha256 { + private readonly state = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + private readonly block = new Uint8Array(64); + private blockLength = 0; + private bytes = 0; + private finished = false; + + update(value: ArrayBuffer | Uint8Array): this { + if (this.finished) throw new Error("SHA-256 digest is already finalized"); + const data = value instanceof Uint8Array ? value : new Uint8Array(value); + this.bytes += data.byteLength; + let offset = 0; + while (offset < data.byteLength) { + const length = Math.min(64 - this.blockLength, data.byteLength - offset); + this.block.set(data.subarray(offset, offset + length), this.blockLength); + this.blockLength += length; + offset += length; + if (this.blockLength === 64) { + this.compress(this.block); + this.blockLength = 0; + } + } + return this; + } + + hex(): string { + if (!this.finished) { + const bitLength = this.bytes * 8; + this.block[this.blockLength++] = 0x80; + if (this.blockLength > 56) { + this.block.fill(0, this.blockLength); + this.compress(this.block); + this.blockLength = 0; + } + this.block.fill(0, this.blockLength, 56); + const view = new DataView(this.block.buffer); + view.setUint32(56, Math.floor(bitLength / 0x1_0000_0000), false); + view.setUint32(60, bitLength >>> 0, false); + this.compress(this.block); + this.finished = true; + } + return Array.from(this.state, (word) => word.toString(16).padStart(8, "0")).join(""); + } + + private compress(block: Uint8Array): void { + const words = new Uint32Array(64); + const view = new DataView(block.buffer, block.byteOffset, 64); + for (let index = 0; index < 16; index++) words[index] = view.getUint32(index * 4, false); + for (let index = 16; index < 64; index++) { + const s0 = rotate(words[index - 15], 7) ^ rotate(words[index - 15], 18) ^ (words[index - 15] >>> 3); + const s1 = rotate(words[index - 2], 17) ^ rotate(words[index - 2], 19) ^ (words[index - 2] >>> 10); + words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, h] = this.state; + for (let index = 0; index < 64; index++) { + const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25); + const choose = (e & f) ^ (~e & g); + const t1 = (h + s1 + choose + K[index] + words[index]) >>> 0; + const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const t2 = (s0 + majority) >>> 0; + h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; + } + this.state[0] = (this.state[0] + a) >>> 0; + this.state[1] = (this.state[1] + b) >>> 0; + this.state[2] = (this.state[2] + c) >>> 0; + this.state[3] = (this.state[3] + d) >>> 0; + this.state[4] = (this.state[4] + e) >>> 0; + this.state[5] = (this.state[5] + f) >>> 0; + this.state[6] = (this.state[6] + g) >>> 0; + this.state[7] = (this.state[7] + h) >>> 0; + } +} diff --git a/web/app/src/volume/nanovdb-float32.ts b/web/app/src/volume/nanovdb-float32.ts new file mode 100644 index 00000000..35529d3a --- /dev/null +++ b/web/app/src/volume/nanovdb-float32.ts @@ -0,0 +1,99 @@ +import type { NanoVDBFloat32TreeLayoutIR, NanoVDBGridIR } from "../../../protocol/volume-vdb"; + +export interface NanoVDBSampleIR { value: number; active: boolean } + +export class NanoVDBFloat32Sampler { + private readonly view: DataView; + private readonly layout: NanoVDBFloat32TreeLayoutIR; + private readonly root: number; + + constructor(payload: ArrayBuffer, grid: NanoVDBGridIR, layout: NanoVDBFloat32TreeLayoutIR | undefined) { + if (grid.valueType !== "FLOAT32" || !layout) throw new Error("NANOVDB_GRID_UNSUPPORTED: Float32 tree layout is required"); + if (payload.byteLength !== grid.byteLength || payload.byteLength < layout.gridDataBytes + layout.treeDataBytes) throw new Error("NANOVDB_STREAM_INCOMPLETE: Float32 grid payload length mismatch"); + this.view = new DataView(payload); + this.layout = layout; + if (this.u32(0) !== 0x6f6e614e || this.u32(4) !== 0x31424456) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB grid magic mismatch"); + if ((this.u32(16) >>> 21) !== 32) throw new Error("NANOVDB_GRID_UNSUPPORTED: NanoVDB major version is unsupported"); + if (this.u64(32) !== BigInt(payload.byteLength)) throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB GridData size mismatch"); + const tree = layout.gridDataBytes; + const rootOffset = this.i64(tree + layout.treeRootOffsetOffset); + if (rootOffset <= 0n || rootOffset > BigInt(payload.byteLength - layout.rootDataBytes)) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB root offset is outside the payload"); + this.root = tree + Number(rootOffset); + const tableSize = this.u32(this.root + layout.rootTableSizeOffset); + this.range(this.root + layout.rootDataBytes, tableSize * layout.rootTileBytes); + } + + nearest(coord: readonly [number, number, number]): NanoVDBSampleIR { + 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); + let low = 0; + let high = tableSize - 1; + let tile = -1; + while (low <= high) { + const middle = (low + high) >>> 1; + const address = this.root + this.layout.rootDataBytes + middle * this.layout.rootTileBytes; + const candidate = this.u64(address + this.layout.rootTileKeyOffset); + if (candidate === key) { tile = address; break; } + // NanoVDB root tiles are serialized in descending key order. + if (candidate > key) low = middle + 1; + else high = middle - 1; + } + if (tile < 0) return { 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 }; + 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; + 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; + 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) }; + } + + linear(coord: readonly [number, number, number]): NanoVDBSampleIR { + const base = coord.map(Math.floor) as [number, number, number]; + const fraction = coord.map((value, index) => value - base[index]) as [number, number, number]; + let value = 0; + let active = false; + for (let x = 0; x < 2; x++) for (let y = 0; y < 2; y++) for (let z = 0; z < 2; z++) { + const sample = this.nearest([base[0] + x, base[1] + y, base[2] + z]); + const weight = (x ? fraction[0] : 1 - fraction[0]) * (y ? fraction[1] : 1 - fraction[1]) * (z ? fraction[2] : 1 - fraction[2]); + value += sample.value * weight; + active ||= sample.active; + } + return { value, active }; + } + + private internal(node: number, index: number, valueMaskOffset: number, childMaskOffset: number, tableOffset: number, childBytes: number): { child: number } | { sample: NanoVDBSampleIR } { + if (!this.mask(node + childMaskOffset, index)) return { sample: { value: this.f32(node + tableOffset + index * 8), active: this.mask(node + valueMaskOffset, index) } }; + return { child: this.child(node, this.i64(node + tableOffset + index * 8), childBytes) }; + } + + private child(parent: number, offset: bigint, bytes: number): number { + if (offset <= 0n || offset > BigInt(this.view.byteLength)) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB child offset is invalid"); + const child = parent + Number(offset); + this.range(child, bytes); + return child; + } + + private rootKey(coord: readonly number[]): bigint { + const x = BigInt(coord[0] >>> 0) >> 12n; + const y = BigInt(coord[1] >>> 0) >> 12n; + const z = BigInt(coord[2] >>> 0) >> 12n; + return z | (y << 21n) | (x << 42n); + } + + private mask(address: number, index: number): boolean { return (this.u32(address + (index >>> 5) * 4) & (1 << (index & 31))) !== 0; } + private u32(address: number): number { this.range(address, 4); return this.view.getUint32(address, true); } + private f32(address: number): number { this.range(address, 4); return this.view.getFloat32(address, true); } + private u64(address: number): bigint { this.range(address, 8); return this.view.getBigUint64(address, true); } + private i64(address: number): bigint { this.range(address, 8); return this.view.getBigInt64(address, true); } + private range(address: number, bytes: number): void { + if (!Number.isSafeInteger(address) || !Number.isSafeInteger(bytes) || address < 0 || bytes < 0 || address > this.view.byteLength - bytes) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB address is outside the grid payload"); + } +} diff --git a/web/app/src/volume/nanovdb-opfs.ts b/web/app/src/volume/nanovdb-opfs.ts new file mode 100644 index 00000000..e53302e9 --- /dev/null +++ b/web/app/src/volume/nanovdb-opfs.ts @@ -0,0 +1,268 @@ +import { + evaluateVDBProjectBinding, + validateNanoVDBBundleManifest, + validateVDBProjectBinding, + verifyNanoVDBChunk, + type NanoVDBBundleManifestIR, + type VDBProjectBindingIR, + type VDBProjectBindingStatusIR, + type VDBProjectReopenContextIR, +} from "../../../protocol/volume-vdb"; +import { validateProjectId, validateSha256 } from "../storage/opfs-files"; +import { IncrementalSha256 } from "./incremental-sha256"; +import { streamNanoVDBChunks, type NanoVDBRangeSource } from "./nanovdb-stream"; + +type OpfsStorage = StorageManager & { getDirectory?: () => Promise }; +type MovableFile = FileSystemFileHandle & { move?: (name: string) => Promise }; +type DirectoryEntries = AsyncIterableIterator<[string, FileSystemHandle]>; + +export interface NanoVDBOPFSCommitResult { + projectId: string; + bundleSha256: string; + bundleByteLength: number; + chunks: number; + deduplicated: boolean; +} + +export interface NanoVDBOPFSOpenResult { + manifest: NanoVDBBundleManifestIR; + binding?: VDBProjectBindingIR; + bindingStatus?: VDBProjectBindingStatusIR; + source: NanoVDBRangeSource; +} + +export async function listVDBProjectBindings(projectId: string, storage?: StorageManager): Promise { + const cache = await rootFor(projectId, storage); + const bindings = await directory(cache, "bindings"); + const result: VDBProjectBindingIR[] = []; + const entries = (bindings as unknown as { entries: () => DirectoryEntries }).entries(); + for await (const [name, handle] of entries) { + if (handle.kind !== "file" || !/^[a-f0-9]{64}\.json$/.test(name)) continue; + try { result.push(validateVDBProjectBinding(await readJson(bindings, name))); } + catch { /* Invalid binding records are ignored and cannot make a bundle discoverable. */ } + } + return result.sort((left, right) => right.committedAt.localeCompare(left.committedAt)); +} + +async function directory(parent: FileSystemDirectoryHandle, name: string, create = true): Promise { + if (!/^[A-Za-z0-9._-]{1,128}$/.test(name) || name === "." || name === "..") throw new Error("NANOVDB_MANIFEST_INVALID: OPFS directory name"); + return parent.getDirectoryHandle(name, { create }); +} + +async function rootFor(projectId: string, storage?: StorageManager): Promise { + validateProjectId(projectId); + const manager = (storage ?? navigator.storage) as OpfsStorage; + if (!manager.getDirectory) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS is unavailable"); + let current = await manager.getDirectory(); + for (const name of ["projects", projectId, "cache", "vdb"]) current = await directory(current, name); + await directory(current, "bindings"); + return current; +} + +async function writeFile(parent: FileSystemDirectoryHandle, name: string, value: ArrayBuffer | string): Promise { + const handle = await parent.getFileHandle(name, { create: true }); + const writer = await handle.createWritable(); + await writer.write(value); + await writer.close(); +} + +async function atomicWrite(parent: FileSystemDirectoryHandle, name: string, value: ArrayBuffer | string): Promise { + const stageName = `${name}.${crypto.randomUUID()}.stage`; + await writeFile(parent, stageName, value); + const stage = await parent.getFileHandle(stageName) as MovableFile; + if (stage.move) await stage.move(name); + else { + const bytes = await (await stage.getFile()).arrayBuffer(); + await writeFile(parent, name, bytes); + await parent.removeEntry(stageName); + } +} + +async function readJson(parent: FileSystemDirectoryHandle, name: string): Promise { + const bytes = await (await (await parent.getFileHandle(name)).getFile()).arrayBuffer(); + return JSON.parse(new TextDecoder().decode(bytes)) as T; +} + +async function remove(parent: FileSystemDirectoryHandle, name: string, recursive = false): Promise { + try { await parent.removeEntry(name, { recursive }); } + catch (error) { if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error; } +} + +function chunkName(index: number): string { + return `${String(index).padStart(5, "0")}.chunk`; +} + +async function digestJson(value: unknown): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + const hash = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function createVDBProjectBinding( + manifestValue: NanoVDBBundleManifestIR, + sourceBlendSha256: string, +): Promise { + const manifest = validateNanoVDBBundleManifest(manifestValue); + validateSha256(sourceBlendSha256); + const manifestSha256 = await digestJson(manifest); + return validateVDBProjectBinding({ + schemaVersion: 1, + projectId: manifest.projectId, + sourceBlendSha256, + sourcePath: manifest.sourcePath, + sourceSha256: manifest.sourceSha256, + conversionRequestSha256: manifest.conversionRequestSha256, + bundleSha256: manifest.bundleSha256, + bundleByteLength: manifest.bundleByteLength, + manifestSha256, + converter: manifest.converter, + shaderSemanticVersion: manifest.gpu.shaderSemanticVersion, + material: manifest.material, + committedAt: new Date().toISOString(), + }); +} + +export async function commitNanoVDBToOPFS( + manifestValue: NanoVDBBundleManifestIR, + source: NanoVDBRangeSource, + signal: AbortSignal, + bindingValue?: VDBProjectBindingIR, + storage?: StorageManager, +): Promise { + const manifest = validateNanoVDBBundleManifest(manifestValue); + const binding = bindingValue ? validateVDBProjectBinding(bindingValue) : undefined; + const manifestSha256 = await digestJson(manifest); + if (binding && (binding.projectId !== manifest.projectId || binding.bundleSha256 !== manifest.bundleSha256 || binding.conversionRequestSha256 !== manifest.conversionRequestSha256 || binding.manifestSha256 !== manifestSha256)) throw new Error("NANOVDB_HASH_MISMATCH: project binding does not match manifest"); + const cache = await rootFor(manifest.projectId, storage); + const bundle = await directory(cache, manifest.bundleSha256); + try { + const existing = validateNanoVDBBundleManifest(await readJson(bundle, "manifest.json")); + if (existing.bundleSha256 === manifest.bundleSha256 && existing.conversionRequestSha256 === manifest.conversionRequestSha256 && await digestJson(existing) === manifestSha256) { + for (const chunk of existing.chunks) { + const data = await (await (await bundle.getFileHandle(chunkName(chunk.index))).getFile()).arrayBuffer(); + await verifyNanoVDBChunk(chunk, data); + } + await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength })); + if (binding) { + const bindings = await directory(cache, "bindings"); + await atomicWrite(bindings, `${binding.conversionRequestSha256}.json`, JSON.stringify(binding)); + } + return { projectId: manifest.projectId, bundleSha256: manifest.bundleSha256, bundleByteLength: manifest.bundleByteLength, chunks: manifest.chunks.length, deduplicated: true }; + } + } + catch { /* An incomplete directory is staging and remains undiscoverable until manifest commit. */ } + + const hasher = new IncrementalSha256(); + const staged: string[] = []; + try { + await streamNanoVDBChunks(manifest, source, async (range, data) => { + if (signal.aborted) throw new DOMException("NanoVDB OPFS commit cancelled", "AbortError"); + hasher.update(data); + const name = `${chunkName(range.chunkIndex)}.${crypto.randomUUID()}.stage`; + staged.push(name); + await writeFile(bundle, name, data); + const written = await (await bundle.getFileHandle(name)).getFile(); + await verifyNanoVDBChunk(manifest.chunks[range.chunkIndex], await written.arrayBuffer()); + }, signal); + if (hasher.hex() !== manifest.bundleSha256) throw new Error("NANOVDB_HASH_MISMATCH: streamed bundle SHA-256 mismatch"); + for (let index = 0; index < staged.length; index++) { + const handle = await bundle.getFileHandle(staged[index]) as MovableFile; + if (handle.move) await handle.move(chunkName(index)); + else { + await writeFile(bundle, chunkName(index), await (await handle.getFile()).arrayBuffer()); + await bundle.removeEntry(staged[index]); + } + } + await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength })); + await atomicWrite(bundle, "manifest.json", JSON.stringify(manifest)); + if (binding) { + const bindings = await directory(cache, "bindings"); + await atomicWrite(bindings, `${binding.conversionRequestSha256}.json`, JSON.stringify(binding)); + } + return { projectId: manifest.projectId, bundleSha256: manifest.bundleSha256, bundleByteLength: manifest.bundleByteLength, chunks: manifest.chunks.length, deduplicated: false }; + } + catch (error) { + await Promise.all(staged.map((name) => remove(bundle, name))); + await remove(bundle, "manifest.json"); + throw error; + } +} + +export async function openNanoVDBFromOPFS( + projectId: string, + bundleSha256: string, + conversionRequestSha256?: string, + reopenContext?: VDBProjectReopenContextIR, + storage?: StorageManager, +): Promise { + validateSha256(bundleSha256); + if (conversionRequestSha256) validateSha256(conversionRequestSha256); + const cache = await rootFor(projectId, storage); + const bundle = await directory(cache, bundleSha256, false); + const manifest = validateNanoVDBBundleManifest(await readJson(bundle, "manifest.json")); + if (manifest.projectId !== projectId || manifest.bundleSha256 !== bundleSha256) throw new Error("NANOVDB_HASH_MISMATCH: OPFS bundle identity mismatch"); + let binding: VDBProjectBindingIR | undefined; + let bindingStatus: VDBProjectBindingStatusIR | undefined; + if (conversionRequestSha256) { + const bindings = await directory(cache, "bindings"); + try { binding = validateVDBProjectBinding(await readJson(bindings, `${conversionRequestSha256}.json`)); } + catch { binding = undefined; } + if (binding && binding.manifestSha256 !== await digestJson(manifest)) throw new Error("NANOVDB_HASH_MISMATCH: OPFS manifest changed after project commit"); + if (reopenContext) bindingStatus = evaluateVDBProjectBinding(binding, reopenContext); + } + await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength })); + const source: NanoVDBRangeSource = async (range, signal) => { + if (signal.aborted) throw new DOMException("NanoVDB OPFS read cancelled", "AbortError"); + const declared = manifest.chunks[range.chunkIndex]; + if (!declared || range.start !== declared.byteOffset || range.endExclusive !== declared.byteOffset + declared.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS range is not a declared chunk"); + const file = await (await bundle.getFileHandle(chunkName(range.chunkIndex))).getFile(); + if (file.size !== declared.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS chunk length mismatch"); + const data = await file.arrayBuffer(); + await verifyNanoVDBChunk(declared, data); + return data; + }; + return { manifest, binding, bindingStatus, source }; +} + +export async function recoverNanoVDBOPFS(projectId: string, storage?: StorageManager): Promise<{ removedStaging: number; removedIncompleteBundles: number }> { + const cache = await rootFor(projectId, storage); + let removedStaging = 0; + let removedIncompleteBundles = 0; + const entries = (cache as unknown as { entries: () => DirectoryEntries }).entries(); + for await (const [name, handle] of entries) { + if (handle.kind !== "directory" || name === "bindings") continue; + if (!/^[a-f0-9]{64}$/.test(name)) { await remove(cache, name, true); removedIncompleteBundles++; continue; } + const bundle = handle as FileSystemDirectoryHandle; + let validManifest: boolean; + try { validManifest = validateNanoVDBBundleManifest(await readJson(bundle, "manifest.json")).bundleSha256 === name; } + catch { validManifest = false; } + if (!validManifest) { await remove(cache, name, true); removedIncompleteBundles++; continue; } + const files = (bundle as unknown as { entries: () => DirectoryEntries }).entries(); + for await (const [fileName] of files) if (fileName.endsWith(".stage")) { await remove(bundle, fileName); removedStaging++; } + } + return { removedStaging, removedIncompleteBundles }; +} + +export async function pruneNanoVDBOPFS(projectId: string, maxBytes: number, storage?: StorageManager): Promise<{ removed: string[]; retainedBytes: number }> { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid OPFS cache budget"); + const cache = await rootFor(projectId, storage); + const bundles: Array<{ name: string; bytes: number; lastAccessAt: string }> = []; + const entries = (cache as unknown as { entries: () => DirectoryEntries }).entries(); + for await (const [name, handle] of entries) { + if (handle.kind !== "directory" || !/^[a-f0-9]{64}$/.test(name)) continue; + try { + const access = await readJson<{ bytes: number; lastAccessAt: string }>(handle as FileSystemDirectoryHandle, "access.json"); + if (Number.isSafeInteger(access.bytes) && access.bytes > 0 && Number.isFinite(Date.parse(access.lastAccessAt))) bundles.push({ name, ...access }); + } + catch { /* Recovery owns incomplete entries. */ } + } + let total = bundles.reduce((sum, item) => sum + item.bytes, 0); + const removed: string[] = []; + for (const item of bundles.sort((left, right) => left.lastAccessAt.localeCompare(right.lastAccessAt))) { + if (total <= maxBytes) break; + await remove(cache, item.name, true); + total -= item.bytes; + removed.push(item.name); + } + return { removed, retainedBytes: total }; +} diff --git a/web/app/src/volume/nanovdb-stream.ts b/web/app/src/volume/nanovdb-stream.ts new file mode 100644 index 00000000..397fcaa6 --- /dev/null +++ b/web/app/src/volume/nanovdb-stream.ts @@ -0,0 +1,190 @@ +import { + planNanoVDBRanges, + validateNanoVDBBundleManifest, + verifyNanoVDBChunk, + type NanoVDBBundleManifestIR, + type NanoVDBRangeIR, +} from "../../../protocol/volume-vdb"; + +export interface NanoVDBStreamProgressIR { + completedChunks: number; + totalChunks: number; + completedBytes: number; + totalBytes: number; +} + +export interface NanoVDBStreamResultIR extends NanoVDBStreamProgressIR { + declaredBundleSha256: string; +} + +export type NanoVDBRangeSource = (range: NanoVDBRangeIR, signal: AbortSignal) => Promise; +export type NanoVDBChunkConsumer = (range: NanoVDBRangeIR, data: ArrayBuffer, signal: AbortSignal) => Promise | void; + +function cancelled(signal: AbortSignal): void { + if (signal.aborted) throw new DOMException("NanoVDB stream cancelled", "AbortError"); +} + +export async function streamNanoVDBChunks( + manifestValue: NanoVDBBundleManifestIR, + source: NanoVDBRangeSource, + consume: NanoVDBChunkConsumer, + signal: AbortSignal, + onProgress?: (progress: NanoVDBStreamProgressIR) => void, +): Promise { + const manifest = validateNanoVDBBundleManifest(manifestValue); + const ranges = planNanoVDBRanges(manifest); + let completedBytes = 0; + for (const range of ranges) { + cancelled(signal); + const data = await source(range, signal); + cancelled(signal); + await verifyNanoVDBChunk(manifest.chunks[range.chunkIndex], data); + cancelled(signal); + await consume(range, data, signal); + completedBytes += data.byteLength; + onProgress?.({ + completedChunks: range.chunkIndex + 1, + totalChunks: ranges.length, + completedBytes, + totalBytes: manifest.bundleByteLength, + }); + } + return { + completedChunks: ranges.length, + totalChunks: ranges.length, + completedBytes, + totalBytes: manifest.bundleByteLength, + declaredBundleSha256: manifest.bundleSha256, + }; +} + +function parseContentRange(value: string | null): { start: number; endInclusive: number; total: number } | undefined { + const match = value?.match(/^bytes (\d+)-(\d+)\/(\d+)$/); + if (!match) return undefined; + const start = Number(match[1]); + const endInclusive = Number(match[2]); + const total = Number(match[3]); + if (![start, endInclusive, total].every(Number.isSafeInteger)) return undefined; + return { start, endInclusive, total }; +} + +export function createHttpNanoVDBRangeSource( + url: string, + expectedBundleBytes: number, + fetcher: typeof fetch = fetch, +): NanoVDBRangeSource { + return createResumableHttpNanoVDBRangeSource(url, expectedBundleBytes, { fetcher, retries: 0, requireStableEtag: false }); +} + +export interface NanoVDBHttpRangeOptions { + fetcher?: typeof fetch; + retries?: number; + retryDelayMs?: number; + requireStableEtag?: boolean; +} + +async function waitForHttpRetry(delayMs: number, attempt: number, signal: AbortSignal): Promise { + if (delayMs === 0) return; + await new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer); + reject(new DOMException("NanoVDB HTTP range cancelled", "AbortError")); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, delayMs * (attempt + 1)); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function protocolFailure(error: unknown): boolean { + return error instanceof Error && /^(?:NANOVDB_|VDB_)/.test(error.message); +} + +export function createResumableHttpNanoVDBRangeSource( + url: string, + expectedBundleBytes: number, + options: NanoVDBHttpRangeOptions = {}, +): NanoVDBRangeSource { + if (!url || !Number.isSafeInteger(expectedBundleBytes) || expectedBundleBytes <= 0) throw new Error("NANOVDB_MANIFEST_INVALID: HTTP range source is invalid"); + const fetcher = options.fetcher ?? fetch; + const retries = options.retries ?? 2; + const retryDelayMs = options.retryDelayMs ?? 25; + if (!Number.isSafeInteger(retries) || retries < 0 || retries > 8 || !Number.isSafeInteger(retryDelayMs) || retryDelayMs < 0 || retryDelayMs > 10_000) { + throw new Error("NANOVDB_MANIFEST_INVALID: HTTP retry policy is invalid"); + } + let etag: string | undefined; + return async (range, signal) => { + if (!Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.endExclusive) || range.start < 0 || range.endExclusive <= range.start || range.endExclusive > expectedBundleBytes) { + throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range is outside the NanoVDB bundle"); + } + const output = new Uint8Array(range.endExclusive - range.start); + let written = 0; + let lastStatus = 0; + let lastFailure = "network interruption"; + for (let attempt = 0; attempt <= retries; attempt++) { + if (signal.aborted) throw new DOMException("NanoVDB HTTP range cancelled", "AbortError"); + const requestStart = range.start + written; + const headers: Record = { Range: `bytes=${requestStart}-${range.endExclusive - 1}` }; + if (etag) headers["If-Range"] = etag; + let response: Response; + try { + response = await fetcher(url, { method: "GET", headers, signal, cache: "no-store" }); + } + catch (error) { + if (signal.aborted || error instanceof DOMException && error.name === "AbortError") throw new DOMException("NanoVDB HTTP range cancelled", "AbortError"); + lastFailure = error instanceof Error ? error.message : String(error); + if (attempt === retries) break; + await waitForHttpRetry(retryDelayMs, attempt, signal); + continue; + } + lastStatus = response.status; + if (response.status === 408 || response.status === 425 || response.status === 429 || response.status >= 500) { + if (attempt === retries) break; + await waitForHttpRetry(retryDelayMs, attempt, signal); + continue; + } + if (response.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: HTTP range request returned ${response.status}, expected 206`); + const responseEtag = response.headers.get("ETag") ?? undefined; + if (etag && responseEtag !== etag) throw new Error("NANOVDB_HASH_MISMATCH: HTTP ETag changed during NanoVDB streaming"); + if (!etag && responseEtag) etag = responseEtag; + if (options.requireStableEtag && !etag) throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP ETag is required for resumable streaming"); + const contentRange = parseContentRange(response.headers.get("Content-Range")); + if (!contentRange || contentRange.start !== requestStart || contentRange.endInclusive !== range.endExclusive - 1 || contentRange.total !== expectedBundleBytes) { + throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP Content-Range does not match the NanoVDB manifest"); + } + if (!response.body) throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response has no body"); + const reader = response.body.getReader(); + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + const value = next.value; + if (written + value.byteLength > output.byteLength) { + throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response exceeds the requested byte length"); + } + output.set(value, written); + written += value.byteLength; + } + } + catch (error) { + if (signal.aborted || error instanceof DOMException && error.name === "AbortError") throw new DOMException("NanoVDB HTTP range cancelled", "AbortError"); + if (protocolFailure(error)) throw error; + if (written === output.byteLength) return output.buffer; + lastFailure = error instanceof Error ? error.message : String(error); + if (attempt === retries) break; + await waitForHttpRetry(retryDelayMs, attempt, signal); + continue; + } + finally { + reader.releaseLock(); + } + if (written !== output.byteLength) { + throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response has an unexpected byte length"); + } + return output.buffer; + } + throw new Error(`NANOVDB_STREAM_INCOMPLETE: HTTP range retry budget exhausted after ${lastStatus ? `status ${lastStatus}` : lastFailure}`); + }; +} diff --git a/web/app/src/volume/nanovdb-viewport.ts b/web/app/src/volume/nanovdb-viewport.ts new file mode 100644 index 00000000..7ccb5055 --- /dev/null +++ b/web/app/src/volume/nanovdb-viewport.ts @@ -0,0 +1,256 @@ +import { + validateNanoVDBBundleManifest, + verifyNanoVDBChunk, + type NanoVDBBundleManifestIR, + type NanoVDBGridIR, + type NanoVDBMaterialIR, + type NanoVDBRangeIR, +} from "../../../protocol/volume-vdb"; +import { + NanoVDBWebGPUDeviceSession, + probeNanoVDBWebGPU, + renderNanoVDBFloat32WebGPU, + uploadNanoVDBFloat32GridPaged, + type NanoVDBWebGPUCapabilityIR, +} from "../render/nanovdb-volume-renderer"; +import { createResumableHttpNanoVDBRangeSource } from "./nanovdb-stream"; +import type { NanoVDBRangeSource } from "./nanovdb-stream"; +import { + commitNanoVDBToOPFS, + createVDBProjectBinding, + listVDBProjectBindings, + openNanoVDBFromOPFS, +} from "./nanovdb-opfs"; + +export interface NanoVDBViewportGridPayloadIR { + name: string; + data: ArrayBuffer; +} + +export interface NanoVDBViewportAssetIR { + dataId: string; + manifest: NanoVDBBundleManifestIR; + grids: NanoVDBViewportGridPayloadIR[]; + material?: NanoVDBMaterialIR; +} + +export interface NanoVDBViewportRenderResultIR { + dataId: string; + grid: NanoVDBGridIR; + material: NanoVDBMaterialIR; + pixels: Uint8Array; + width: number; + height: number; + capability: NanoVDBWebGPUCapabilityIR; +} + +export interface NanoVDBViewportProjectContextIR { + projectId: string; + sourceBlendSha256: string; +} + +function residentBytes(payloadBytes: number, pageBytes: number, maxResidentBytes: number): number { + const pageCount = Math.ceil(payloadBytes / pageBytes); + return Math.min(pageCount, Math.max(1, Math.floor(maxResidentBytes / pageBytes))) * pageBytes; +} + +/** Keeps the WebGPU device alive for a production viewport and rebuilds it after loss. */ +export class NanoVDBViewportRenderSession { + private readonly deviceSession = new NanoVDBWebGPUDeviceSession(); + private readonly removeLossListener: () => void; + private disposed = false; + + constructor(private readonly onDeviceLost?: () => void) { + this.removeLossListener = this.deviceSession.onDeviceLost(() => this.onDeviceLost?.()); + } + + async render(value: NanoVDBViewportAssetIR, width = 128, height = 128): Promise { + if (this.disposed) throw new Error("VOLUME_SHADER_UNAVAILABLE: viewport render session is disposed"); + const asset = validateNanoVDBViewportAsset(value); + const grid = densityGrid(asset.manifest); + const payload = asset.grids.find((candidate) => candidate.name === grid.name)!.data; + const requiredBytes = residentBytes(payload.byteLength, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes); + let device = this.deviceSession.device; + if (!device || this.deviceSession.status !== "ready") device = await this.deviceSession.open(requiredBytes); + for (let attempt = 0; attempt < 2; attempt++) { + const uploaded = uploadNanoVDBFloat32GridPaged(device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes); + try { + const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, grid, asset.material ?? asset.manifest.material, width, height); + return { dataId: asset.dataId, grid, material: asset.material ?? asset.manifest.material, pixels, width, height, capability: { + available: true, + maxStorageBufferBindingSize: Number(device.limits.maxStorageBufferBindingSize), + maxBufferSize: Number(device.limits.maxBufferSize), + } }; + } + catch (error) { + if (this.deviceSession.status !== "lost" || attempt !== 0) throw error; + device = await this.deviceSession.recover(requiredBytes); + } + finally { uploaded.dispose(); } + } + throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU device recovery exhausted"); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.removeLossListener(); + this.deviceSession.dispose(); + } +} + +function densityGrid(manifest: NanoVDBBundleManifestIR): NanoVDBGridIR { + const grid = manifest.grids.find((candidate) => candidate.name === manifest.material.densityGrid); + if (!grid || grid.semantic !== "DENSITY" || grid.valueType !== "FLOAT32") { + throw new Error("NANOVDB_GRID_UNSUPPORTED: production viewport requires a Float32 density grid"); + } + return grid; +} + +async function loadDensityPayload( + manifest: NanoVDBBundleManifestIR, + source: NanoVDBRangeSource, + signal: AbortSignal, +): Promise { + const grid = densityGrid(manifest); + const payload = new Uint8Array(grid.byteLength); + let copiedBytes = 0; + for (const chunk of manifest.chunks) { + const chunkEnd = chunk.byteOffset + chunk.byteLength; + const gridEnd = grid.byteOffset + grid.byteLength; + const overlapStart = Math.max(chunk.byteOffset, grid.byteOffset); + const overlapEnd = Math.min(chunkEnd, gridEnd); + if (overlapEnd <= overlapStart) continue; + const range: NanoVDBRangeIR = { chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunkEnd, sha256: chunk.sha256 }; + const data = await source(range, signal); + await verifyNanoVDBChunk(chunk, data); + const sourceOffset = overlapStart - chunk.byteOffset; + const targetOffset = overlapStart - grid.byteOffset; + const overlapLength = overlapEnd - overlapStart; + payload.set(new Uint8Array(data, sourceOffset, overlapLength), targetOffset); + copiedBytes += overlapLength; + } + if (copiedBytes !== grid.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: density grid ranges are incomplete"); + return payload.buffer; +} + +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); + const grid = densityGrid(manifest); + const payload = value.grids.find((candidate) => candidate.name === grid.name); + if (!payload || payload.data.byteLength !== grid.byteLength) { + throw new Error("NANOVDB_STREAM_INCOMPLETE: viewport density payload does not match the manifest"); + } + return { dataId: value.dataId, manifest, grids: value.grids, material: value.material ?? manifest.material }; +} + +export function cloneNanoVDBViewportAssets(assets: readonly NanoVDBViewportAssetIR[]): NanoVDBViewportAssetIR[] { + return assets.map((asset) => ({ + ...asset, + manifest: structuredClone(asset.manifest), + material: asset.material ? structuredClone(asset.material) : undefined, + grids: asset.grids.map((grid) => ({ name: grid.name, data: grid.data.slice(0) })), + })); +} + +export function nanoVDBViewportAssetTransferables(assets: readonly NanoVDBViewportAssetIR[]): Transferable[] { + return assets.flatMap((asset) => asset.grids.map((grid) => grid.data)); +} + +export async function loadNanoVDBViewportAsset( + dataId: string, + manifestUrl: string, + bundleUrl: string, + signal: AbortSignal, + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher(manifestUrl, { signal, cache: "no-store" }); + if (!response.ok) throw new Error(`NANOVDB_STREAM_INCOMPLETE: manifest request returned ${response.status}`); + const manifest = validateNanoVDBBundleManifest(await response.json() as NanoVDBBundleManifestIR); + const grid = densityGrid(manifest); + const source = createResumableHttpNanoVDBRangeSource(bundleUrl, manifest.bundleByteLength, { fetcher, retries: 2, requireStableEtag: true }); + const payload = await loadDensityPayload(manifest, source, signal); + return validateNanoVDBViewportAsset({ + dataId, + manifest, + grids: [{ name: grid.name, data: payload }], + }); +} + +export async function reopenNanoVDBViewportAssetFromOPFS( + dataId: string, + sourcePath: string, + context: NanoVDBViewportProjectContextIR, + signal: AbortSignal, +): Promise { + const bindings = await listVDBProjectBindings(context.projectId); + const candidates = bindings.filter((binding) => binding.sourcePath === sourcePath); + let lastBlockedCode = "VDB_BINDING_MISSING"; + for (const binding of candidates) { + const opened = await openNanoVDBFromOPFS(context.projectId, binding.bundleSha256, binding.conversionRequestSha256, { + projectId: context.projectId, + sourceBlendSha256: context.sourceBlendSha256, + sourcePath, + sourceSha256: binding.sourceSha256, + converter: binding.converter, + shaderSemanticVersion: "volume-wgsl-v1", + }); + if (opened.bindingStatus?.status !== "READY") { + lastBlockedCode = opened.bindingStatus?.code ?? lastBlockedCode; + continue; + } + const grid = densityGrid(opened.manifest); + return validateNanoVDBViewportAsset({ + dataId, + manifest: opened.manifest, + grids: [{ name: grid.name, data: await loadDensityPayload(opened.manifest, opened.source, signal) }], + }); + } + throw new Error(`${lastBlockedCode}: no current NanoVDB project binding is available`); +} + +export async function loadAndCommitNanoVDBViewportAsset( + dataId: string, + sourcePath: string, + manifestUrl: string, + bundleUrl: string, + context: NanoVDBViewportProjectContextIR, + signal: AbortSignal, + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher(manifestUrl, { signal, cache: "no-store" }); + if (!response.ok) throw new Error(`NANOVDB_STREAM_INCOMPLETE: manifest request returned ${response.status}`); + const received = validateNanoVDBBundleManifest(await response.json() as NanoVDBBundleManifestIR); + if (received.sourcePath !== sourcePath) throw new Error("NANOVDB_HASH_MISMATCH: manifest source path does not match the Volume binding"); + const manifest = validateNanoVDBBundleManifest({ ...received, projectId: context.projectId }); + const source = createResumableHttpNanoVDBRangeSource(bundleUrl, manifest.bundleByteLength, { fetcher, retries: 2, requireStableEtag: true }); + const binding = await createVDBProjectBinding(manifest, context.sourceBlendSha256); + await commitNanoVDBToOPFS(manifest, source, signal, binding); + return reopenNanoVDBViewportAssetFromOPFS(dataId, sourcePath, context, signal); +} + +export async function renderNanoVDBViewportAsset( + value: NanoVDBViewportAssetIR, + width = 128, + height = 128, + session?: NanoVDBViewportRenderSession, +): Promise { + if (session) return session.render(value, width, height); + const asset = validateNanoVDBViewportAsset(value); + const grid = densityGrid(asset.manifest); + const payload = asset.grids.find((candidate) => candidate.name === grid.name)!.data; + const probe = await probeNanoVDBWebGPU(residentBytes(payload.byteLength, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes)); + if (!probe.capability.available || !probe.device) { + throw new Error(`VOLUME_SHADER_UNAVAILABLE: ${probe.capability.reason ?? "WebGPU device unavailable"}`); + } + const uploaded = uploadNanoVDBFloat32GridPaged(probe.device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes); + try { + const pixels = await renderNanoVDBFloat32WebGPU(probe.device, uploaded, grid, asset.material ?? asset.manifest.material, width, height); + return { dataId: asset.dataId, grid, material: asset.material ?? asset.manifest.material, pixels, width, height, capability: probe.capability }; + } + finally { + uploaded.dispose(); + probe.device.destroy(); + } +} diff --git a/web/app/src/volume/volume-material-mapping.ts b/web/app/src/volume/volume-material-mapping.ts new file mode 100644 index 00000000..3864c0d7 --- /dev/null +++ b/web/app/src/volume/volume-material-mapping.ts @@ -0,0 +1,94 @@ +import { + validateNanoVDBBundleManifest, + type NanoVDBBundleManifestIR, + type NanoVDBGridSemantic, + type NanoVDBMaterialIR, +} from "../../../protocol/volume-vdb"; + +export interface PrincipledVolumeMappingInputIR { + densityGrid: string; + densityScale: number; + color?: [number, number, number]; + colorGrid?: string; + temperatureGrid?: string; + temperatureScale?: number; + blackbodyEnabled?: boolean; + emissionGrid?: string; + emissionColor?: [number, number, number]; + emissionScale?: number; + velocityGrid?: string; + anisotropy?: number; + interpolation?: "NEAREST" | "LINEAR"; +} + +export interface VolumeMaterialMappingLossIR { + code: + | "VOLUME_COLOR_GRID_UNSUPPORTED" + | "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED" + | "VOLUME_EMISSION_GRID_UNSUPPORTED" + | "VOLUME_VELOCITY_RENDER_UNSUPPORTED"; + field: "colorGrid" | "temperatureGrid" | "emissionGrid" | "velocityGrid"; + fallback: string; +} + +export interface VolumeMaterialMappingResultIR { + material: NanoVDBMaterialIR; + losses: VolumeMaterialMappingLossIR[]; + supportedSemantics: Array<"DENSITY_GRID" | "CONSTANT_COLOR" | "CONSTANT_EMISSION" | "ANISOTROPY" | "INTERPOLATION">; +} + +function finite(value: number, minimum: number, maximum: number, name: string): number { + if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`NANOVDB_MANIFEST_INVALID: ${name}`); + return value; +} + +function color(value: [number, number, number] | undefined, fallback: [number, number, number], name: string): [number, number, number] { + const result = value ?? fallback; + if (!Array.isArray(result) || result.length !== 3 || result.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 1_000_000)) throw new Error(`NANOVDB_MANIFEST_INVALID: ${name}`); + return [...result]; +} + +function requireGrid(manifest: NanoVDBBundleManifestIR, name: string | undefined, semantic: NanoVDBGridSemantic, field: string): string | undefined { + if (name === undefined) return undefined; + const grid = manifest.grids.find((candidate) => candidate.name === name); + if (!grid || grid.semantic !== semantic) throw new Error(`NANOVDB_MANIFEST_INVALID: ${field} must reference a ${semantic} grid`); + return name; +} + +export function mapPrincipledVolumeToNanoVDB( + sourceManifest: NanoVDBBundleManifestIR, + input: PrincipledVolumeMappingInputIR, +): VolumeMaterialMappingResultIR { + const manifest = validateNanoVDBBundleManifest(sourceManifest); + const densityGrid = requireGrid(manifest, input.densityGrid, "DENSITY", "densityGrid"); + if (!densityGrid) throw new Error("NANOVDB_MANIFEST_INVALID: a density grid is required"); + const colorGrid = requireGrid(manifest, input.colorGrid, "COLOR", "colorGrid"); + const temperatureGrid = requireGrid(manifest, input.temperatureGrid, "TEMPERATURE", "temperatureGrid"); + const emissionGrid = requireGrid(manifest, input.emissionGrid, "EMISSION", "emissionGrid"); + const velocityGrid = requireGrid(manifest, input.velocityGrid, "VELOCITY", "velocityGrid"); + const material: NanoVDBMaterialIR = { + densityGrid, + ...(colorGrid ? { colorGrid } : {}), + ...(temperatureGrid ? { temperatureGrid } : {}), + ...(emissionGrid ? { emissionGrid } : {}), + ...(velocityGrid ? { velocityGrid } : {}), + densityScale: finite(input.densityScale, 0, 1_000_000, "densityScale"), + emissionScale: finite(input.emissionScale ?? 0, 0, 1_000_000, "emissionScale"), + temperatureScale: finite(input.temperatureScale ?? 1, 0, 1_000_000, "temperatureScale"), + anisotropy: finite(input.anisotropy ?? 0, -0.99, 0.99, "anisotropy"), + interpolation: input.interpolation ?? "LINEAR", + color: color(input.color, [0.72, 0.78, 0.86], "color"), + emissionColor: color(input.emissionColor, [1, 1, 1], "emissionColor"), + }; + if (material.interpolation !== "NEAREST" && material.interpolation !== "LINEAR") throw new Error("NANOVDB_MANIFEST_INVALID: interpolation"); + const losses: VolumeMaterialMappingLossIR[] = []; + if (colorGrid) losses.push({ code: "VOLUME_COLOR_GRID_UNSUPPORTED", field: "colorGrid", fallback: "constant color" }); + if (temperatureGrid && input.blackbodyEnabled) losses.push({ code: "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED", field: "temperatureGrid", fallback: "constant emission color" }); + if (emissionGrid) losses.push({ code: "VOLUME_EMISSION_GRID_UNSUPPORTED", field: "emissionGrid", fallback: "constant emission color and scale" }); + if (velocityGrid) losses.push({ code: "VOLUME_VELOCITY_RENDER_UNSUPPORTED", field: "velocityGrid", fallback: "velocity metadata retained without motion rendering" }); + return { + material, + losses, + supportedSemantics: ["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"], + }; +} diff --git a/web/app/src/workers/asset-library-io-test.worker.ts b/web/app/src/workers/asset-library-io-test.worker.ts index 83d737de..dabaedfb 100644 --- a/web/app/src/workers/asset-library-io-test.worker.ts +++ b/web/app/src/workers/asset-library-io-test.worker.ts @@ -1,21 +1,31 @@ -import { ASSET_LIBRARY_BUDGET, assetStorageCapabilities, gateIORequest, gateLibraryMutation, libraryLoadOrder, parseAssetLibraryManifest, parseIORequest, verifyAssetSource } from "../../../protocol/asset-library-io"; +import { ASSET_LIBRARY_BUDGET, assetStorageCapabilities, gateIORequest, gateLibraryMutation, libraryLoadOrder, parseAssetLibraryManifest, parseIORequest, planIOArchiveRanges, verifyAssetPreview, verifyAssetSource } from "../../../protocol/asset-library-io"; const shaA = "a".repeat(64); const shaB = "b".repeat(64); const asset = { id: "asset:1", name: "Cube", kind: "OBJECT", catalogId: "catalog:models", tags: ["model"], author: "Web", license: "CC0-1.0", sourceSha256: shaA, sourcePath: "assets/cube.blend" }; const base = { schemaVersion: 1, revision: 2, catalogs: [{ id: "catalog:root", name: "Root", parentId: null }, { id: "catalog:models", name: "Models", parentId: "catalog:root" }], assets: [asset], libraries: [{ id: "library:a", name: "A", sourcePath: "libraries/a.blend", sourceSha256: shaA, dependencyIds: ["library:b"], readOnly: true }, { id: "library:b", name: "B", sourcePath: "libraries/b.blend", sourceSha256: shaB, dependencyIds: [], readOnly: true }] }; const glbRequest = { format: "GLB", operation: "EXPORT", externalUris: [], archiveEntries: [] }; -self.onmessage = () => { +self.onmessage = async () => { const result: Record = {}; try { const manifest = parseAssetLibraryManifest(base); verifyAssetSource(manifest.assets[0], shaA); result.valid = [manifest.assets[0].license, libraryLoadOrder(manifest)]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); } try { verifyAssetSource(parseAssetLibraryManifest(base).assets[0], shaB); } catch (error) { result.hash = error instanceof Error ? error.message : String(error); } try { parseAssetLibraryManifest({ ...base, assets: [{ ...asset, license: "" }] }); } catch (error) { result.license = error instanceof Error ? error.message : String(error); } try { parseAssetLibraryManifest({ ...base, libraries: [{ ...base.libraries[0], dependencyIds: ["library:a"] }, base.libraries[1]] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); } try { parseIORequest({ ...glbRequest, archiveEntries: [{ path: "safe.bin", compressedBytes: 1, uncompressedBytes: ASSET_LIBRARY_BUDGET.maxCompressionRatio + 1 }] }); } catch (error) { result.archive = error instanceof Error ? error.message : String(error); } + try { parseIORequest({ ...glbRequest, archiveEntries: [{ path: "mesh", compressedBytes: 1, uncompressedBytes: 1 }, { path: "mesh/data.bin", compressedBytes: 1, uncompressedBytes: 1 }] }); } catch (error) { result.archivePath = error instanceof Error ? error.message : String(error); } + try { parseIORequest({ ...glbRequest, byteLength: 1, archiveEntries: [{ path: "a.bin", compressedBytes: 2, uncompressedBytes: 2 }] }); } catch (error) { result.archiveLength = error instanceof Error ? error.message : String(error); } + result.archivePlan = planIOArchiveRanges({ ...glbRequest, byteLength: 10, archiveEntries: [{ path: "z.bin", compressedBytes: 3, uncompressedBytes: 4 }, { path: "a.bin", compressedBytes: 2, uncompressedBytes: 2 }] }); try { parseIORequest({ ...glbRequest, externalUris: ["https://example.com/file.bin"] }); } catch (error) { result.uri = error instanceof Error ? error.message : String(error); } result.glb = gateIORequest(glbRequest).status; result.obj = gateIORequest({ ...glbRequest, format: "OBJ", operation: "IMPORT" }).issues[0]?.code; result.library = gateLibraryMutation("APPEND").status; result.storage = assetStorageCapabilities(); + try { + const png = new ArrayBuffer(24); const bytes = new Uint8Array(png); bytes.set([137, 80, 78, 71, 13, 10, 26, 10]); const view = new DataView(png); view.setUint32(16, 2, false); view.setUint32(20, 3, false); + const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", png)), (value) => value.toString(16).padStart(2, "0")).join(""); + await verifyAssetPreview({ assetId: "preview:1", sha256: digest, mimeType: "image/png", width: 2, height: 3, byteLength: 24 }, png); + result.preview = digest; + try { await verifyAssetPreview({ assetId: "preview:1", sha256: digest, mimeType: "image/png", width: 3, height: 2, byteLength: 24 }, png); } catch (error) { result.previewSize = error instanceof Error ? error.message : String(error); } + } catch (error) { result.preview = error instanceof Error ? error.message : String(error); } self.postMessage(result); }; diff --git a/web/app/src/workers/compositor-test.worker.ts b/web/app/src/workers/compositor-test.worker.ts index a2aabb6d..5d7e62dd 100644 --- a/web/app/src/workers/compositor-test.worker.ts +++ b/web/app/src/workers/compositor-test.worker.ts @@ -1,4 +1,4 @@ -import { executeCompositorGraph, gateCompositorGraph, parseCompositorGraph } from "../../../protocol/compositor"; +import { CompositorFrameCache, executeCompositorGraph, executeCompositorGraphCached, gateCompositorGraph, parseCompositorGraph } from "../../../protocol/compositor"; const node = (id: string, type: string, properties: Record = {}) => ({ id, type, name: id, properties }); const valid = { @@ -26,7 +26,7 @@ const valid = { ], }; -self.onmessage = () => { +self.onmessage = async () => { const result: Record = {}; try { const parsed = parseCompositorGraph(valid); @@ -54,5 +54,13 @@ self.onmessage = () => { catch (error) { result.budget = error instanceof Error ? error.message : String(error); } try { executeCompositorGraph(valid, new Map(), { width: 2, height: 2, cancelled: () => true }); } catch (error) { result.cancelled = error instanceof Error ? error.message : String(error); } + try { + const cache = new CompositorFrameCache(512); + const first = await executeCompositorGraphCached(valid, new Map(), cache, { frame: 1, width: 2, height: 2 }); + first.composite.data[0] = 99; + const second = await executeCompositorGraphCached(valid, new Map(), cache, { frame: 1, width: 2, height: 2 }); + const third = await executeCompositorGraphCached(valid, new Map(), cache, { frame: 2, width: 2, height: 2 }); + result.cache = [first.cacheHit, second.cacheHit, third.cacheHit, first.cacheKey === second.cacheKey, first.cacheKey !== third.cacheKey, second.composite.data[0], cache.size, cache.byteLength]; + } catch (error) { result.cache = error instanceof Error ? error.message : String(error); } self.postMessage(result); }; diff --git a/web/app/src/workers/editor-workflow-test.worker.ts b/web/app/src/workers/editor-workflow-test.worker.ts index 0956b897..f6fed2de 100644 --- a/web/app/src/workers/editor-workflow-test.worker.ts +++ b/web/app/src/workers/editor-workflow-test.worker.ts @@ -1,4 +1,4 @@ -import { applyEditorWorkflowEdit, EDITOR_WORKFLOW_BUDGET, gateEditorOperation, parseEditorWorkflow } from "../../../protocol/editor-workflow"; +import { applyEditorWorkflowEdit, EDITOR_WORKFLOW_BUDGET, gateEditorOperation, keyChordFromKeyboardEvent, parseEditorWorkflow, resolveKeymapCommand } from "../../../protocol/editor-workflow"; const regions = [{ id: "header", kind: "HEADER", visible: true }, { id: "main", kind: "MAIN", visible: true }]; const area = (id: string, editor: string, x: number, y: number, width: number, height: number) => ({ id, editor, regions, rect: { x, y, width, height }, maximized: false }); @@ -6,10 +6,21 @@ const base = { schemaVersion: 1, workspaces: [{ id: "Layout", name: "Layout", ac self.onmessage = () => { const result: Record = {}; - try { const parsed = parseEditorWorkflow(base); const switched = applyEditorWorkflowEdit(parsed, { type: "SWITCH_WORKSPACE", revision: 0, workspaceId: "Animation" }); const selected = applyEditorWorkflowEdit(switched, { type: "SET_SELECTION", revision: 1, selectedIds: ["object:2"], activeObjectId: "object:2" }); result.edit = [selected.context.revision, selected.context.activeEditor, selected.context.selection]; } catch (error) { result.edit = error instanceof Error ? error.message : String(error); } + try { const parsed = parseEditorWorkflow(base); const switched = applyEditorWorkflowEdit(parsed, { type: "SWITCH_WORKSPACE", revision: 0, workspaceId: "Animation" }); const selected = applyEditorWorkflowEdit(switched, { type: "SET_SELECTION", revision: 1, selectedIds: ["object:2"], activeObjectId: "object:2" }); result.edit = [selected.context.revision, selected.context.activeEditor, selected.context.selection]; result.keymap = resolveKeymapCommand(parsed, keyChordFromKeyboardEvent({ key: "x", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })); } catch (error) { result.edit = error instanceof Error ? error.message : String(error); } try { applyEditorWorkflowEdit(base, { type: "SET_ACTIVE_AREA", revision: 8, areaId: "viewport" }); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); } try { parseEditorWorkflow({ ...base, workspaces: [{ ...base.workspaces[0], areas: [area("a", "VIEW_3D", 0, 0, 0.6, 1), area("b", "OUTLINER", 0.5, 0, 0.5, 1)] }] }); } catch (error) { result.overlap = error instanceof Error ? error.message : String(error); } try { parseEditorWorkflow({ ...base, context: { ...base.context, selection: new Array(EDITOR_WORKFLOW_BUDGET.maxSelection + 1).fill("x") } }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); } + try { parseEditorWorkflow({ ...base, keymaps: [...base.keymaps, { id: "key:delete-duplicate", key: "x", modifiers: [], command: "object.delete.other", enabled: true }] }); } catch (error) { result.keymapConflict = error instanceof Error ? error.message : String(error); } + try { + const scoped = parseEditorWorkflow({ ...base, keymaps: [ + { id: "key:view", key: "Q", modifiers: [], command: "view.command", enabled: true, editors: ["VIEW_3D"] }, + { id: "key:timeline", key: "Q", modifiers: [], command: "timeline.command", enabled: true, editors: ["TIMELINE"] }, + ] }); + const chord = { key: "q", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }; + const viewCommand = resolveKeymapCommand(scoped, keyChordFromKeyboardEvent(chord)); + const timeline = applyEditorWorkflowEdit(scoped, { type: "SWITCH_WORKSPACE", revision: 0, workspaceId: "Animation" }); + result.scopedKeymap = [viewCommand, resolveKeymapCommand(timeline, keyChordFromKeyboardEvent(chord))]; + } catch (error) { result.scopedKeymap = error instanceof Error ? error.message : String(error); } result.view = gateEditorOperation("READ_ONLY_VIEW").status; result.writer = gateEditorOperation("WRITER").issues[0]?.code; result.gizmo = gateEditorOperation("GIZMO").issues[0]?.code; self.postMessage(result); }; diff --git a/web/app/src/workers/grease-pencil-viewport-test.worker.ts b/web/app/src/workers/grease-pencil-viewport-test.worker.ts new file mode 100644 index 00000000..257cf4e9 --- /dev/null +++ b/web/app/src/workers/grease-pencil-viewport-test.worker.ts @@ -0,0 +1,71 @@ +import { + Line, + PerspectiveCamera, + Points, + Raycaster, + Vector2, + Vector3, +} from "../vendor/three/three.module.js"; +import { applyGreasePencilPointPreview, applyGreasePencilPointSelection, createGreasePencilObject, greasePencilPointRef } from "../three-adapter/grease-pencil"; +import type { GreasePencilDataIR } from "../../../protocol/grease-pencil"; + +self.onmessage = () => { + const result: Record = {}; + const data: GreasePencilDataIR = { + id: "grease-pencil:Viewport", + name: "Viewport", + geometryStatus: "available", + layerCount: 1, + frameCount: 1, + strokeCount: 1, + pointCount: 3, + layers: [{ id: "grease-pencil-layer:Viewport", name: "Layer", visible: true, locked: false, opacity: 1, frames: [{ frame: 1, drawing: { + 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 }, + ] }], + } }] }], + }; + const object = createGreasePencilObject(data, 1); + if (!object) throw new Error("Grease Pencil viewport object was not created"); + object.updateMatrixWorld(true); + let points: Points | undefined; + let line: Line | undefined; + object.traverse((child) => { + if (!points && child instanceof Points && typeof child.userData.greasePencilPointDataId === "string") points = child; + if (!line && child instanceof Line && typeof child.userData.greasePencilPreviewDataId === "string") line = child; + }); + if (!points || !line) throw new Error("Grease Pencil viewport proxies were not created"); + points.visible = true; + const camera = new PerspectiveCamera(45, 1, 0.01, 100); + camera.position.set(0, 0, 5); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + camera.updateProjectionMatrix(); + const ndc = new Vector3(0, 0, 0).project(camera); + const raycaster = new Raycaster(); + raycaster.params.Points.threshold = 0.14; + raycaster.setFromCamera(new Vector2(ndc.x, ndc.y), camera); + const hit = raycaster.intersectObject(points, true)[0]; + if (!hit || hit.index === undefined) throw new Error("Grease Pencil point raycast did not hit"); + const ref = greasePencilPointRef(hit.object, hit.index); + result.hit = ref; + if (!ref) throw new Error("Grease Pencil point reference was not stable"); + applyGreasePencilPointSelection(object, [ref]); + const colors = points.geometry.getAttribute("color"); + result.selectedColor = colors ? [colors.getX(hit.index), colors.getY(hit.index), colors.getZ(hit.index)] : null; + applyGreasePencilPointPreview(object, data.id, data.layers[0].id, 1, [{ ...ref, position: [0.5, 1, 2] }]); + const previewLine = line.geometry.getAttribute("position"); + const previewPoint = points.geometry.getAttribute("position"); + result.preview = [[previewLine.getX(1), previewLine.getY(1), previewLine.getZ(1)], [previewPoint.getX(1), previewPoint.getY(1), previewPoint.getZ(1)]]; + applyGreasePencilPointPreview(object, data.id, data.layers[0].id, 1, null); + result.restored = [[previewLine.getX(1), previewLine.getY(1), previewLine.getZ(1)], [previewPoint.getX(1), previewPoint.getY(1), previewPoint.getZ(1)]]; + let proxyCount = 0; + object.traverse((child) => { if (typeof child.userData.greasePencilPointDataId === "string") proxyCount++; }); + result.proxyCount = proxyCount; + self.postMessage(result); +}; diff --git a/web/app/src/workers/nonmesh-interaction-test.worker.ts b/web/app/src/workers/nonmesh-interaction-test.worker.ts index f4a4500a..d7f812d3 100644 --- a/web/app/src/workers/nonmesh-interaction-test.worker.ts +++ b/web/app/src/workers/nonmesh-interaction-test.worker.ts @@ -1,11 +1,34 @@ -import { applyCurveGizmoDelta } from "../../../protocol/nonmesh-interaction"; +import { applyCurveGizmoDelta, curveGizmoAxisDelta, deriveCurveHandleGizmoFrame } from "../../../protocol/nonmesh-interaction"; +import { applyCurveHandlePreview, createNonMeshObject } from "../three-adapter/nonmesh"; +import { LineSegments, Points } from "../vendor/three/three.module.js"; self.onmessage = () => { const result: Record = {}; const base = { schemaVersion: 1, dataId: "curve:1", baseRevision: 3, phase: "COMMIT", axis: 0, delta: [0.25, 0, 0], handles: [{ pointIndex: 2, side: "LEFT", position: [1, 2, 3] }] }; + try { const applied = applyCurveGizmoDelta({ ...base, phase: "PREVIEW" }, 3); result.preview = [applied.revision, applied.handles[0].position]; } catch (error) { result.preview = error instanceof Error ? error.message : String(error); } try { const applied = applyCurveGizmoDelta(base, 3); result.commit = [applied.revision, applied.handles[0].position]; } catch (error) { result.commit = error instanceof Error ? error.message : String(error); } try { applyCurveGizmoDelta(base, 4); } catch (error) { result.stale = error instanceof Error ? error.message : String(error); } try { applyCurveGizmoDelta({ ...base, handles: [{ ...base.handles[0] }, { ...base.handles[0] }] }, 3); } catch (error) { result.duplicate = error instanceof Error ? error.message : String(error); } try { applyCurveGizmoDelta({ ...base, delta: [0.25, 0.25, 0] }, 3); } catch (error) { result.axis = error instanceof Error ? error.message : String(error); } + try { + const frame = deriveCurveHandleGizmoFrame([0, 0, 0, 1, 0, 0], [{ pointIndex: 0, side: "LEFT", position: [0, 1, 0] }]); + const delta = curveGizmoAxisDelta(frame, 0, 0.25); + const local = applyCurveGizmoDelta({ ...base, axisVector: frame.axes[0], delta, handles: [{ pointIndex: 0, side: "LEFT", position: [0, 1, 0] }] }, 3); + result.localFrame = [frame.origin, frame.axes, local.handles[0].position]; + } catch (error) { result.localFrame = error instanceof Error ? error.message : String(error); } + try { + const object = createNonMeshObject({ id: "curve:1", name: "Curve", type: "CURVE", geometryStatus: "available", pointCount: 2, splineCount: 1, controlPoints: [0, 0, 0, 1, 0, 0], splineOffsets: [0, 2], handlePointIndices: [0, 1], handlePoints: [-0.5, 0, 0, 0.5, 0, 0, 0.5, 0, 0, 1.5, 0, 0] }); + if (!object) throw new Error("Curve preview object was not created"); + let points: Points | undefined; let lines: LineSegments | undefined; + object.traverse((child) => { if (child instanceof Points && child.userData.nonMeshHandleBasePositions) points = child; if (child instanceof LineSegments && child.userData.nonMeshHandleBasePositions) lines = child; }); + if (!points || !lines) throw new Error("Curve handle proxies were not created"); + const preview = [{ pointIndex: 0, side: "LEFT" as const, position: [-0.25, 0, 0] as [number, number, number] }]; + applyCurveHandlePreview(object, "curve:1", preview); + const first = [points.geometry.getAttribute("position").getX(0), lines.geometry.getAttribute("position").getX(1)]; + applyCurveHandlePreview(object, "curve:1", preview); + const repeated = points.geometry.getAttribute("position").getX(0); + applyCurveHandlePreview(object, "curve:1", null); + result.rendererPreview = [first, repeated, points.geometry.getAttribute("position").getX(0)]; + } catch (error) { result.rendererPreview = error instanceof Error ? error.message : String(error); } self.postMessage(result); }; diff --git a/web/app/src/workers/paint-schema-test.worker.ts b/web/app/src/workers/paint-schema-test.worker.ts index 476e656f..ca84784a 100644 --- a/web/app/src/workers/paint-schema-test.worker.ts +++ b/web/app/src/workers/paint-schema-test.worker.ts @@ -1,4 +1,4 @@ -import { PAINT_BUDGET, applyUdimTilePatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch } from "../../../protocol/paint"; +import { PAINT_BUDGET, applyUdimTilePatch, buildPaintBrushSpatialIndex, composePaintColorPatch, composePaintWeightPatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch, queryPaintBrushSpatialIndex } from "../../../protocol/paint"; const base = { schemaVersion: 1, @@ -25,6 +25,27 @@ self.onmessage = async () => { { index: 4, position: [3, 0, 0], normal: [0, 0, 1] }, ], [0, 0, 0], 2, 0.8, { frontFaceOnly: true, viewDirection: [0, 0, -1] }); } catch (error) { result.brush = error instanceof Error ? error.message : String(error); } + try { + const index = buildPaintBrushSpatialIndex(Array.from({ length: 1_000 }, (_, vertex) => ({ index: vertex, position: [vertex % 100, Math.floor(vertex / 100), 0], normal: [0, 0, 1] })), 1); + const query = queryPaintBrushSpatialIndex(index, [50, 5, 0], 1.1, 1, { frontFaceOnly: true, viewDirection: [0, 0, -1], visibleVertexIndices: [449, 450, 451, 549, 550, 551, 649, 650, 651], requireVisibility: true }); + result.spatialBrush = [query.candidateCount, query.visitedCellCount, query.weights.map((entry) => entry.index)]; + result.selectedMasked = queryPaintBrushSpatialIndex(index, [50, 5, 0], 1.1, 1, { selectedVertexIndices: [450, 550, 650], requireSelection: true, maskWeights: [{ index: 450, weight: 0.25 }, { index: 550, weight: 0.5 }, { index: 650, weight: 0 }] }).weights; + try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { requireVisibility: true }); } catch (error) { result.spatialVisibility = error instanceof Error ? error.message : String(error); } + try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { requireSelection: true }); } catch (error) { result.spatialSelection = error instanceof Error ? error.message : String(error); } + try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { selectedVertexIndices: [450, 450] }); } catch (error) { result.selectionDuplicate = error instanceof Error ? error.message : String(error); } + try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { maskWeights: [{ index: 450, weight: 1.1 }] }); } catch (error) { result.maskInvalid = error instanceof Error ? error.message : String(error); } + try { queryPaintBrushSpatialIndex(index, [50, 5, 0], 1, 1, { selectedVertexIndices: [1001] }); } catch (error) { result.selectionUnknown = error instanceof Error ? error.message : String(error); } + try { queryPaintBrushSpatialIndex({ schemaVersion: 1, cellSize: 1, vertices: [], cells: new Map() }, [0, 0, 0], 1, 1); } catch (error) { result.spatialForgery = error instanceof Error ? error.message : String(error); } + (index.cells as Map).clear(); + result.spatialMutation = queryPaintBrushSpatialIndex(index, [50, 5, 0], 1.1, 1, { visibleVertexIndices: [450, 550], requireVisibility: true }).candidateCount; + } catch (error) { result.spatialBrush = error instanceof Error ? error.message : String(error); } + try { + const brush = [{ index: 2, weight: 0.25 }, { index: 0, weight: 1 }]; + result.weightPatch = composePaintWeightPatch("object:Paint", "Group", 4, 4, [0.2, 0.4, 0.6], brush, 1); + result.colorPatch = composePaintColorPatch(4, 4, [0, 0, 0, 1, 0.5, 0.5, 0.5, 1, 1, 0, 0, 1], brush, [0, 1, 0, 0.5]); + try { composePaintWeightPatch("object:Paint", "Group", 3, 4, [0], [{ index: 0, weight: 1 }], 1); } catch (error) { result.patchRevision = error instanceof Error ? error.message : String(error); } + try { composePaintColorPatch(4, 4, [0, 0, 0, 1], [{ index: 4, weight: 1 }], [1, 1, 1, 1]); } catch (error) { result.patchIdentity = error instanceof Error ? error.message : String(error); } + } catch (error) { result.weightPatch = error instanceof Error ? error.message : String(error); } try { const baseTile = new Uint8Array(16); const hash = async (bytes: Uint8Array) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer)), (value) => value.toString(16).padStart(2, "0")).join(""); diff --git a/web/app/src/workers/physics-simulation-test.worker.ts b/web/app/src/workers/physics-simulation-test.worker.ts index b3d576f1..6b10fdfb 100644 --- a/web/app/src/workers/physics-simulation-test.worker.ts +++ b/web/app/src/workers/physics-simulation-test.worker.ts @@ -7,6 +7,7 @@ import { physicsCapabilityInventory, selectPhysicsCacheFrame, } from "../../../protocol/physics-simulation"; +import { applyBrowserTransformCachePreview, BrowserTransformCachePlaybackSession } from "../../../protocol/physics-cache-playback"; const hash = "a".repeat(64); const base = { @@ -30,7 +31,19 @@ const base = { }, }; -self.onmessage = () => { +function browserFrame(frame: number, translation: [number, number, number]): ArrayBuffer { + const bytes = new ArrayBuffer(16 + 72); + const view = new DataView(bytes); + view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, frame, true); view.setUint32(12, 1, true); + const id = new TextEncoder().encode("object:Cloth"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id); + [...translation, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true)); + return bytes; +} + +const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +const scene = { schemaVersion: 1 as const, revision: 3, sceneId: "scene:Physics", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:Cloth", frame: { current: 1, start: 1, end: 10 }, nodes: [{ id: "object:Cloth", name: "Cloth", type: "MESH" as const, parentId: null, dataId: "mesh:Cloth", 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: [] }; + +self.onmessage = async () => { const result: Record = {}; try { const parsed = parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [base] }); @@ -63,16 +76,40 @@ self.onmessage = () => { result.manifest = gatePhysicsExecution("RIGID_BODY", "CACHE_MANIFEST").status; result.familyCount = PHYSICS_FAMILIES.length; try { - const bytes = new ArrayBuffer(16 + 72); + const bytes = browserFrame(7, [1, 2, 3]); const view = new DataView(bytes); - view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, 7, true); view.setUint32(12, 1, true); - const id = new TextEncoder().encode("object:Cloth"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id); - [1, 2, 3, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true)); const decoded = decodeBrowserTransformCacheFrame(bytes); result.browserPlayback = [decoded.frame, decoded.objects[0].objectId, decoded.objects[0].translation]; + const preview = applyBrowserTransformCachePreview(scene, bytes.slice(0), 7); + result.browserPreview = [preview.frame.current, preview.nodes[0].transform.translation, preview.nodes[0].worldMatrix.slice(12, 15)]; + try { applyBrowserTransformCachePreview(preview, bytes.slice(0), 8); } catch (error) { result.browserFrameMismatch = error instanceof Error ? error.message : String(error); } view.setFloat32(48 + 3 * 4, 2, true); try { decodeBrowserTransformCacheFrame(bytes); } catch (error) { result.browserRotation = error instanceof Error ? error.message : String(error); } } catch (error) { result.browserPlayback = error instanceof Error ? error.message : String(error); } + try { + const frames = new Map([[7, browserFrame(7, [1, 0, 0])], [8, browserFrame(8, [2, 0, 0])]]); + const published: number[] = []; + const session = new BrowserTransformCachePlaybackSession(scene, { frameStart: 7, frameEnd: 8, readFrame: async (frame) => frames.get(frame)!.slice(0) }, (preview) => published.push(preview.frame.current)); + const playback = await session.play(); + result.browserSession = [playback.status, playback.appliedFrames, playback.lastFrame, published]; + + let releaseSlow: ((data: ArrayBuffer) => void) | undefined; + const latePublished: number[] = []; + const late = new BrowserTransformCachePlaybackSession(scene, { frameStart: 7, frameEnd: 8, readFrame: (frame) => frame === 7 ? new Promise((resolve) => { releaseSlow = resolve; }) : Promise.resolve(frames.get(frame)!.slice(0)) }, (preview) => latePublished.push(preview.frame.current)); + const superseded = late.seek(7); + const current = late.seek(8); + releaseSlow?.(frames.get(7)!.slice(0)); + result.browserSupersede = [await superseded === null, (await current)?.frame.current, latePublished]; + + let releaseCancelled: ((data: ArrayBuffer) => void) | undefined; + const cancelledPublished: number[] = []; + const cancelled = new BrowserTransformCachePlaybackSession(scene, { frameStart: 7, frameEnd: 7, readFrame: () => new Promise((resolve) => { releaseCancelled = resolve; }) }, (preview) => cancelledPublished.push(preview.frame.current)); + const cancelledRead = cancelled.seek(7); + cancelled.cancel(); + releaseCancelled?.(frames.get(7)!.slice(0)); + result.browserCancel = [await cancelledRead === null, cancelledPublished]; + } + catch (error) { result.browserSession = error instanceof Error ? error.message : String(error); } self.postMessage(result); }; diff --git a/web/app/src/workers/release-gate-test.worker.ts b/web/app/src/workers/release-gate-test.worker.ts index 70269667..e1201ab9 100644 --- a/web/app/src/workers/release-gate-test.worker.ts +++ b/web/app/src/workers/release-gate-test.worker.ts @@ -1,9 +1,9 @@ import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate"; -const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], acceptance: [], dependencies }); +const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], excludedSlices: [], acceptance: [], dependencies }); const evidenceRecord = { id: "fixture", fields: ["runtime.offline", "performance.geometry1M", "faults.malformedBlend", "faults.zipBomb", "provenance.license"], command: "fixture", exitCode: 0, durationMs: 1, output: "fixture passed", artifactSha256: ["a".repeat(64)] }; const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false }, records: [evidenceRecord] }; -const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence }; +const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00.000Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence }; self.onmessage = () => { const result: Record = {}; @@ -13,5 +13,9 @@ self.onmessage = () => { try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, evidence: { ...evidence, browser: { chromium: true } } }); } catch (error) { result.unbound = error instanceof Error ? error.message : String(error); } + try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), excludedSlices: ["A"], blockedSlices: ["A", "B"] }, family("N-016", ["N-015"])] }); } catch (error) { result.excludedOverlap = error instanceof Error ? error.message : String(error); } + try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, fields: ["performance.geometry10M"] }] } }); } catch (error) { result.disabledEvidence = error instanceof Error ? error.message : String(error); } + try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, artifactSha256: [] }] } }); } catch (error) { result.emptyArtifact = error instanceof Error ? error.message : String(error); } + try { parseReleaseManifest({ ...base, generatedAt: "2026-02-31T00:00:00.000Z" }); } catch (error) { result.generatedAt = error instanceof Error ? error.message : String(error); } self.postMessage(result); }; diff --git a/web/app/src/workers/scene-delta-render-test.worker.ts b/web/app/src/workers/scene-delta-render-test.worker.ts new file mode 100644 index 00000000..966af3ef --- /dev/null +++ b/web/app/src/workers/scene-delta-render-test.worker.ts @@ -0,0 +1,34 @@ +import { applySceneDelta, diffSceneSnapshots, parseSceneDelta, sceneDeltaRequiresRendererRebuild } from "../../../protocol/scene-delta"; +import type { SceneSnapshotIR } from "../../../protocol/scene-ir"; + +self.onmessage = () => { + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const before: SceneSnapshotIR = { + schemaVersion: 1, + revision: 3, + sceneId: "scene:Render", + source: { kind: "mock" }, + coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 }, + nodes: [{ id: "object:Camera", name: "Camera", type: "CAMERA", parentId: null, dataId: "camera:Main", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }], + meshes: [], materials: [], cameras: [], lights: [], images: [], animations: [], collections: [], + worlds: [{ id: "world:Render", name: "World", color: [0.1, 0.2, 0.3], exposure: 0 }], + scenes: [{ id: "scene:Render", name: "Render", worldId: "world:Render", colorManagement: { displayDevice: "sRGB", viewTransform: "AgX", look: "None", exposure: 0, gamma: 1 } }], + activeObjectId: "object:Camera", + frame: { current: 1, start: 1, end: 10 }, + }; + const after: SceneSnapshotIR = { + ...before, + revision: 4, + worlds: [{ ...before.worlds[0], color: [0.8, 0.4, 0.2], exposure: 2 }], + scenes: [{ ...before.scenes[0], colorManagement: { ...before.scenes[0].colorManagement!, viewTransform: "Standard", exposure: 1 } }], + }; + const delta = parseSceneDelta(diffSceneSnapshots(before, after)); + const applied = applySceneDelta(before, delta); + const result: Record = { + collections: [delta.worlds?.updated.length, delta.scenes?.updated.length], + applied: [applied.worlds[0].color, applied.worlds[0].exposure, applied.scenes[0].colorManagement?.viewTransform, applied.scenes[0].colorManagement?.exposure], + rebuild: sceneDeltaRequiresRendererRebuild(delta), + }; + try { parseSceneDelta({ ...delta, worlds: { updated: "not-an-array" } }); } catch (error) { result.invalid = error instanceof Error ? error.message : String(error); } + self.postMessage(result); +}; diff --git a/web/app/src/workers/scripting-platform-test.worker.ts b/web/app/src/workers/scripting-platform-test.worker.ts index 92049e67..827bf68a 100644 --- a/web/app/src/workers/scripting-platform-test.worker.ts +++ b/web/app/src/workers/scripting-platform-test.worker.ts @@ -1,13 +1,23 @@ -import { gateScriptExecution, gateServerScriptJob, parseScriptingManifest, platformCapabilities, SCRIPTING_BUDGET } from "../../../protocol/scripting-platform"; +import { appendScriptExecutionAudit, createScriptExecutionAudit, gateScriptExecution, gateServerScriptJob, parseScriptExecutionAuditLog, parseScriptingManifest, platformCapabilities, SCRIPTING_BUDGET } from "../../../protocol/scripting-platform"; const sha = "a".repeat(64); const signature = "b".repeat(128); const script = { id: "script:clean", name: "Clean", entryPath: "scripts/clean.py", sourceSha256: sha, publisher: "Team", signature, keyId: "key:trusted", permissions: ["READ_MAIN"], dependencies: [], cpuMs: 1000, memoryBytes: 1024 * 1024, wallMs: 5000, network: false, autorun: false, driverExpressions: false, addonInstall: false }; const base = { schemaVersion: 1, scripts: [script] }; -self.onmessage = () => { +self.onmessage = async () => { const result: Record = {}; try { result.valid = parseScriptingManifest(base).scripts[0].entryPath; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); } result.exec = gateScriptExecution(base, "script:clean", new Set(["key:trusted"])).issues[0]?.code; + const audit = await createScriptExecutionAudit(base, "script:clean", new Set(["key:trusted"]), { requestId: "e2e-script-audit", requestedAt: "2026-08-12T12:00:00.000Z" }); + result.audit = [audit.decision, audit.reason, audit.approvedKey, audit.permissions, audit.budget.cpuMs, audit.manifestSha256, audit.requestSha256]; + const secondAudit = await createScriptExecutionAudit(base, "script:clean", new Set(), { requestId: "e2e-script-audit-2", requestedAt: "2026-08-12T12:00:01.000Z" }); + const firstLog = await appendScriptExecutionAudit({ schemaVersion: 1, entries: [] }, audit); + const auditLog = await appendScriptExecutionAudit(firstLog, secondAudit); + const checkedLog = await parseScriptExecutionAuditLog(auditLog); + result.auditLog = [checkedLog.entries.length, checkedLog.entries[0].previousEntrySha256, checkedLog.entries[1].previousEntrySha256, checkedLog.entries[1].entrySha256]; + try { await appendScriptExecutionAudit(auditLog, secondAudit); } catch (error) { result.auditReplay = error instanceof Error ? error.message : String(error); } + try { await parseScriptExecutionAuditLog({ ...auditLog, entries: auditLog.entries.map((entry, index) => index === 0 ? { ...entry, audit: { ...entry.audit, scriptId: "script:tampered" } } : entry) }); } catch (error) { result.auditTamper = error instanceof Error ? error.message : String(error); } + try { await createScriptExecutionAudit(base, "script:clean", new Set(["key:trusted"]), { requestId: "bad-date", requestedAt: "2026-02-31T12:00:00.000Z" }); } catch (error) { result.auditDate = error instanceof Error ? error.message : String(error); } result.server = gateServerScriptJob({ scriptId: "script:clean", sourceSha256: sha, inputBlendSha256: "c".repeat(64), status: "QUEUED" }, base, "c".repeat(64)).issues[0]?.code; try { parseScriptingManifest({ ...base, scripts: [{ ...script, entryPath: "../escape.py" }] }); } catch (error) { result.path = error instanceof Error ? error.message : String(error); } try { parseScriptingManifest({ ...base, scripts: [{ ...script, autorun: true }] }); } catch (error) { result.policy = error instanceof Error ? error.message : String(error); } diff --git a/web/app/src/workers/sequencer-test.worker.ts b/web/app/src/workers/sequencer-test.worker.ts index b4b4effc..9bf7f7b4 100644 --- a/web/app/src/workers/sequencer-test.worker.ts +++ b/web/app/src/workers/sequencer-test.worker.ts @@ -3,6 +3,8 @@ import { applySequencerEdit, gateSequencerCodec, parseSequencerTimeline, + resolveSequencerFrame, + resolveSequencerTransitionFrame, sequencerRuntimeCapabilities, sequencerSourceFrame, } from "../../../protocol/sequencer"; @@ -23,6 +25,7 @@ self.onmessage = () => { const moved = applySequencerEdit(timeline, { type: "MOVE", revision: 3, stripId: movie.id, frameDelta: 5, channel: 3 }); const split = applySequencerEdit(moved, { type: "SPLIT", revision: 4, stripId: movie.id, frame: 20, rightStripId: "strip:MovieRight" }); result.edit = [split.revision, split.strips.map((strip) => [strip.id, strip.frameStart, strip.frameEnd, strip.sourceStart, strip.sourceEnd])]; + result.frame = resolveSequencerFrame(split, 20).map((strip) => [strip.stripId, strip.channel, strip.sourceFrame]); try { applySequencerEdit(timeline, { type: "MOVE", revision: 2, stripId: movie.id, frameDelta: 1 }); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); } } @@ -40,6 +43,15 @@ self.onmessage = () => { } catch (error) { result.budget = error instanceof Error ? error.message : String(error); } result.codec = gateSequencerCodec("video/mp4", new Set()).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 }, + { id: "strip:Cross", name: "Cross", type: "EFFECT", effectType: "CROSS", inputStripIds: ["strip:From", "strip:To"], channel: 3, frameStart: 10, frameEnd: 20, sourceStart: 0, sourceEnd: 10, speed: 1, muted: false, locked: false }, + ] }; + const transition = resolveSequencerTransitionFrame(transitionTimeline, "strip:Cross", 15); + result.transition = [transition.effectType, transition.factor, transition.from.stripId, transition.from.sourceFrame, transition.to.stripId, transition.to.sourceFrame]; + try { resolveSequencerTransitionFrame(transitionTimeline, "strip:Cross", 20); } + catch (error) { result.transitionBoundary = error instanceof Error ? error.message : String(error); } result.runtime = sequencerRuntimeCapabilities(); self.postMessage(result); }; diff --git a/web/app/src/workers/tracking-mask-test.worker.ts b/web/app/src/workers/tracking-mask-test.worker.ts index a86c4b58..368686dd 100644 --- a/web/app/src/workers/tracking-mask-test.worker.ts +++ b/web/app/src/workers/tracking-mask-test.worker.ts @@ -3,6 +3,8 @@ import { applyTrackingMaskEdit, gateTrackingOperation, parseTrackingMaskProject, + raycastMaskProject, + selectMaskPointsInBounds, type MaskPointIR, type TrackingMarkerIR, } from "../../../protocol/tracking-mask"; @@ -18,6 +20,10 @@ self.onmessage = () => { const markerEdit = applyTrackingMaskEdit(parsed, { type: "SET_MARKER", revision: 4, clipId: "clip:1", trackId: "track:1", marker: { ...marker, frame: 10, position: [0.6, 0.4], keyframe: false } }); const pointEdit = applyTrackingMaskEdit(markerEdit, { type: "SET_MASK_POINT", revision: 5, maskId: "mask:1", layerId: "layer:1", splineId: "spline:1", point: { ...point, co: [0.4, 0.6], feather: 0.25 } }); result.edit = [pointEdit.revision, pointEdit.clips[0].tracks[0].markers.map((item) => item.frame), pointEdit.masks[0].layers[0].splines[0].points[0].co]; + result.raycast = raycastMaskProject(pointEdit, [0.4, 0.6], 0.03); + const selection = selectMaskPointsInBounds(pointEdit, [0.1, 0.2], [0.5, 0.7]); + const toggled = selectMaskPointsInBounds(pointEdit, [0.1, 0.2], [0.5, 0.7], selection, "TOGGLE"); + result.marquee = [selection.map((item) => item.pointId), toggled.length]; try { applyTrackingMaskEdit(parsed, { type: "SET_TRACK_SELECTION", revision: 3, clipId: "clip:1", trackId: "track:1", selected: true }); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); } } catch (error) { result.edit = error instanceof Error ? error.message : String(error); } try { parseTrackingMaskProject({ ...base, clips: [{ ...base.clips[0], sourcePath: "../shot.mp4" }] }); } catch (error) { result.path = error instanceof Error ? error.message : String(error); } diff --git a/web/app/src/workers/vdb-fault-test.worker.ts b/web/app/src/workers/vdb-fault-test.worker.ts new file mode 100644 index 00000000..dac00df5 --- /dev/null +++ b/web/app/src/workers/vdb-fault-test.worker.ts @@ -0,0 +1,173 @@ +import { verifyNanoVDBChunk, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; +import { + NanoVDBGpuPageAllocator, + NanoVDBWebGPUDeviceSession, + sampleNanoVDBFloat32WebGPU, + uploadNanoVDBFloat32GridPaged, +} from "../render/nanovdb-volume-renderer"; +import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream"; +import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; + +const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; + +scope.onmessage = (): void => { + void (async () => { + const manifest = await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR; + const report = await (await fetch("/__vdb_fixture__/report", { cache: "no-store" })).json() as { grids: Array<{ name: string; scalarSamples?: Array<{ coord: [number, number, number]; value: number; active: boolean }> }> }; + let attempts = 0; + let retryResponses = 0; + const ifRanges: string[] = []; + const interruptedFetcher: typeof fetch = async (input, init) => { + attempts++; + const headers = new Headers(init?.headers); + ifRanges.push(headers.get("If-Range") ?? ""); + if (retryResponses++ === 0) return new Response("temporary interruption", { status: 503 }); + return fetch(input, init); + }; + const rangeSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { + fetcher: interruptedFetcher, + retries: 2, + retryDelayMs: 0, + requireStableEtag: true, + }); + const first = await rangeSource({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal); + await verifyNanoVDBChunk(manifest.chunks[0], first); + const second = await rangeSource({ chunkIndex: 1, start: manifest.chunks[1].byteOffset, endExclusive: manifest.chunks[1].byteOffset + manifest.chunks[1].byteLength, sha256: manifest.chunks[1].sha256 }, new AbortController().signal); + await verifyNanoVDBChunk(manifest.chunks[1], second); + + const resumeRanges: string[] = []; + const resumeIfRanges: string[] = []; + let interruptBody = true; + const interruptedBodyFetcher: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + resumeRanges.push(headers.get("Range") ?? ""); + resumeIfRanges.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, Math.min(4096, bytes.byteLength - 1)); + let emitted = false; + const body = new ReadableStream({ + pull(controller) { + if (!emitted) { + emitted = true; + controller.enqueue(partial); + return; + } + controller.error(new TypeError("injected response-body interruption")); + }, + }); + return new Response(body, { status: response.status, headers: response.headers }); + }; + const resumable = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { + fetcher: interruptedBodyFetcher, + retries: 2, + retryDelayMs: 0, + requireStableEtag: true, + }); + const resumed = await resumable({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal); + await verifyNanoVDBChunk(manifest.chunks[0], resumed); + + let shortResponse = ""; + try { + const shortFetcher: typeof fetch = async (input, init) => { + const response = await fetch(input, init); + const bytes = new Uint8Array(await response.arrayBuffer()); + return new Response(bytes.slice(0, Math.max(1, Math.floor(bytes.byteLength / 2))), { status: response.status, headers: response.headers }); + }; + const shortSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { fetcher: shortFetcher, retries: 2, retryDelayMs: 0, requireStableEtag: true }); + await shortSource({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal); + } + catch (error) { shortResponse = error instanceof Error ? error.message : String(error); } + + let changedEtag = ""; + try { + let etagResponses = 0; + const changedEtagFetcher: typeof fetch = async (input, init) => { + const response = await fetch(input, init); + if (etagResponses++ === 0) return response; + const headers = new Headers(response.headers); + headers.set("ETag", '"changed-vdb-etag"'); + return new Response(await response.arrayBuffer(), { status: response.status, headers }); + }; + const changedEtagSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { fetcher: changedEtagFetcher, retries: 0, requireStableEtag: true }); + await changedEtagSource({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal); + await changedEtagSource({ chunkIndex: 1, start: manifest.chunks[1].byteOffset, endExclusive: manifest.chunks[1].byteOffset + manifest.chunks[1].byteLength, sha256: manifest.chunks[1].sha256 }, new AbortController().signal); + } + catch (error) { changedEtag = error instanceof Error ? error.message : String(error); } + + let outOfOrderResponse = ""; + try { + const outOfOrderFetcher: typeof fetch = async (input, init) => { + const response = await fetch(input, init); + const bytes = await response.arrayBuffer(); + const headers = new Headers(response.headers); + const requested = new Headers(init?.headers).get("Range")?.match(/^bytes=(\d+)-(\d+)$/); + if (requested) headers.set("Content-Range", `bytes ${Number(requested[1]) + 32}-${requested[2]}/${manifest.bundleByteLength}`); + return new Response(bytes, { status: response.status, headers }); + }; + const outOfOrderSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, { fetcher: outOfOrderFetcher, retries: 0, requireStableEtag: true }); + await outOfOrderSource({ chunkIndex: 0, start: manifest.chunks[0].byteOffset, endExclusive: manifest.chunks[0].byteOffset + manifest.chunks[0].byteLength, sha256: manifest.chunks[0].sha256 }, new AbortController().signal); + } + catch (error) { outOfOrderResponse = error instanceof Error ? error.message : String(error); } + + const asset = await loadNanoVDBViewportAsset("volume:FaultGate", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal); + const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid)!; + const native = report.grids.find((grid) => grid.name === density.name)?.scalarSamples ?? []; + const payload = asset.grids.find((grid) => grid.name === density.name)!.data; + const session = new NanoVDBWebGPUDeviceSession(); + const device = await session.open(payload.byteLength + 256 * 1024); + + const allocator = new NanoVDBGpuPageAllocator(device, 64 * 1024, 128 * 1024); + const page = new Uint8Array(64 * 1024).buffer; + allocator.upload("page-a", page); + allocator.upload("page-b", page); + allocator.touch("page-a"); + allocator.upload("page-c", page); + const lru = allocator.stats(); + allocator.dispose(); + + let oom = ""; + try { + const constrained = uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, 256 * 1024); + if (constrained.residentPageCount < constrained.pageCount) oom = "NANOVDB_GPU_BUDGET_EXCEEDED: resident paging active"; + constrained.dispose(); + } + catch (error) { oom = error instanceof Error ? error.message : String(error); } + + const residentBudget = Math.ceil(payload.byteLength / (256 * 1024)) * 256 * 1024; + const uploaded = uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, residentBudget); + const before = await sampleNanoVDBFloat32WebGPU(device, uploaded, native.map((sample) => sample.coord)); + const paging = { pageCount: uploaded.pageCount, residentPageCount: uploaded.residentPageCount, byteLength: uploaded.byteLength }; + uploaded.dispose(); + const firstGeneration = session.generation; + device.destroy(); + const loss = await session.waitForLoss(); + const recoveredDevice = await session.recover(payload.byteLength + 256 * 1024); + const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget); + const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord)); + recovered.dispose(); + const recoveredGeneration = session.generation; + session.dispose(); + return { + network: { + attempts, + ifRanges, + firstBytes: first.byteLength, + secondBytes: second.byteLength, + resumeRanges, + resumeIfRanges, + resumedBytes: resumed.byteLength, + shortResponse, + changedEtag, + outOfOrderResponse, + }, + lru, + oom, + paging, + deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration }, + 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/vdb-opfs-test.worker.ts b/web/app/src/workers/vdb-opfs-test.worker.ts new file mode 100644 index 00000000..18baae2c --- /dev/null +++ b/web/app/src/workers/vdb-opfs-test.worker.ts @@ -0,0 +1,213 @@ +import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; +import { IncrementalSha256 } from "../volume/incremental-sha256"; +import { + commitNanoVDBToOPFS, + createVDBProjectBinding, + openNanoVDBFromOPFS, + pruneNanoVDBOPFS, + recoverNanoVDBOPFS, +} from "../volume/nanovdb-opfs"; + +const scope = self as unknown as { onmessage: ((event: MessageEvent<{ action: "commit" | "reopen" | "interrupt" | "recoverInterrupted" | "prepareQuota" | "quota" | "verifyQuota"; state?: TestState }>) => void) | null; postMessage: (value: unknown) => void }; +const projectId = "vdb-fixtures"; +const sourceBlendSha256 = "b".repeat(64); +const cancelConverter = { target: "SERVER" as const, blenderVersion: "5.2.0", openVDBVersion: "13.0.0", nanoVDBVersion: "32.9.0", executableSha256: "c".repeat(64) }; + +interface TestState { bundleSha256: string; conversionRequestSha256: string; sourceSha256: string } + +function bytes(seed: number): Uint8Array { + return Uint8Array.from({ length: 128 * 1024 }, (_value, index) => (index * 17 + seed) & 0xff); +} + +function buffer(value: Uint8Array): ArrayBuffer { + return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer; +} + +async function hash(value: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", buffer(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function bundleDirectory(bundleSha256: string): Promise { + let directory = await navigator.storage.getDirectory(); + for (const name of ["projects", projectId, "cache", "vdb", bundleSha256]) directory = await directory.getDirectoryHandle(name); + return directory; +} + +async function overwriteFile(directory: FileSystemDirectoryHandle, name: string, value: ArrayBuffer | string): Promise { + const writer = await (await directory.getFileHandle(name)).createWritable(); + await writer.write(value); + await writer.close(); +} + +async function manifestFor(data: Uint8Array, requestSeed: string): Promise { + const first = data.subarray(0, 64 * 1024); + const second = data.subarray(64 * 1024); + return validateNanoVDBBundleManifest({ + schemaVersion: 1, + projectId, + sourcePath: "//volumes/smoke.vdb", + sourceSha256: "a".repeat(64), + conversionRequestSha256: requestSeed.repeat(64), + bundlePath: "//volumes/smoke.nvdb", + bundleByteLength: data.byteLength, + bundleSha256: await hash(data), + converter: cancelConverter, + grids: [{ + name: "density", valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 8, + 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: [0.5, 0.5, 0.5], + 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 hash(first) }, + { index: 1, byteOffset: first.byteLength, byteLength: second.byteLength, sha256: await hash(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: 64 * 1024 * 1024, shaderSemanticVersion: "volume-wgsl-v1" }, + }); +} + +async function commit(): Promise { + await pruneNanoVDBOPFS(projectId, 0); + const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR); + const rangeSource = async (range: { start: number; endExclusive: number }): Promise => { + const response = await fetch("/__vdb_fixture__/bundle", { + cache: "no-store", + headers: { Range: `bytes=${range.start}-${range.endExclusive - 1}` }, + }); + if (response.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: fixture range returned ${response.status}`); + return response.arrayBuffer(); + }; + const binding = await createVDBProjectBinding(manifest, sourceBlendSha256); + const committed = await commitNanoVDBToOPFS(manifest, rangeSource, new AbortController().signal, binding); + const deduplicated = await commitNanoVDBToOPFS(manifest, rangeSource, new AbortController().signal, binding); + + const cancelledData = bytes(71); + const cancelledManifest = await manifestFor(cancelledData, "e"); + const controller = new AbortController(); + let cancelled = false; + try { + await commitNanoVDBToOPFS(cancelledManifest, async (range) => { + if (range.chunkIndex === 1) controller.abort(); + return buffer(cancelledData.slice(range.start, range.endExclusive)); + }, controller.signal); + } + catch (error) { cancelled = error instanceof DOMException && error.name === "AbortError"; } + const recovered = await recoverNanoVDBOPFS(projectId); + return { + committed, + deduplicated: deduplicated.deduplicated, + cancelled, + recovered, + realBundleBytes: manifest.bundleByteLength, + realChunkCount: manifest.chunks.length, + state: { bundleSha256: manifest.bundleSha256, conversionRequestSha256: manifest.conversionRequestSha256, sourceSha256: manifest.sourceSha256 }, + }; +} + +async function reopen(state: TestState): Promise { + const fixture = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR); + const reopenContext = { + projectId, sourceBlendSha256, sourcePath: fixture.sourcePath, sourceSha256: state.sourceSha256, converter: fixture.converter, shaderSemanticVersion: "volume-wgsl-v1", + } as const; + const opened = await openNanoVDBFromOPFS(projectId, state.bundleSha256, state.conversionRequestSha256, reopenContext); + const hasher = new IncrementalSha256(); + for (let index = 0; index < opened.manifest.chunks.length; index++) { + const chunk = opened.manifest.chunks[index]; + const data = await opened.source({ chunkIndex: index, start: chunk.byteOffset, endExclusive: chunk.byteOffset + chunk.byteLength, sha256: chunk.sha256 }, new AbortController().signal); + hasher.update(data); + } + const stale = await openNanoVDBFromOPFS(projectId, state.bundleSha256, state.conversionRequestSha256, { + projectId, sourceBlendSha256, sourcePath: fixture.sourcePath, sourceSha256: "f".repeat(64), converter: fixture.converter, shaderSemanticVersion: "volume-wgsl-v1", + }); + const bundle = await bundleDirectory(state.bundleSha256); + const firstChunkHandle = await bundle.getFileHandle("00000.chunk"); + const firstChunk = new Uint8Array(await (await firstChunkHandle.getFile()).arrayBuffer()); + const tampered = firstChunk.slice(); + tampered[0] ^= 0xff; + await overwriteFile(bundle, "00000.chunk", tampered.buffer); + let tamperedChunk = ""; + try { + const chunk = opened.manifest.chunks[0]; + await opened.source({ chunkIndex: 0, start: chunk.byteOffset, endExclusive: chunk.byteOffset + chunk.byteLength, sha256: chunk.sha256 }, new AbortController().signal); + } + catch (error) { tamperedChunk = error instanceof Error ? error.message : String(error); } + await overwriteFile(bundle, "00000.chunk", firstChunk.buffer); + + const manifestHandle = await bundle.getFileHandle("manifest.json"); + const manifestText = await (await manifestHandle.getFile()).text(); + const rolledBackManifest = { ...opened.manifest, material: { ...opened.manifest.material, densityScale: opened.manifest.material.densityScale + 0.25 } }; + await overwriteFile(bundle, "manifest.json", JSON.stringify(rolledBackManifest)); + let manifestRollback = ""; + try { await openNanoVDBFromOPFS(projectId, state.bundleSha256, state.conversionRequestSha256, reopenContext); } + catch (error) { manifestRollback = error instanceof Error ? error.message : String(error); } + await overwriteFile(bundle, "manifest.json", manifestText); + const pruned = await pruneNanoVDBOPFS(projectId, 0); + return { bindingStatus: opened.bindingStatus, staleStatus: stale.bindingStatus, bundleHash: hasher.hex(), expectedHash: state.bundleSha256, tamperedChunk, manifestRollback, pruned }; +} + +async function interrupt(): Promise { + await pruneNanoVDBOPFS(projectId, 0); + const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR); + await commitNanoVDBToOPFS(manifest, async (range) => { + if (range.chunkIndex === 1) { + scope.postMessage({ staged: true, bundleSha256: manifest.bundleSha256 }); + await new Promise(() => undefined); + } + const response = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${range.start}-${range.endExclusive - 1}` } }); + if (response.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: fixture range returned ${response.status}`); + return response.arrayBuffer(); + }, new AbortController().signal); + throw new Error("interrupted commit unexpectedly completed"); +} + +async function recoverInterrupted(): Promise { + const recovered = await recoverNanoVDBOPFS(projectId); + await pruneNanoVDBOPFS(projectId, 0); + return recovered; +} + +async function prepareQuota(): Promise { + await pruneNanoVDBOPFS(projectId, 0); + const data = bytes(81); + const manifest = await manifestFor(data, "1"); + await commitNanoVDBToOPFS(manifest, async (range) => buffer(data.slice(range.start, range.endExclusive)), new AbortController().signal); + return { state: { bundleSha256: manifest.bundleSha256, conversionRequestSha256: manifest.conversionRequestSha256, sourceSha256: manifest.sourceSha256 } }; +} + +async function quota(state: TestState): Promise { + let error = ""; + try { + const data = bytes(82); + const manifest = await manifestFor(data, "2"); + await commitNanoVDBToOPFS(manifest, async (range) => buffer(data.slice(range.start, range.endExclusive)), new AbortController().signal); + } + catch (caught) { error = caught instanceof Error ? `${caught.name}: ${caught.message}` : String(caught); } + let recoveryWhileQuotaLimited = ""; + let recoveredWhileQuotaLimited: unknown; + try { recoveredWhileQuotaLimited = await recoverNanoVDBOPFS(projectId); } + catch (caught) { recoveryWhileQuotaLimited = caught instanceof Error ? `${caught.name}: ${caught.message}` : String(caught); } + return { quotaError: error, recoveryWhileQuotaLimited, recoveredWhileQuotaLimited, previousBundleSha256: state.bundleSha256 }; +} + +async function verifyQuota(state: TestState): Promise { + const recovered = await recoverNanoVDBOPFS(projectId); + const opened = await openNanoVDBFromOPFS(projectId, state.bundleSha256); + const first = opened.manifest.chunks[0]; + await opened.source({ chunkIndex: 0, start: first.byteOffset, endExclusive: first.byteOffset + first.byteLength, sha256: first.sha256 }, new AbortController().signal); + await pruneNanoVDBOPFS(projectId, 0); + return { recovered, previousBundleReadable: true }; +} + +scope.onmessage = (event): void => { + const action = event.data.action; + void (action === "commit" ? commit() : + action === "reopen" ? reopen(event.data.state!) : + action === "interrupt" ? interrupt() : + action === "recoverInterrupted" ? recoverInterrupted() : + action === "prepareQuota" ? prepareQuota() : + action === "quota" ? quota(event.data.state!) : verifyQuota(event.data.state!)) + .then((result) => scope.postMessage(result)) + .catch((error) => scope.postMessage({ error: error instanceof Error ? error.stack ?? error.message : String(error) })); +}; diff --git a/web/app/src/workers/vdb-test.worker.ts b/web/app/src/workers/vdb-test.worker.ts index 0e831e71..e6628fed 100644 --- a/web/app/src/workers/vdb-test.worker.ts +++ b/web/app/src/workers/vdb-test.worker.ts @@ -1,27 +1,206 @@ -import { decodeVDBResource, type VDBResourceManifest } from "../../../protocol/volume-vdb"; +import { + VDB_PIPELINE_SCHEMA, + gateNanoVDBPipeline, + hashVDBConversionRequest, + planNanoVDBRanges, + prepareVDBConversionInput, + validateNanoVDBBundleManifest, + validateVDBConversionRequest, + verifyNanoVDBBundle, + verifyNanoVDBChunk, + type NanoVDBBundleManifestIR, + type NanoVDBGridIR, + type VDBConverterIdentityIR, + type VDBResourceManifest, +} from "../../../protocol/volume-vdb"; +import { createHttpNanoVDBRangeSource, streamNanoVDBChunks } from "../volume/nanovdb-stream"; const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; +const identityTransform: NanoVDBGridIR["indexToWorld"] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + +function bytes(length: number, seed: number): Uint8Array { + return Uint8Array.from({ length }, (_value, index) => (index * 37 + seed) & 0xff); +} + +function arrayBuffer(value: Uint8Array): ArrayBuffer { + return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer; +} + +async function digest(value: Uint8Array): Promise { + const hash = await crypto.subtle.digest("SHA-256", arrayBuffer(value)); + return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function grid(name: string, semantic: NanoVDBGridIR["semantic"], byteOffset: number): NanoVDBGridIR { + return { + name, + valueType: "FLOAT32", + gridClass: "FOG_VOLUME", + semantic, + activeVoxelCount: 8, + segmentByteOffset: byteOffset, + segmentByteLength: 32, + byteOffset, + byteLength: 32, + indexBounds: { min: [0, 0, 0], max: [1, 1, 1] }, + worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, + voxelSize: [0.5, 0.5, 0.5], + indexToWorld: [...identityTransform], + }; +} scope.onmessage = (): void => { void (async () => { - const data = Uint8Array.from([0x76, 0x64, 0x62, 0x01]); - const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (value) => value.toString(16).padStart(2, "0")).join(""); - const manifest: VDBResourceManifest = { projectId: "vdb-test", sourcePath: "//cache/smoke.vdb", byteLength: data.byteLength, sha256: digest, grids: [{ name: "density", valueType: "FLOAT", voxelCount: 16, activeVoxelCount: 8 }] }; - const decoded = await decodeVDBResource({ ...manifest, data: data.buffer }, async (request, signal) => { - if (signal.aborted) throw new DOMException("cancelled", "AbortError"); - return { metadata: request, decodedByteLength: request.data.byteLength }; - }, new AbortController().signal); + // These deterministic bytes exercise the transport contract only. They are not + // presented as an OpenVDB or NanoVDB decoder fixture. + const sourceBytes = bytes(64, 11); + const source: VDBResourceManifest = { + projectId: "vdb-protocol-test", + sourcePath: "//volumes/smoke.vdb", + byteLength: sourceBytes.byteLength, + sha256: await digest(sourceBytes), + grids: [ + { name: "density", valueType: "FLOAT", voxelCount: 16, activeVoxelCount: 8 }, + { name: "temperature", valueType: "FLOAT", voxelCount: 16, activeVoxelCount: 8 }, + ], + }; + const prepared = await prepareVDBConversionInput({ ...source, data: arrayBuffer(sourceBytes) }, new AbortController().signal); + const converter: VDBConverterIdentityIR = { + target: "SERVER", + blenderVersion: "5.2.0", + openVDBVersion: "contract-fixture", + nanoVDBVersion: "contract-fixture", + executableSha256: "1".repeat(64), + }; + const conversion = validateVDBConversionRequest({ + schemaVersion: VDB_PIPELINE_SCHEMA, + jobId: "vdb-protocol-job", + source, + outputPath: "//volumes/smoke.nvdb", + selectedGrids: ["density", "temperature"], + quantization: "LOSSLESS", + chunkByteLength: 64 * 1024, + converter, + }); + const conversionRequestSha256 = await hashVDBConversionRequest(conversion); + const relocatedConversionSha256 = await hashVDBConversionRequest({ + ...conversion, + jobId: "vdb-relocated-job", + outputPath: "//cache/relocated.nvdb", + }); + + const bundleBytes = bytes(64, 23); + const first = bundleBytes.slice(0, 32); + const second = bundleBytes.slice(32); + const manifest: NanoVDBBundleManifestIR = { + schemaVersion: VDB_PIPELINE_SCHEMA, + projectId: source.projectId, + sourcePath: source.sourcePath, + sourceSha256: source.sha256, + conversionRequestSha256, + bundlePath: "//volumes/smoke.nvdb", + bundleByteLength: bundleBytes.byteLength, + bundleSha256: await digest(bundleBytes), + converter, + grids: [grid("density", "DENSITY", 0), grid("temperature", "TEMPERATURE", 32)], + chunks: [ + { index: 0, byteOffset: 0, byteLength: 32, sha256: await digest(first) }, + { index: 1, byteOffset: 32, byteLength: 32, sha256: await digest(second) }, + ], + material: { + densityGrid: "density", + temperatureGrid: "temperature", + densityScale: 1, + emissionScale: 0, + temperatureScale: 1, + anisotropy: 0, + interpolation: "LINEAR", + }, + gpu: { + representation: "NANOVDB_STORAGE_BUFFER", + byteAlignment: 32, + pageByteLength: 64 * 1024, + maxResidentBytes: 64 * 1024, + shaderSemanticVersion: "volume-wgsl-v1", + }, + }; + const validated = validateNanoVDBBundleManifest(manifest); + const ranges = planNanoVDBRanges(validated); + await verifyNanoVDBChunk(validated.chunks[0], arrayBuffer(first)); + await verifyNanoVDBChunk(validated.chunks[1], arrayBuffer(second)); + await verifyNanoVDBBundle(validated, arrayBuffer(bundleBytes)); + const consumed: number[] = []; + const progress: number[] = []; + const stream = await streamNanoVDBChunks( + validated, + async (range) => arrayBuffer(bundleBytes.slice(range.start, range.endExclusive)), + (range) => { consumed.push(range.chunkIndex); }, + new AbortController().signal, + (state) => { progress.push(state.completedBytes); }, + ); + const exactFetch: typeof fetch = async (_input, init) => { + const rangeHeader = new Headers(init?.headers).get("Range"); + const match = rangeHeader?.match(/^bytes=(\d+)-(\d+)$/); + if (!match) return new Response(null, { status: 416 }); + const start = Number(match[1]); + const endInclusive = Number(match[2]); + return new Response(arrayBuffer(bundleBytes.slice(start, endInclusive + 1)), { + status: 206, + headers: { "Content-Range": `bytes ${start}-${endInclusive}/${bundleBytes.byteLength}` }, + }); + }; + const httpRange = await createHttpNanoVDBRangeSource("/assets/smoke.nvdb", bundleBytes.byteLength, exactFetch)(ranges[0], new AbortController().signal); + let invalidHttpRange = ""; + try { + const fullResponse: typeof fetch = async () => new Response(arrayBuffer(bundleBytes), { status: 200 }); + await createHttpNanoVDBRangeSource("/assets/smoke.nvdb", bundleBytes.byteLength, fullResponse)(ranges[0], new AbortController().signal); + } + catch (error) { invalidHttpRange = error instanceof Error ? error.message : String(error); } + let outsideProject = ""; try { - await decodeVDBResource({ ...manifest, sourcePath: "../../outside.vdb", data: data.buffer }, undefined, new AbortController().signal); + await prepareVDBConversionInput({ ...source, sourcePath: "../../outside.vdb", data: arrayBuffer(sourceBytes) }, new AbortController().signal); } catch (error) { outsideProject = error instanceof Error ? error.message : String(error); } + let tamperedChunk = ""; + try { + const tampered = first.slice(); + tampered[0] ^= 0xff; + await verifyNanoVDBChunk(validated.chunks[0], arrayBuffer(tampered)); + } + catch (error) { tamperedChunk = error instanceof Error ? error.message : String(error); } + let incompleteStream = ""; + try { + validateNanoVDBBundleManifest({ ...manifest, chunks: manifest.chunks.map((chunk, index) => index === 1 ? { ...chunk, byteOffset: 64 } : chunk) }); + } + catch (error) { incompleteStream = error instanceof Error ? error.message : String(error); } + const controller = new AbortController(); - const cancelled = decodeVDBResource({ ...manifest, data: data.buffer }, async (_request, signal) => new Promise((resolve, reject) => { - const timer = setTimeout(() => resolve({ metadata: manifest, decodedByteLength: data.byteLength }), 20); - signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("cancelled", "AbortError")); }, { once: true }); - }), controller.signal).then(() => false).catch((error) => error instanceof DOMException && error.name === "AbortError"); - setTimeout(() => controller.abort(), 1); - scope.postMessage({ decodedByteLength: decoded.decodedByteLength, outsideProject, cancelled: await cancelled }); + controller.abort(); + const cancelled = prepareVDBConversionInput({ ...source, data: arrayBuffer(sourceBytes) }, controller.signal) + .then(() => false) + .catch((error) => error instanceof DOMException && error.name === "AbortError"); + const rawBrowserGate = gateNanoVDBPipeline("RAW_VDB_BROWSER_DECODE"); + const streamGate = gateNanoVDBPipeline("NANOVDB_STREAM", { manifestValidated: true, rangeReaderAvailable: true }); + const renderGate = gateNanoVDBPipeline("WEBGPU_VOLUME_RENDER", { manifestValidated: true, rangeReaderAvailable: true, webgpuAvailable: true }); + scope.postMessage({ + preparedByteLength: prepared.data.byteLength, + conversionTarget: conversion.converter.target, + conversionRequestSha256: manifest.conversionRequestSha256, + relocationKeepsContentKey: conversionRequestSha256 === relocatedConversionSha256, + ranges, + consumed, + progress, + stream, + httpRangeByteLength: httpRange.byteLength, + invalidHttpRange, + outsideProject, + tamperedChunk, + incompleteStream, + cancelled: await cancelled, + rawBrowserGate, + streamGate, + renderGate, + }); })().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) })); }; diff --git a/web/app/src/workers/vdb-webgpu-test.worker.ts b/web/app/src/workers/vdb-webgpu-test.worker.ts new file mode 100644 index 00000000..d3b3ca63 --- /dev/null +++ b/web/app/src/workers/vdb-webgpu-test.worker.ts @@ -0,0 +1,64 @@ +import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb"; +import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32"; +import { mapPrincipledVolumeToNanoVDB } from "../volume/volume-material-mapping"; +import { probeNanoVDBWebGPU, renderNanoVDBFloat32WebGPU, sampleNanoVDBFloat32WebGPU, uploadNanoVDBFloat32Grid } from "../render/nanovdb-volume-renderer"; + +const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void }; + +scope.onmessage = (): void => { + void (async () => { + const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR); + const report = await (await fetch("/__vdb_fixture__/report", { cache: "no-store" })).json() as { grids: Array<{ name: string; scalarSamples?: Array<{ coord: [number, number, number]; value: number; active: boolean }> }> }; + const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid); + const nativeDensity = report.grids.find((grid) => grid.name === manifest.material.densityGrid); + if (!density || !nativeDensity?.scalarSamples || !manifest.gpu.float32TreeLayout) throw new Error("VDB WebGPU fixture is incomplete"); + const response = await fetch("/__vdb_fixture__/bundle", { headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` }, cache: "no-store" }); + if (response.status !== 206) throw new Error(`VDB payload range returned ${response.status}`); + const payload = await response.arrayBuffer(); + const cpu = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout); + const cpuSamples = nativeDensity.scalarSamples.map((sample) => ({ coord: sample.coord, ...cpu.nearest(sample.coord), expectedValue: sample.value, expectedActive: sample.active })); + const probe = await probeNanoVDBWebGPU(payload.byteLength); + if (!probe.capability.available || !probe.device) throw new Error(probe.capability.reason ?? "WebGPU adapter is unavailable"); + const device = probe.device; + device.pushErrorScope("validation"); + const uploaded = uploadNanoVDBFloat32Grid(device, payload); + const gpuSamples = await sampleNanoVDBFloat32WebGPU(device, uploaded, nativeDensity.scalarSamples.map((sample) => sample.coord)); + const materialMapping = mapPrincipledVolumeToNanoVDB(manifest, { + densityGrid: manifest.material.densityGrid, + densityScale: 1, + colorGrid: manifest.material.colorGrid, + color: [0.7, 0.8, 0.95], + temperatureGrid: manifest.material.temperatureGrid, + temperatureScale: 1, + blackbodyEnabled: true, + emissionColor: [1, 0.35, 0.1], + emissionScale: 0.08, + velocityGrid: manifest.material.velocityGrid, + anisotropy: 0.2, + interpolation: "LINEAR", + }); + const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, materialMapping.material, 96, 96); + const validationError = await device.popErrorScope(); + if (validationError) throw new Error(`WebGPU validation failed: ${validationError.message}`); + let visiblePixels = 0; + let alphaSum = 0; + for (let index = 3; index < pixels.length; index += 4) { + alphaSum += pixels[index]; + if (pixels[index] > 0) visiblePixels++; + } + const imageHash = await crypto.subtle.digest("SHA-256", new Uint8Array(pixels).buffer); + uploaded.dispose(); + device.destroy(); + return { + capability: probe.capability, + payloadBytes: payload.byteLength, + nativeSamples: nativeDensity.scalarSamples, + cpuSamples, + gpuSamples, + visiblePixels, + alphaSum, + imageSha256: Array.from(new Uint8Array(imageHash), (byte) => byte.toString(16).padStart(2, "0")).join(""), + materialMapping, + }; + })().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 1ca4d326..8cfaa967 100644 --- a/web/app/src/workers/viewport-render.worker.ts +++ b/web/app/src/workers/viewport-render.worker.ts @@ -10,6 +10,7 @@ import { InstancedMesh, Matrix4, Mesh, + MeshBasicMaterial, MeshPhysicalMaterial, PerspectiveCamera, Quaternion, @@ -27,6 +28,10 @@ import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web- import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary"; import type { OffscreenViewportRequest, OffscreenViewportResponse } from "../three-adapter/offscreen-viewport-protocol"; import type { NonMeshElementKind } from "../three-adapter/nonmesh"; +import type { CurveGizmoFrameIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction"; +import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport"; +import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport"; +import { createNanoVDBViewportObject } from "../three-adapter/volume"; import { configurePBRLight, configurePBRRenderer, @@ -35,8 +40,15 @@ import { setPBRMaterialSelected, } from "../three-adapter/pbr"; import { GPUTextureStore } from "../three-adapter/texture-assets"; -import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh"; -import { applyGreasePencilTransform, createGreasePencilObject } from "../three-adapter/grease-pencil"; +import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh"; +import { + applyGreasePencilPointSelection, + applyGreasePencilPointPreview, + applyGreasePencilTransform, + createGreasePencilObject, + greasePencilPointRef, + type GreasePencilPointRef, +} from "../three-adapter/grease-pencil"; const workerScope = self as unknown as { onmessage: ((event: MessageEvent) => void) | null; @@ -47,6 +59,7 @@ let scene: Scene | null = null; let camera: PerspectiveCamera | null = null; let root: Group | null = null; let importedLights: Group | null = null; +let contextLost = false; let width = 1; let height = 1; let yaw = -Math.PI / 4; @@ -57,17 +70,27 @@ let selectionMode: MeshElementMode = "FACE"; let currentSnapshot: SceneSnapshotIR | null = null; const textureStore = new GPUTextureStore(); const raycaster = new Raycaster(); +raycaster.params.Points.threshold = 0.14; const objectById = new Map(); +let curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null; +let volumeAssets: NanoVDBViewportAssetIR[] = []; +const volumeRenderCache = new Map(); +let volumeRenderGeneration = 0; +const volumeRenderSession = new NanoVDBViewportRenderSession(() => { + volumeRenderCache.clear(); + void refreshVolumes(); +}); function post(message: OffscreenViewportResponse): void { workerScope.postMessage(message); } function render(): void { - if (!renderer || !scene || !camera) return; + 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); renderer.render(scene, camera); + publishCurveGizmoFrame(); const gl = renderer.getContext(); const sampleWidth = Math.min(16, gl.drawingBufferWidth); const sampleHeight = Math.min(16, gl.drawingBufferHeight); @@ -88,6 +111,28 @@ function render(): void { post({ type: "frame", visiblePixels }); } +function publishCurveGizmoFrame(): void { + const active = curveGizmoFrame; + let frame: CurveGizmoScreenFrameIR | null = null; + const node = active ? currentSnapshot?.nodes.find((candidate) => candidate.dataId === active.dataId && candidate.id === currentSnapshot?.activeObjectId) : undefined; + const object = node ? objectById.get(node.id) : undefined; + if (active && object && camera) { + object.updateWorldMatrix(true, false); + camera.updateMatrixWorld(true); + const project = (value: readonly number[]): Vector3 => new Vector3(value[0], value[2], -value[1]).applyMatrix4(object.matrixWorld).project(camera!); + const origin = project(active.frame.origin); + const axes = active.frame.axes.map((axis) => { + const endpoint = project([active.frame.origin[0] + axis[0], active.frame.origin[1] + axis[1], active.frame.origin[2] + axis[2]]); + const x = endpoint.x - origin.x; + const y = origin.y - endpoint.y; + const magnitude = Math.hypot(x, y); + return magnitude > 1e-8 ? [x / magnitude, y / magnitude] as [number, number] : [0, 0] as [number, number]; + }) as CurveGizmoScreenFrameIR["axes"]; + frame = { origin: [(origin.x + 1) / 2, (1 - origin.y) / 2], axes }; + } + post({ type: "curveGizmoScreenFrame", frame }); +} + function resize(nextWidth: number, nextHeight: number, pixelRatio: number): void { width = Math.max(1, nextWidth); height = Math.max(1, nextHeight); @@ -172,12 +217,65 @@ function clearRoot(): void { const mesh = object as Mesh; mesh.geometry?.dispose(); const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; - for (const material of materials) material?.dispose(); + for (const material of materials) { + if (mesh.userData.nanoVDBVolume && material instanceof MeshBasicMaterial) material.map?.dispose(); + material?.dispose(); + } }); } objectById.clear(); } +async function refreshVolumes(): Promise { + const generation = ++volumeRenderGeneration; + if (!root || !currentSnapshot) return; + for (const child of [...root.children]) { + if (!child.userData.nanoVDBVolume) continue; + root.remove(child); + child.traverse((object) => { + const mesh = object as Mesh; + mesh.geometry?.dispose(); + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const material of materials) { + if (material instanceof MeshBasicMaterial) material.map?.dispose(); + material?.dispose(); + } + }); + } + const snapshot = currentSnapshot; + const nodes = snapshot.nodes.filter((node) => node.visible && node.type === "VOLUME" && node.dataId); + if (nodes.length === 0) { + post({ type: "volumeStatus", status: "none", count: 0 }); + return; + } + post({ type: "volumeStatus", status: "loading", count: 0 }); + try { + let count = 0; + for (const node of nodes) { + const asset = volumeAssets.find((candidate) => candidate.dataId === node.dataId); + if (!asset) continue; + const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`; + let result = volumeRenderCache.get(cacheKey); + if (!result) { + result = await renderNanoVDBViewportAsset(asset, 128, 128, volumeRenderSession); + volumeRenderCache.set(cacheKey, result); + } + if (generation !== volumeRenderGeneration || currentSnapshot !== snapshot || !root) return; + const object = createNanoVDBViewportObject(result, node); + root.add(object); + objectById.set(node.id, object); + count++; + } + if (generation !== volumeRenderGeneration) return; + post({ type: "volumeStatus", status: count === nodes.length ? "ready" : "blocked", count, ...(count === nodes.length ? {} : { errorCode: "NON_MESH_RESOURCE_MISSING" }) }); + render(); + } + catch (error) { + if (generation !== volumeRenderGeneration) return; + post({ type: "volumeStatus", status: "blocked", count: 0, errorCode: error instanceof Error ? error.message.split(":", 1)[0] : "VOLUME_SHADER_UNAVAILABLE" }); + } +} + function applyTextureAssets(assets: readonly GPUTextureAsset[]): void { void textureStore.upload(assets).then((status) => { if (currentSnapshot && root) textureStore.applySnapshotMaterials(root, currentSnapshot); @@ -258,6 +356,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n if (node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA" || !node.visible || !node.dataId) continue; const data = dataById.get(node.dataId); if (!data) continue; + if (data.type === "VOLUME") continue; const object = createNonMeshObject(data, nonMeshGeometryBuffers); if (!object) { nonMeshBlockedCount++; @@ -310,10 +409,11 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n objectById.set(node.id, light); } } + void refreshVolumes(); render(); } -function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>): void { +function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[]): void { const selected = new Set(ids); const visited = new Set(); for (const [id, object] of objectById) { @@ -340,13 +440,24 @@ function setSelection(ids: string[], elements: Array<{ dataId: string; kind: Non selection.set(element.dataId, kinds); } if (root) applyNonMeshElementSelection(root, selection); + if (root) applyGreasePencilPointSelection(root, greasePencilPoints); render(); } function pick(x: number, y: number, additive: boolean): void { if (!root || !camera) return; raycaster.setFromCamera(new Vector2(x, y), camera); - const hit = raycaster.intersectObjects(root.children, true)[0]; + const hits = raycaster.intersectObjects(root.children, true); + const greasePencilHit = editMode + ? hits.find((intersection) => intersection.index !== undefined && greasePencilPointRef(intersection.object, intersection.index) !== null) + : undefined; + if (greasePencilHit?.index !== undefined) { + const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index); + if (point) post({ type: "greasePencilPointSelected", point, additive }); + return; + } + const preferredNonMeshHit = editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined; + const hit = preferredNonMeshHit ?? hits[0]; if (!hit) return; const nonMeshDataId = hit.object.userData.nonMeshDataId; if (typeof nonMeshDataId === "string" && hit.index !== undefined) { @@ -403,6 +514,18 @@ workerScope.onmessage = (event): void => { const message = event.data; if (message.type === "init") { renderer = new WebGLRenderer({ canvas: message.canvas, antialias: true, preserveDrawingBuffer: true }); + message.canvas.addEventListener("webglcontextlost", (event) => { + event.preventDefault(); + contextLost = true; + post({ type: "volumeStatus", status: "loading", count: 0, errorCode: "WEBGL_CONTEXT_LOST" }); + }); + message.canvas.addEventListener("webglcontextrestored", () => { + contextLost = false; + configurePBRRenderer(renderer!); + volumeRenderCache.clear(); + void refreshVolumes(); + render(); + }); configurePBRRenderer(renderer); scene = new Scene(); camera = new PerspectiveCamera(45, 1, 0.01, 1000); @@ -421,11 +544,31 @@ workerScope.onmessage = (event): void => { } else if (message.type === "snapshot") setSnapshot(message.snapshot, message.geometryBuffers, message.nonMeshGeometryBuffers); else if (message.type === "textureAssets") applyTextureAssets(message.assets); + else if (message.type === "volumeAssets") { + volumeAssets = message.assets; + void refreshVolumes(); + } else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio); - else if (message.type === "selection") setSelection(message.objectIds, message.elements); + else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints); else if (message.type === "interaction") { editMode = message.editMode; selectionMode = message.selectionMode; + root?.traverse((object) => { + if (typeof object.userData.greasePencilPointDataId === "string") object.visible = editMode; + }); + render(); + } + else if (message.type === "curveHandlePreview") { + if (root) applyCurveHandlePreview(root, message.dataId, message.handles); + render(); + } + else if (message.type === "greasePencilPointPreview") { + if (root) applyGreasePencilPointPreview(root, message.dataId, message.layerId, message.frame, message.points); + render(); + } + else if (message.type === "curveGizmoFrame") { + curveGizmoFrame = message.dataId && message.frame ? { dataId: message.dataId, frame: message.frame } : null; + render(); } else if (message.type === "orbit") { yaw -= message.deltaX * 0.008; @@ -436,6 +579,9 @@ workerScope.onmessage = (event): void => { else if (message.type === "pick") pick(message.x, message.y, message.additive); else if (message.type === "dispose") { clearRoot(); + volumeRenderGeneration++; + volumeRenderCache.clear(); + volumeRenderSession.dispose(); clearLights(); renderer?.dispose(); textureStore.dispose(); @@ -444,6 +590,7 @@ workerScope.onmessage = (event): void => { camera = null; root = null; importedLights = null; + contextLost = false; } } catch (error) { diff --git a/web/app/src/workers/web-engine.worker.ts b/web/app/src/workers/web-engine.worker.ts index e218729c..7c3be5e4 100644 --- a/web/app/src/workers/web-engine.worker.ts +++ b/web/app/src/workers/web-engine.worker.ts @@ -90,6 +90,7 @@ let module: WasmModule | null = null; let wasmFactory: WasmFactory | null = null; let wasmBinary: ArrayBuffer | null = null; let handle = 0; +let initializationPromise: Promise | null = null; let currentSnapshot: ReturnType | null = null; let currentGeometryBuffers: MeshGeometryBuffer[] = []; let sourceBlendBuffer: ArrayBuffer | null = null; @@ -300,6 +301,17 @@ function assertFutureCapability(payload: Extract typeof id !== "string" || !available.has(id))) throw report("NON_MESH_RESOURCE_MISSING", "Font style links must reference VFonts already present in the current Main"); return; } + case "setVolumeProperties": { + const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId); + if (!data || data.type !== "VOLUME") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Volume data block is unavailable: ${payload.dataId}`); + if (typeof payload.sourcePath !== "string" || !payload.sourcePath.startsWith("//") || !payload.sourcePath.endsWith(".vdb") || payload.sourcePath.includes("\\") || payload.sourcePath.slice(2).split("/").includes("..") || new TextEncoder().encode(payload.sourcePath).byteLength >= 1024) throw report("NON_MESH_RESOURCE_OUTSIDE_PROJECT", "Volume source must be a bounded project-relative // path ending in .vdb"); + if (!Number.isFinite(payload.displayDensity) || payload.displayDensity < 0 || payload.displayDensity > 1_000_000) throw report("NON_MESH_PROPERTY_INVALID", "Volume display density is outside the bounded range"); + if (payload.interpolation !== "NEAREST" && payload.interpolation !== "LINEAR") throw report("NON_MESH_PROPERTY_INVALID", "Volume interpolation is unsupported"); + if (!Number.isFinite(payload.stepSize) || payload.stepSize < 0 || payload.stepSize > 1_000_000) throw report("NON_MESH_PROPERTY_INVALID", "Volume render step is outside the bounded range"); + if (payload.velocityGrid !== undefined && (typeof payload.velocityGrid !== "string" || new TextEncoder().encode(payload.velocityGrid).byteLength >= 64)) throw report("NON_MESH_PROPERTY_INVALID", "Volume velocity grid name exceeds the Blender field limit"); + if (payload.velocityScale !== undefined && (!Number.isFinite(payload.velocityScale) || payload.velocityScale < -1_000_000 || payload.velocityScale > 1_000_000)) throw report("NON_MESH_PROPERTY_INVALID", "Volume velocity scale is outside the bounded range"); + return; + } case "setMetaballElements": { const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId); if (!data || data.type !== "METABALL") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Metaball data block is unavailable: ${payload.dataId}`); @@ -459,20 +471,32 @@ function nativeError(fallbackCode: ErrorReport["code"]): ErrorReport { } async function initialize(): Promise { - if (!module) { - const imported = await import("../vendor/blender/web_engine.js") as unknown as { default: WasmFactory }; - const factory = imported.default; - const binary = await fetch("/vendor/blender/web_engine.wasm?v=2").then((response) => { - if (!response.ok) throw new Error(`web_engine.wasm request failed: ${response.status}`); - return response.arrayBuffer(); + if (module) return status(); + if (!initializationPromise) { + initializationPromise = (async () => { + const imported = await import("../vendor/blender/web_engine.js") as unknown as { default: WasmFactory }; + const factory = imported.default; + const binary = await fetch("/vendor/blender/web_engine.wasm?v=2").then((response) => { + if (!response.ok) throw new Error(`web_engine.wasm request failed: ${response.status}`); + return response.arrayBuffer(); + }); + const initializedModule = await factory({ wasmBinary: binary }); + const initializedHandle = initializedModule._web_engine_create(); + if (initializedHandle <= 0) throw new Error("WebEngine handle creation failed"); + wasmFactory = factory; + wasmBinary = binary; + module = initializedModule; + handle = initializedHandle; + return status(); + })().catch((error: unknown) => { + module = null; + handle = 0; + throw error; + }).finally(() => { + initializationPromise = null; }); - wasmFactory = factory; - wasmBinary = binary; - module = await factory({ wasmBinary: binary }); - handle = module._web_engine_create(); - if (handle <= 0) throw new Error("WebEngine handle creation failed"); } - return status(); + return initializationPromise; } function copyIntoWasm(buffer: ArrayBuffer): { pointer: number; length: number } { diff --git a/web/app/vite.config.ts b/web/app/vite.config.ts index a75da112..d6ad1ec3 100644 --- a/web/app/vite.config.ts +++ b/web/app/vite.config.ts @@ -1,5 +1,9 @@ import { defineConfig, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; +// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types. +import fs from "node:fs"; +// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types. +import path from "node:path"; const isolationHeaders = { "Cross-Origin-Opener-Policy": "same-origin", @@ -21,9 +25,51 @@ function preserveIsolationHeaders(): Plugin { }; } +function localVDBFixture(): Plugin { + const resourceRoot = (globalThis as { process?: { env?: Record } }).process?.env?.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb"; + const files = new Map([ + ["/__vdb_fixture__/manifest", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }], + ["/__vdb_fixture__/report", { path: path.join(resourceRoot, "reports/generated-smoke-conversion.json"), type: "application/json" }], + ["/__vdb_fixture__/bundle", { path: path.join(resourceRoot, "nanovdb/generated-smoke.nvdb"), type: "application/x-nanovdb" }], + ["/assets/volumes/generated-smoke.nanovdb.json", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }], + ["/assets/volumes/generated-smoke.nvdb", { path: path.join(resourceRoot, "nanovdb/generated-smoke.nvdb"), type: "application/x-nanovdb" }], + ["/volumes/generated-smoke.nanovdb.json", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }], + ["/volumes/generated-smoke.nvdb", { path: path.join(resourceRoot, "nanovdb/generated-smoke.nvdb"), type: "application/x-nanovdb" }], + ]); + return { + name: "local-vdb-fixture", + configureServer(server) { + server.middlewares.use((request, response, next) => { + const nodeRequest = request as unknown as { url?: string; headers: { range?: string } }; + const pathname = nodeRequest.url?.split("?", 1)[0] ?? ""; + const fixture = files.get(pathname); + if (!fixture || !fs.existsSync(fixture.path)) { next(); return; } + const stat = fs.statSync(fixture.path); + response.setHeader("Content-Type", fixture.type); + response.setHeader("Accept-Ranges", "bytes"); + response.setHeader("Cache-Control", "no-store"); + response.setHeader("ETag", `"vdb-${stat.size}-${Math.trunc(stat.mtimeMs)}"`); + const match = nodeRequest.headers.range?.match(/^bytes=(\d+)-(\d+)$/); + if (match) { + const start = Number(match[1]); + const end = Number(match[2]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= stat.size) { response.writeHead(416); response.end(); return; } + response.statusCode = 206; + response.setHeader("Content-Range", `bytes ${start}-${end}/${stat.size}`); + response.setHeader("Content-Length", end - start + 1); + fs.createReadStream(fixture.path, { start, end }).pipe(response); + return; + } + response.setHeader("Content-Length", stat.size); + fs.createReadStream(fixture.path).pipe(response); + }); + }, + }; +} + export default defineConfig({ root: "app", - plugins: [preserveIsolationHeaders(), react()], + plugins: [preserveIsolationHeaders(), localVDBFixture(), react()], server: { port: 5173, strictPort: false, diff --git a/web/package.json b/web/package.json index e6eed531..5c8f4559 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,11 @@ "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-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", + "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:browser": "playwright test --config playwright.release.config.ts", "test:cross-browser": "npm run test:browser", @@ -21,7 +26,7 @@ "test:topology-collapse": "node ../tools/web/check-topology-collapse.mjs", "test:depsgraph": "node ../tools/web/check-depsgraph.mjs", "test:nonmesh-binary": "playwright test --config playwright.config.ts -g \"one-million-point binary transfer gate\"", - "test:vdb": "playwright test --config playwright.config.ts -g \"OpenVDB metadata\"", + "test:vdb": "playwright test --config playwright.config.ts -g \"VDB conversion boundary\"", "test:frame-evaluation": "node ../tools/web/check-frame-evaluation.mjs", "test:pose-constraint-goldens": "node ../tools/web/check-pose-constraint-goldens.mjs", "test:main-roundtrip": "node ../tools/web/check-main-roundtrip.mjs", @@ -30,6 +35,12 @@ "test:selection-history": "playwright test --config playwright.config.ts -g \"N-015 selection history\"", "test:nonmesh-interaction": "playwright test --config playwright.config.ts -g \"N-015 curve gizmo interaction\"", "test:vdb-availability": "node ../tools/web/check-vdb-availability.mjs", + "test:vdb-native": "node ../tools/web/check-vdb-native-pipeline.mjs", + "test:vdb-server": "node ../tools/web/check-vdb-server-job.mjs", + "test:vdb-opfs": "playwright test --config playwright.config.ts -g \"hash-bound NanoVDB project through OPFS\"", + "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: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", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 35aecfb2..0a47400d 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ headless: true, launchOptions: { executablePath: chromePath, - args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"], }, screenshot: "only-on-failure", trace: "retain-on-failure", diff --git a/web/protocol/asset-library-io.ts b/web/protocol/asset-library-io.ts index 5635c072..6dab8801 100644 --- a/web/protocol/asset-library-io.ts +++ b/web/protocol/asset-library-io.ts @@ -36,6 +36,7 @@ export interface AssetEntryIR { export interface AssetLibraryIR { id: string; name: string; sourcePath: string; sourceSha256: string; dependencyIds: string[]; readOnly: boolean } export interface AssetLibraryManifestIR { schemaVersion: typeof ASSET_LIBRARY_SCHEMA; revision: number; catalogs: AssetCatalogIR[]; assets: AssetEntryIR[]; libraries: AssetLibraryIR[] } export interface IOArchiveEntryIR { path: string; compressedBytes: number; uncompressedBytes: number } +export interface IOArchiveRangeIR extends IOArchiveEntryIR { compressedOffset: number } export interface IORequestIR { format: IOFormat; operation: "IMPORT" | "EXPORT" | "ANALYZE"; sourcePath?: string; sourceSha256?: string; byteLength?: number; externalUris: string[]; archiveEntries: IOArchiveEntryIR[] } export class AssetLibraryValidationError extends Error { @@ -101,6 +102,25 @@ export function verifyAssetSource(asset: AssetEntryIR, actualSha256: string): vo if (!SHA256.test(actualSha256) || actualSha256 !== asset.sourceSha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Source hash does not match ${asset.id}`); } +async function sha256(data: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); +} + +export async function verifyAssetPreview(preview: AssetPreviewIR, data: ArrayBuffer): Promise { + if (!(data instanceof ArrayBuffer) || data.byteLength !== preview.byteLength || await sha256(data) !== preview.sha256) throw new AssetLibraryValidationError("ASSET_SOURCE_HASH_MISMATCH", `Preview hash or byte length does not match ${preview.assetId}`); + const bytes = new Uint8Array(data); + if (preview.mimeType === "image/png") { + if (bytes.length < 24 || ![137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not PNG data`); + const view = new DataView(data); + if (view.getUint32(16, false) !== preview.width || view.getUint32(20, false) !== preview.height) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} PNG dimensions do not match the manifest`); + } + else { + const riff = bytes.length >= 30 && String.fromCharCode(...bytes.subarray(0, 4)) === "RIFF" && String.fromCharCode(...bytes.subarray(8, 12)) === "WEBP"; + if (!riff) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", `${preview.assetId} is not WebP data`); + } +} + export function parseIORequest(value: unknown): IORequestIR { if (!record(value) || !FORMATS.has(value.format as IOFormat) || !["IMPORT", "EXPORT", "ANALYZE"].includes(value.operation as string) || !Array.isArray(value.externalUris) || !Array.isArray(value.archiveEntries)) throw new AssetLibraryValidationError("ASSET_MANIFEST_INVALID", "IO request is invalid"); if (value.externalUris.length > ASSET_LIBRARY_BUDGET.maxExternalUris || value.archiveEntries.length > ASSET_LIBRARY_BUDGET.maxArchiveEntries) throw new AssetLibraryValidationError("ASSET_BUDGET_EXCEEDED", "IO request exceeds the resource budget"); @@ -108,16 +128,32 @@ export function parseIORequest(value: unknown): IORequestIR { if (value.sourcePath !== undefined) request.sourcePath = projectPath(value.sourcePath, "sourcePath", "IO_EXTERNAL_URI_BLOCKED"); if (value.sourceSha256 !== undefined) request.sourceSha256 = digest(value.sourceSha256, "sourceSha256"); if (value.byteLength !== undefined) request.byteLength = integer(value.byteLength, "byteLength", 0, ASSET_LIBRARY_BUDGET.maxArchiveBytes); - let totalUncompressed = 0; + let totalCompressed = 0; let totalUncompressed = 0; const archivePaths = new Set(); request.archiveEntries = value.archiveEntries.map((entry, index): IOArchiveEntryIR => { if (!record(entry)) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `archiveEntries[${index}] is invalid`); const path = projectPath(entry.path, `archiveEntries[${index}].path`, "IO_ARCHIVE_UNSAFE"); const compressedBytes = integer(entry.compressedBytes, `archiveEntries[${index}].compressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); const uncompressedBytes = integer(entry.uncompressedBytes, `archiveEntries[${index}].uncompressedBytes`, 0, ASSET_LIBRARY_BUDGET.maxEntryBytes); - totalUncompressed += uncompressedBytes; if (!Number.isSafeInteger(totalUncompressed) || totalUncompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > ASSET_LIBRARY_BUDGET.maxCompressionRatio))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive expansion exceeds the byte or compression-ratio budget"); + if (archivePaths.has(path) || [...archivePaths].some((existing) => existing.startsWith(`${path}/`) || path.startsWith(`${existing}/`))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `Archive path ${path} is duplicated or conflicts with a file prefix`); + archivePaths.add(path); + totalCompressed += compressedBytes; totalUncompressed += uncompressedBytes; + if (!Number.isSafeInteger(totalCompressed) || !Number.isSafeInteger(totalUncompressed) || totalCompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || totalUncompressed > ASSET_LIBRARY_BUDGET.maxArchiveBytes || (uncompressedBytes > 0 && (compressedBytes === 0 || uncompressedBytes / compressedBytes > ASSET_LIBRARY_BUDGET.maxCompressionRatio))) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive expansion exceeds the byte or compression-ratio budget"); return { path, compressedBytes, uncompressedBytes }; }); + if (request.byteLength !== undefined && totalCompressed > request.byteLength) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive compressed entries exceed the declared source byte length"); return request; } +/** Builds a deterministic bounded range plan; it does not decode or trust an archive container. */ +export function planIOArchiveRanges(value: unknown): IOArchiveRangeIR[] { + const request = parseIORequest(value); + let compressedOffset = 0; + return [...request.archiveEntries].sort((left, right) => left.path.localeCompare(right.path)).map((entry) => { + const range = { ...entry, compressedOffset }; + compressedOffset += entry.compressedBytes; + if (!Number.isSafeInteger(compressedOffset) || compressedOffset > ASSET_LIBRARY_BUDGET.maxArchiveBytes) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", "Archive range offset exceeds the byte budget"); + return range; + }); +} + export function gateIORequest(value: unknown): CapabilityGateResult { const request = parseIORequest(value); const capability = `${request.format}_${request.operation}`; if ((request.format === "GLB" && (request.operation === "ANALYZE" || request.operation === "EXPORT")) || (request.format === "USD" && request.operation === "ANALYZE")) return readyGate("N-023", capability); diff --git a/web/protocol/compositor.ts b/web/protocol/compositor.ts index ec943717..f67207ad 100644 --- a/web/protocol/compositor.ts +++ b/web/protocol/compositor.ts @@ -11,6 +11,7 @@ export const COMPOSITOR_BUDGET = { maxImageBytes: 256 * 1024 * 1024, maxBlurRadius: 32, maxOperations: 100_000_000, + maxFrameCacheBytes: 256 * 1024 * 1024, } as const; export const COMPOSITOR_NODE_TYPES = [ @@ -77,6 +78,69 @@ export interface CompositorExecutionResult { evaluatedNodeIds: string[]; } +export interface CompositorCachedExecutionResult extends CompositorExecutionResult { + cacheKey: string; + cacheHit: boolean; +} + +interface CompositorFrameCacheEntry { + result: CompositorExecutionResult; + byteLength: number; +} + +function cloneImage(image: CompositorImageBuffer): CompositorImageBuffer { + return { ...image, data: image.data.slice() }; +} + +function cloneExecution(result: CompositorExecutionResult): CompositorExecutionResult { + return { composite: cloneImage(result.composite), viewers: new Map([...result.viewers].map(([id, image]) => [id, cloneImage(image)])), evaluatedNodeIds: [...result.evaluatedNodeIds] }; +} + +function executionBytes(result: CompositorExecutionResult): number { + const unique = new Set(); + unique.add(result.composite.data.buffer as ArrayBuffer); + for (const image of result.viewers.values()) unique.add(image.data.buffer as ArrayBuffer); + return [...unique].reduce((total, buffer) => total + buffer.byteLength, 0); +} + +export class CompositorFrameCache { + readonly maxBytes: number; + private readonly entries = new Map(); + private currentBytes = 0; + + constructor(maxBytes = COMPOSITOR_BUDGET.maxFrameCacheBytes) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > COMPOSITOR_BUDGET.maxFrameCacheBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame cache byte budget is invalid"); + this.maxBytes = maxBytes; + } + + get byteLength(): number { return this.currentBytes; } + get size(): number { return this.entries.size; } + + get(key: string): CompositorExecutionResult | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + this.entries.delete(key); + this.entries.set(key, entry); + return cloneExecution(entry.result); + } + + set(key: string, result: CompositorExecutionResult): void { + const clone = cloneExecution(result); + const byteLength = executionBytes(clone); + if (byteLength > this.maxBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame exceeds the cache byte budget"); + const previous = this.entries.get(key); + if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); } + while (this.currentBytes + byteLength > this.maxBytes) { + const oldest = this.entries.entries().next().value as [string, CompositorFrameCacheEntry] | undefined; + if (!oldest) break; + this.entries.delete(oldest[0]); + this.currentBytes -= oldest[1].byteLength; + } + this.entries.set(key, { result: clone, byteLength }); + this.currentBytes += byteLength; + } +} + export class CompositorValidationError extends Error { readonly code: ErrorCode; @@ -267,6 +331,11 @@ export function executeCompositorGraph( const outputs = new Map(); const viewers = new Map(); const evaluatedNodeIds: string[] = []; + let operationCounter = 0; + const checkCancelled = (operations = 1): void => { + operationCounter += operations; + if ((operationCounter === operations || operationCounter % 16_384 < operations) && options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled"); + }; const requireInput = (nodeId: string, socket: string): CompositorImageBuffer => { const source = incoming.get(`${nodeId}:${socket}`)?.fromNodeId; const image = source ? outputs.get(source) : undefined; @@ -279,7 +348,7 @@ export function executeCompositorGraph( const evaluate = (id: string): CompositorImageBuffer => { const existing = outputs.get(id); if (existing) return existing; - if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled"); + checkCancelled(); const node = byId.get(id); if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing node ${id}`); for (const link of graph.links.filter((candidate) => candidate.toNodeId === id)) evaluate(link.fromNodeId); @@ -297,7 +366,7 @@ export function executeCompositorGraph( const height = options.height ?? 1; output = allocate(width, height); const color = node.properties.color as number[]; - for (let offset = 0; offset < output.data.length; offset += 4) output.data.set(color, offset); + for (let offset = 0; offset < output.data.length; offset += 4) { checkCancelled(); output.data.set(color, offset); } } else if (node.type === "TRANSFORM") { const input = requireInput(id, "Image"); @@ -305,6 +374,7 @@ export function executeCompositorGraph( const tx = Number(node.properties.translateX ?? 0), ty = Number(node.properties.translateY ?? 0); const sx = Number(node.properties.scaleX ?? 1), sy = Number(node.properties.scaleY ?? 1); for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) { + checkCancelled(); const sourceX = Math.round((x - tx) / sx), sourceY = Math.round((y - ty) / sy); if (sourceX < 0 || sourceX >= input.width || sourceY < 0 || sourceY >= input.height) continue; output.data.set(input.data.subarray((sourceY * input.width + sourceX) * 4, (sourceY * input.width + sourceX) * 4 + 4), (y * input.width + x) * 4); @@ -315,6 +385,7 @@ export function executeCompositorGraph( output = allocate(input.width, input.height); const multiplier = node.type === "EXPOSURE" ? 2 ** Number(node.properties.exposure ?? 0) : 1; for (let offset = 0; offset < input.data.length; offset += 4) { + checkCancelled(); for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = node.type === "INVERT" ? 1 - input.data[offset + channel] : input.data[offset + channel] * multiplier; output.data[offset + 3] = input.data[offset + 3]; } @@ -325,6 +396,7 @@ export function executeCompositorGraph( sameSize(left, right, id); output = allocate(left.width, left.height); for (let offset = 0; offset < left.data.length; offset += 4) { + checkCancelled(); if (node.type === "MIX") { const factor = Number(node.properties.factor ?? 0.5); for (let channel = 0; channel < 4; channel++) output.data[offset + channel] = left.data[offset + channel] * (1 - factor) + right.data[offset + channel] * factor; @@ -344,6 +416,7 @@ export function executeCompositorGraph( if (operations > COMPOSITOR_BUDGET.maxOperations) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${id} exceeds the blur operation budget`); output = allocate(input.width, input.height); for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) { + checkCancelled((radius * 2 + 1) ** 2); const target = (y * input.width + x) * 4; let samples = 0; for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) { @@ -366,3 +439,52 @@ export function executeCompositorGraph( }; return { composite: evaluate(graph.outputNodeId), viewers, evaluatedNodeIds }; } + +async function sha256Bytes(data: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); +} + +function stableJSON(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`; + if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`; + return JSON.stringify(value); +} + +export async function compositorFrameCacheKey( + value: unknown, + sourceImages: ReadonlyMap, + frame: number, + width?: number, + height?: number, +): Promise { + const graph = parseCompositorGraph(value); + if (!Number.isSafeInteger(frame) || frame < -1_000_000 || frame > 1_000_000) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "Compositor cache frame is invalid"); + const sources: Array<{ id: string; width: number; height: number; sha256: string }> = []; + for (const resource of [...graph.resources].sort((left, right) => left.sourceId.localeCompare(right.sourceId))) { + const image = sourceImages.get(resource.sourceId); + if (!image) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resource ${resource.sourceId}`); + validateImage(image, resource.sourceId); + const sha256 = await sha256Bytes(image.data.buffer.slice(image.data.byteOffset, image.data.byteOffset + image.data.byteLength) as ArrayBuffer); + if (resource.sha256 && resource.sha256 !== sha256) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Compositor resource ${resource.sourceId} failed SHA-256 verification`); + sources.push({ id: resource.sourceId, width: image.width, height: image.height, sha256 }); + } + const descriptor = new TextEncoder().encode(stableJSON({ graph, sources, frame, width: width ?? null, height: height ?? null })); + return sha256Bytes(descriptor.buffer as ArrayBuffer); +} + +export async function executeCompositorGraphCached( + value: unknown, + sourceImages: ReadonlyMap, + cache: CompositorFrameCache, + options: { frame: number; width?: number; height?: number; cancelled?: () => boolean }, +): Promise { + 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"); + const cached = cache.get(cacheKey); + if (cached) return { ...cached, cacheKey, cacheHit: true }; + const result = executeCompositorGraph(value, sourceImages, options); + cache.set(cacheKey, result); + return { ...result, cacheKey, cacheHit: false }; +} diff --git a/web/protocol/editor-workflow.ts b/web/protocol/editor-workflow.ts index 8ea1edf7..e1588dc5 100644 --- a/web/protocol/editor-workflow.ts +++ b/web/protocol/editor-workflow.ts @@ -12,8 +12,18 @@ export interface EditorRegionIR { id: string; kind: EditorRegionKind; visible: b export interface EditorAreaIR { id: string; editor: EditorTypeIR; regions: EditorRegionIR[]; rect: { x: number; y: number; width: number; height: number }; maximized: boolean } export interface EditorWorkspaceIR { id: string; name: string; areas: EditorAreaIR[]; activeAreaId: string; revision: number } export interface EditorContextIR { workspaceId: string; activeAreaId: string; activeEditor: EditorTypeIR; mode: EditorMode; activeObjectId: string | null; selection: string[]; viewLayer: string; pinnedData: string | null; revision: number } -export interface KeymapBindingIR { id: string; key: string; modifiers: string[]; command: string; enabled: boolean } +export interface KeymapBindingIR { + id: string; + key: string; + modifiers: string[]; + command: string; + enabled: boolean; + workspaceIds?: string[]; + editors?: EditorTypeIR[]; + modes?: EditorMode[]; +} export interface EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[] } +export interface KeyChordIR { key: string; modifiers: Array<"ALT" | "CTRL" | "META" | "SHIFT"> } export type EditorWorkflowEditIR = | { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string } | { type: "SET_ACTIVE_AREA"; revision: number; areaId: string } @@ -38,6 +48,8 @@ function rect(value: unknown, name: string): EditorAreaIR["rect"] { return next; } function overlap(a: EditorAreaIR["rect"], b: EditorAreaIR["rect"]): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; } +function scopeOverlaps(left: readonly T[] | undefined, right: readonly T[] | undefined): boolean { return !left?.length || !right?.length || left.some((value) => right.includes(value)); } +function keymapScopesOverlap(left: KeymapBindingIR, right: KeymapBindingIR): boolean { return scopeOverlaps(left.workspaceIds, right.workspaceIds) && scopeOverlaps(left.editors, right.editors) && scopeOverlaps(left.modes, right.modes); } export function parseEditorWorkflow(value: unknown): EditorWorkflowIR { if (!record(value) || value.schemaVersion !== EDITOR_WORKFLOW_SCHEMA || !Array.isArray(value.workspaces) || !record(value.context) || !Array.isArray(value.keymaps)) throw new EditorWorkflowValidationError("PROTOCOL_MISMATCH", "Unsupported editor workflow schema"); @@ -64,10 +76,47 @@ export function parseEditorWorkflow(value: unknown): EditorWorkflowIR { if (!EDITOR_TYPES.includes(contextValue.activeEditor as EditorTypeIR) || !["OBJECT", "EDIT", "POSE"].includes(contextValue.mode as string) || !Array.isArray(contextValue.selection)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context is invalid"); if (contextValue.selection.length > EDITOR_WORKFLOW_BUDGET.maxSelection || contextValue.selection.some((item) => typeof item !== "string" || item.length === 0)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection exceeds the budget"); if (contextValue.activeObjectId !== null && typeof contextValue.activeObjectId !== "string") throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object is invalid"); - const keymapIds = new Set(); const keymaps = value.keymaps.map((bindingValue, index): KeymapBindingIR => { const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`); const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id); const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16)); if (new Set(modifiers).size !== modifiers.length) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers duplicate`); return { id, key: text(bindingValue.key, `${name}.key`, 32), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false }; }); + const keymapIds = new Set(); const keymaps: KeymapBindingIR[] = []; + value.keymaps.forEach((bindingValue, index) => { + const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`); + const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id); + const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16).toUpperCase()); if (new Set(modifiers).size !== modifiers.length || modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers are invalid or duplicate`); modifiers.sort(); + const parseScope = (field: "workspaceIds" | "editors" | "modes", allowed?: readonly T[]): T[] | undefined => { + const source = bindingValue[field]; if (source === undefined) return undefined; + if (!Array.isArray(source) || source.length === 0 || source.length > 64) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid`); + const parsed = source.map((item, scopeIndex) => text(item, `${name}.${field}[${scopeIndex}]`, 256) as T); + if (new Set(parsed).size !== parsed.length || (allowed && parsed.some((item) => !allowed.includes(item)))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid or duplicated`); + return parsed; + }; + const binding: KeymapBindingIR = { id, key: text(bindingValue.key, `${name}.key`, 32).toUpperCase(), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false, workspaceIds: parseScope("workspaceIds"), editors: parseScope("editors", EDITOR_TYPES), modes: parseScope("modes", ["OBJECT", "EDIT", "POSE"] as const) }; + if (binding.workspaceIds?.some((workspace) => !workspaceIds.has(workspace))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.workspaceIds references a missing workspace`); + if (binding.enabled && keymaps.some((candidate) => candidate.enabled && candidate.key === binding.key && candidate.modifiers.join("+") === binding.modifiers.join("+") && keymapScopesOverlap(candidate, binding))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} conflicts with another enabled keymap in the same context`); + keymaps.push(binding); + }); return { schemaVersion: EDITOR_WORKFLOW_SCHEMA, workspaces, context: { workspaceId, activeAreaId, activeEditor: contextValue.activeEditor as EditorTypeIR, mode: contextValue.mode as EditorMode, activeObjectId: contextValue.activeObjectId as string | null, selection: [...contextValue.selection] as string[], viewLayer: text(contextValue.viewLayer, "context.viewLayer"), pinnedData: contextValue.pinnedData === null ? null : text(contextValue.pinnedData, "context.pinnedData"), revision: integer(contextValue.revision, "context.revision", 0, Number.MAX_SAFE_INTEGER) }, keymaps }; } +export function keyChordFromKeyboardEvent(event: Pick): KeyChordIR { + const key = text(event.key, "event.key", 32).toUpperCase(); + const modifiers: KeyChordIR["modifiers"] = []; + if (event.altKey) modifiers.push("ALT"); + if (event.ctrlKey) modifiers.push("CTRL"); + if (event.metaKey) modifiers.push("META"); + if (event.shiftKey) modifiers.push("SHIFT"); + return { key, modifiers }; +} + +export function resolveKeymapCommand(value: unknown, chordValue: unknown): string | null { + const workflow = parseEditorWorkflow(value); + if (!record(chordValue) || !Array.isArray(chordValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord is invalid"); + const chord = { key: text(chordValue.key, "keyChord.key", 32).toUpperCase(), modifiers: chordValue.modifiers.map((modifier, index) => text(modifier, `keyChord.modifiers[${index}]`, 16).toUpperCase()).sort() }; + if (new Set(chord.modifiers).size !== chord.modifiers.length || chord.modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord modifiers are invalid"); + return workflow.keymaps.find((binding) => binding.enabled && binding.key === chord.key && binding.modifiers.join("+") === chord.modifiers.join("+") && + (!binding.workspaceIds || binding.workspaceIds.includes(workflow.context.workspaceId)) && + (!binding.editors || binding.editors.includes(workflow.context.activeEditor)) && + (!binding.modes || binding.modes.includes(workflow.context.mode)))?.command ?? null; +} + export function applyEditorWorkflowEdit(value: unknown, edit: EditorWorkflowEditIR): EditorWorkflowIR { const workflow = parseEditorWorkflow(value); if (edit.revision !== workflow.context.revision) throw new EditorWorkflowValidationError("REVISION_CONFLICT", "Editor context revision is stale"); const clone = structuredClone(workflow); if (edit.type === "SWITCH_WORKSPACE") { const workspace = clone.workspaces.find((item) => item.id === edit.workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown workspace ${edit.workspaceId}`); clone.context.workspaceId = workspace.id; clone.context.activeAreaId = workspace.activeAreaId; clone.context.activeEditor = workspace.areas.find((item) => item.id === workspace.activeAreaId)?.editor ?? "VIEW_3D"; } diff --git a/web/protocol/error.ts b/web/protocol/error.ts index 722a719e..73482316 100644 --- a/web/protocol/error.ts +++ b/web/protocol/error.ts @@ -58,6 +58,14 @@ export type ErrorCode = | "NON_MESH_RESOURCE_OUTSIDE_PROJECT" | "NON_MESH_VDB_BUDGET_EXCEEDED" | "NON_MESH_BINARY_INVALID" + | "VDB_CONVERSION_REQUIRED" + | "VDB_CONVERTER_UNAVAILABLE" + | "VDB_CONVERSION_INVALID" + | "NANOVDB_MANIFEST_INVALID" + | "NANOVDB_HASH_MISMATCH" + | "NANOVDB_STREAM_INCOMPLETE" + | "NANOVDB_GRID_UNSUPPORTED" + | "NANOVDB_GPU_BUDGET_EXCEEDED" | "NON_MESH_DATA_SHARED" | "NON_MESH_PROPERTY_INVALID" | "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED" diff --git a/web/protocol/nonmesh-interaction.ts b/web/protocol/nonmesh-interaction.ts index a28eaf8d..6867e184 100644 --- a/web/protocol/nonmesh-interaction.ts +++ b/web/protocol/nonmesh-interaction.ts @@ -21,14 +21,26 @@ export interface CurveGizmoDragIR { baseRevision: number; phase: CurveGizmoPhase; axis: 0 | 1 | 2; + axisVector?: [number, number, number]; delta: [number, number, number]; handles: CurveGizmoHandleIR[]; } +export interface CurveGizmoFrameIR { + origin: [number, number, number]; + axes: [[number, number, number], [number, number, number], [number, number, number]]; +} + +export interface CurveGizmoScreenFrameIR { + origin: [number, number]; + axes: [[number, number], [number, number], [number, number]]; +} + export interface AppliedCurveGizmoDragIR { dataId: string; phase: CurveGizmoPhase; axis: 0 | 1 | 2; + axisVector?: [number, number, number]; revision: number; handles: CurveGizmoHandleIR[]; } @@ -57,6 +69,64 @@ function vector(value: unknown, path: string): [number, number, number] { return [finite(value[0], `${path}[0]`), finite(value[1], `${path}[1]`), finite(value[2], `${path}[2]` )]; } +function length(value: readonly number[]): number { + return Math.hypot(value[0], value[1], value[2]); +} + +function normalize(value: readonly number[], path: string): [number, number, number] { + const magnitude = length(value); + if (!Number.isFinite(magnitude) || magnitude < 1e-8) fail(path, "must have a finite non-zero direction"); + return [value[0] / magnitude, value[1] / magnitude, value[2] / magnitude]; +} + +function dot(left: readonly number[], right: readonly number[]): number { + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; +} + +function cross(left: readonly number[], right: readonly number[]): [number, number, number] { + return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]]; +} + +export function deriveCurveHandleGizmoFrame(controlPoints: ArrayLike, handles: readonly CurveGizmoHandleIR[]): CurveGizmoFrameIR { + if (controlPoints.length === 0 || controlPoints.length % 3 !== 0) fail("controlPoints", "must contain finite XYZ coordinates"); + for (let index = 0; index < controlPoints.length; index += 1) { + if (!Number.isFinite(controlPoints[index])) fail("controlPoints", "must contain finite XYZ coordinates"); + } + if (handles.length === 0 || handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget"); + const ordered = [...handles].sort((left, right) => left.pointIndex - right.pointIndex || left.side.localeCompare(right.side)); + const origin: [number, number, number] = [0, 0, 0]; + const directions: Array<[number, number, number]> = []; + for (const [index, handle] of ordered.entries()) { + if (!Number.isSafeInteger(handle.pointIndex) || handle.pointIndex < 0 || handle.pointIndex * 3 + 2 >= controlPoints.length || handle.side === "CONTROL") fail(`handles[${index}]`, "must identify a Curve handle with an existing control point"); + vector(handle.position, `handles[${index}].position`); + origin[0] += handle.position[0]; + origin[1] += handle.position[1]; + origin[2] += handle.position[2]; + const point = handle.pointIndex * 3; + directions.push(normalize([ + handle.position[0] - controlPoints[point], + handle.position[1] - controlPoints[point + 1], + handle.position[2] - controlPoints[point + 2], + ], `handles[${index}].direction`)); + } + origin[0] /= ordered.length; + origin[1] /= ordered.length; + origin[2] /= ordered.length; + const reference = directions[0]; + const aligned = directions.map((direction) => dot(direction, reference) < 0 ? direction.map((value) => -value) as [number, number, number] : direction); + const axisX = normalize(aligned.reduce<[number, number, number]>((sum, direction) => [sum[0] + direction[0], sum[1] + direction[1], sum[2] + direction[2]], [0, 0, 0]), "handles.directionAverage"); + const up: [number, number, number] = Math.abs(axisX[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0]; + const axisY = normalize(cross(up, axisX), "gizmo.axisY"); + const axisZ = normalize(cross(axisX, axisY), "gizmo.axisZ"); + return { origin, axes: [axisX, axisY, axisZ] }; +} + +export function curveGizmoAxisDelta(frame: CurveGizmoFrameIR, axis: 0 | 1 | 2, amount: number): [number, number, number] { + if (!Number.isFinite(amount) || Math.abs(amount) > CURVE_GIZMO_BUDGET.maxCoordinate) fail("amount", "is outside the finite coordinate budget"); + const direction = normalize(frame.axes[axis], `frame.axes[${axis}]`); + return [direction[0] * amount, direction[1] * amount, direction[2] * amount]; +} + export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number): CurveGizmoDragIR { const drag = record(value, "drag"); if (drag.schemaVersion !== CURVE_GIZMO_SCHEMA) fail("schemaVersion", "is unsupported"); @@ -66,7 +136,12 @@ export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number): if (drag.phase !== "PREVIEW" && drag.phase !== "COMMIT") fail("phase", "is invalid"); if (drag.axis !== 0 && drag.axis !== 1 && drag.axis !== 2) fail("axis", "must be X, Y or Z"); const delta = vector(drag.delta, "delta"); - if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis"); + const axisVector = drag.axisVector === undefined ? undefined : normalize(vector(drag.axisVector, "axisVector"), "axisVector"); + if (axisVector) { + const deltaLength = length(delta); + if (deltaLength > 0 && length(cross(delta, axisVector)) > Math.max(1e-7, deltaLength * 1e-6)) fail("delta", "must be parallel to the selected local axis"); + } + else if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis"); if (!Array.isArray(drag.handles) || drag.handles.length === 0 || drag.handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget"); const seen = new Set(); const handles = drag.handles.map((item, index) => { @@ -79,7 +154,7 @@ export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number): seen.add(key); return { pointIndex, side, position: vector(handle.position, `handles[${index}].position`) }; }); - return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, delta, handles }; + return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, axisVector, delta, handles }; } export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number): AppliedCurveGizmoDragIR { @@ -89,5 +164,5 @@ export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number): position: [handle.position[0] + drag.delta[0], handle.position[1] + drag.delta[1], handle.position[2] + drag.delta[2]] as [number, number, number], })); handles.forEach((handle, index) => vector(handle.position, `handles[${index}].position`)); - return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles }; + return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, axisVector: drag.axisVector, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles }; } diff --git a/web/protocol/paint.ts b/web/protocol/paint.ts index ac19ed3d..fda06b43 100644 --- a/web/protocol/paint.ts +++ b/web/protocol/paint.ts @@ -3,6 +3,7 @@ export const PAINT_BUDGET = { maxWeightEntries: 1_000_000, maxTextureTileBytes: 256 * 1024 * 1024, maxStrokeBytes: 64 * 1024 * 1024, + maxSpatialCells: 1_000_000, } as const; export type PaintMode = "VERTEX_COLOR" | "WEIGHT" | "TEXTURE"; @@ -51,6 +52,112 @@ export interface PaintBrushVertexIR { export interface PaintBrushWeightIR { index: number; weight: number } +export interface PaintBrushQueryOptionsIR { + ignoreOccluded?: boolean; + frontFaceOnly?: boolean; + viewDirection?: [number, number, number]; + visibleVertexIndices?: readonly number[]; + requireVisibility?: boolean; + selectedVertexIndices?: readonly number[]; + requireSelection?: boolean; + maskWeights?: readonly PaintBrushWeightIR[]; +} + +function validateBrushGateIdentities(vertices: readonly PaintBrushVertexIR[], options: PaintBrushQueryOptionsIR): void { + const known = new Set(vertices.map((vertex) => vertex.index)); + for (const [path, values] of [["visibleVertexIndices", options.visibleVertexIndices], ["selectedVertexIndices", options.selectedVertexIndices]] as const) { + values?.forEach((value, index) => { + const vertexIndex = integer(value, `${path}[${index}]`); + if (!known.has(vertexIndex)) fail(`${path}[${index}]`, "references an unknown vertex identity"); + }); + } + options.maskWeights?.forEach((value, index) => { + const entry = record(value, `maskWeights[${index}]`); + const vertexIndex = integer(entry.index, `maskWeights[${index}].index`); + if (!known.has(vertexIndex)) fail(`maskWeights[${index}].index`, "references an unknown vertex identity"); + }); +} + +export interface PaintBrushSpatialIndex { + readonly schemaVersion: 1; + readonly cellSize: number; + readonly vertices: readonly PaintBrushVertexIR[]; + readonly cells: ReadonlyMap; +} + +export interface PaintBrushSpatialQueryIR { + weights: PaintBrushWeightIR[]; + candidateCount: number; + visitedCellCount: number; +} + +export interface PaintColorPatchIR { indices: number[]; colors: number[] } + +function parseBrushWeights(value: unknown, path = "brushWeights"): PaintBrushWeightIR[] { + if (!Array.isArray(value) || value.length > PAINT_BUDGET.maxWeightEntries) fail(path, "exceeds the brush patch budget", true); + if (value.length === 0) fail(path, "must contain at least one brush hit"); + const seen = new Set(); + return value.map((item, index) => { + const entry = record(item, `${path}[${index}]`); + const vertexIndex = integer(entry.index, `${path}[${index}].index`); + const weight = finite(entry.weight, `${path}[${index}].weight`); + if (weight < 0 || weight > 1) fail(`${path}[${index}].weight`, "must be in [0,1]"); + if (seen.has(vertexIndex)) fail(`${path}[${index}].index`, "contains a duplicate vertex"); + seen.add(vertexIndex); + return { index: vertexIndex, weight }; + }).sort((left, right) => left.index - right.index); +} + +export function composePaintWeightPatch( + objectId: string, + vertexGroup: string, + revision: number, + currentRevision: number, + currentWeightsValue: unknown, + brushWeightsValue: unknown, + targetValue: unknown, +): WeightPatchIR { + const parsedRevision = integer(revision, "revision"); + if (parsedRevision !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint weight stroke is stale"); + if (!Array.isArray(currentWeightsValue)) fail("currentWeights", "must be an array"); + const currentWeights = currentWeightsValue.map((value, index) => { + const weight = finite(value, `currentWeights[${index}]`); + if (weight < 0 || weight > 1) fail(`currentWeights[${index}]`, "must be in [0,1]"); + return weight; + }); + const target = finite(targetValue, "targetWeight"); + if (target < 0 || target > 1) fail("targetWeight", "must be in [0,1]"); + const weights = parseBrushWeights(brushWeightsValue); + if (weights.some((entry) => entry.index >= currentWeights.length)) fail("brushWeights", "references an unknown current weight"); + return parseWeightPatch({ schemaVersion: 1, objectId, revision: parsedRevision, vertexGroup, indices: weights.map((entry) => entry.index), values: weights.map((entry) => currentWeights[entry.index] + (target - currentWeights[entry.index]) * entry.weight), normalize: false }); +} + +export function composePaintColorPatch( + revision: number, + currentRevision: number, + currentColorsValue: unknown, + brushWeightsValue: unknown, + targetColorValue: unknown, +): PaintColorPatchIR { + if (integer(revision, "revision") !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint color stroke is stale"); + if (!Array.isArray(currentColorsValue) || currentColorsValue.length % 4 !== 0) fail("currentColors", "must contain RGBA values"); + const currentColors = currentColorsValue.map((value, index) => { + const component = finite(value, `currentColors[${index}]`); + if (component < 0 || component > 1) fail(`currentColors[${index}]`, "must be in [0,1]"); + return component; + }); + const target = tuple(targetColorValue, 4, "targetColor"); + if (target.some((component) => component < 0 || component > 1)) fail("targetColor", "must be in [0,1]"); + const weights = parseBrushWeights(brushWeightsValue); + if (weights.some((entry) => entry.index >= currentColors.length / 4)) fail("brushWeights", "references an unknown current color"); + return { + indices: weights.map((entry) => entry.index), + colors: weights.flatMap((entry) => Array.from({ length: 4 }, (_, component) => currentColors[entry.index * 4 + component] + (target[component] - currentColors[entry.index * 4 + component]) * entry.weight)), + }; +} + +const paintBrushSpatialIndexes = new WeakMap }>(); + export interface UdimTilePatchIR { schemaVersion: 1; textureAssetId: string; @@ -178,14 +285,33 @@ export function parseWeightPatch(value: unknown): WeightPatchIR { return result; } -export function computePaintBrushWeights( - verticesValue: unknown, +function parsePaintBrushVertices(verticesValue: unknown): PaintBrushVertexIR[] { + if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true); + const seen = new Set(); + return verticesValue.map((item, vertexIndex) => { + const vertex = record(item, `vertices[${vertexIndex}]`); + const index = integer(vertex.index, `vertices[${vertexIndex}].index`); + if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex"); + seen.add(index); + const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number]; + if (position.some((component) => Math.abs(component) > 1_000_000_000)) fail(`vertices[${vertexIndex}].position`, "is outside the spatial index range"); + const result: PaintBrushVertexIR = { index, position }; + if (vertex.occluded !== undefined) { + if (typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean"); + result.occluded = vertex.occluded; + } + if (vertex.normal !== undefined) result.normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number]; + return result; + }); +} + +function brushWeights( + vertices: readonly PaintBrushVertexIR[], centerValue: unknown, radiusValue: unknown, strengthValue: unknown, - options: { ignoreOccluded?: boolean; frontFaceOnly?: boolean; viewDirection?: [number, number, number] } = {}, + options: PaintBrushQueryOptionsIR = {}, ): PaintBrushWeightIR[] { - if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true); const center = tuple(centerValue, 3, "center") as [number, number, number]; const radius = finite(radiusValue, "radius"); const strength = finite(strengthValue, "strength"); @@ -193,30 +319,112 @@ export function computePaintBrushWeights( if (strength < 0 || strength > 1) fail("strength", "must be in [0,1]"); const viewDirection = options.viewDirection ?? [0, 0, -1]; tuple(viewDirection, 3, "viewDirection"); + if (options.requireVisibility && options.visibleVertexIndices === undefined) fail("visibleVertexIndices", "is required for depth-gated brush queries"); + if (options.visibleVertexIndices !== undefined && (!Array.isArray(options.visibleVertexIndices) || options.visibleVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("visibleVertexIndices", "exceeds the visibility budget", true); + const visible = options.visibleVertexIndices === undefined ? undefined : new Set(options.visibleVertexIndices.map((value, index) => integer(value, `visibleVertexIndices[${index}]`))); + if (visible && visible.size !== options.visibleVertexIndices?.length) fail("visibleVertexIndices", "contains duplicates"); + if (options.requireSelection && options.selectedVertexIndices === undefined) fail("selectedVertexIndices", "is required for selection-gated brush queries"); + if (options.selectedVertexIndices !== undefined && (!Array.isArray(options.selectedVertexIndices) || options.selectedVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("selectedVertexIndices", "exceeds the selection budget", true); + const selected = options.selectedVertexIndices === undefined ? undefined : new Set(options.selectedVertexIndices.map((value, index) => integer(value, `selectedVertexIndices[${index}]`))); + if (selected && selected.size !== options.selectedVertexIndices?.length) fail("selectedVertexIndices", "contains duplicates"); + if (options.maskWeights !== undefined && (!Array.isArray(options.maskWeights) || options.maskWeights.length > PAINT_BUDGET.maxWeightEntries)) fail("maskWeights", "exceeds the mask budget", true); + const mask = options.maskWeights === undefined ? undefined : new Map(); + options.maskWeights?.forEach((value, index) => { + const entry = record(value, `maskWeights[${index}]`); + const vertexIndex = integer(entry.index, `maskWeights[${index}].index`); + const weight = finite(entry.weight, `maskWeights[${index}].weight`); + if (weight < 0 || weight > 1) fail(`maskWeights[${index}].weight`, "must be in [0,1]"); + if (mask!.has(vertexIndex)) fail(`maskWeights[${index}].index`, "contains a duplicate vertex"); + mask!.set(vertexIndex, weight); + }); const result: PaintBrushWeightIR[] = []; - const seen = new Set(); - for (const [vertexIndex, item] of verticesValue.entries()) { - const vertex = record(item, `vertices[${vertexIndex}]`); - const index = integer(vertex.index, `vertices[${vertexIndex}].index`); - if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex"); - seen.add(index); - const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number]; - if (vertex.occluded !== undefined && typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean"); + for (const vertex of vertices) { + const { index, position } = vertex; + if (visible && !visible.has(index)) continue; + if (selected && !selected.has(index)) continue; + const maskWeight = mask?.get(index) ?? (mask ? 0 : 1); + if (maskWeight === 0) continue; if (options.ignoreOccluded !== false && vertex.occluded === true) continue; if (vertex.normal !== undefined) { - const normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number]; + const normal = tuple(vertex.normal, 3, `vertices[${index}].normal`) as [number, number, number]; if (options.frontFaceOnly && normal[0] * viewDirection[0] + normal[1] * viewDirection[1] + normal[2] * viewDirection[2] >= 0) continue; } const distance = Math.hypot(position[0] - center[0], position[1] - center[1], position[2] - center[2]); if (distance > radius) continue; const normalized = distance / radius; const smoothstep = 1 - normalized * normalized * (3 - 2 * normalized); - const weight = Math.max(0, Math.min(1, strength * smoothstep)); + const weight = Math.max(0, Math.min(1, strength * smoothstep * maskWeight)); if (weight > 0) result.push({ index, weight }); } return result.sort((left, right) => left.index - right.index); } +export function computePaintBrushWeights( + verticesValue: unknown, + centerValue: unknown, + radiusValue: unknown, + strengthValue: unknown, + options: PaintBrushQueryOptionsIR = {}, +): PaintBrushWeightIR[] { + const vertices = parsePaintBrushVertices(verticesValue); + validateBrushGateIdentities(vertices, options); + return brushWeights(vertices, centerValue, radiusValue, strengthValue, options); +} + +function spatialCell(position: readonly number[], cellSize: number): [number, number, number] { + return [Math.floor(position[0] / cellSize), Math.floor(position[1] / cellSize), Math.floor(position[2] / cellSize)]; +} + +function spatialKey(x: number, y: number, z: number): string { return `${x}:${y}:${z}`; } + +export function buildPaintBrushSpatialIndex(verticesValue: unknown, cellSizeValue: unknown): PaintBrushSpatialIndex { + const vertices = parsePaintBrushVertices(verticesValue); + const cellSize = finite(cellSizeValue, "cellSize"); + if (cellSize < 1e-6 || cellSize > 100_000) fail("cellSize", "is outside the bounded range"); + const cells = new Map(); + vertices.forEach((vertex, offset) => { + const cell = spatialCell(vertex.position, cellSize); + if (cell.some((component) => !Number.isSafeInteger(component))) fail("vertices", "produces an unsafe spatial cell"); + const key = spatialKey(...cell); + const offsets = cells.get(key) ?? []; + offsets.push(offset); + cells.set(key, offsets); + }); + if (cells.size > PAINT_BUDGET.maxSpatialCells) fail("vertices", "exceeds the spatial cell budget", true); + const publicVertices = vertices.map((vertex) => ({ ...vertex, position: [...vertex.position] as [number, number, number], ...(vertex.normal ? { normal: [...vertex.normal] as [number, number, number] } : {}) })); + const publicCells = new Map([...cells].map(([key, offsets]) => [key, [...offsets]])); + const index: PaintBrushSpatialIndex = Object.freeze({ schemaVersion: 1, cellSize, vertices: publicVertices, cells: publicCells }); + paintBrushSpatialIndexes.set(index, { cellSize, vertices, cells }); + return index; +} + +export function queryPaintBrushSpatialIndex( + index: PaintBrushSpatialIndex, + centerValue: unknown, + radiusValue: unknown, + strengthValue: unknown, + options: PaintBrushQueryOptionsIR = {}, +): PaintBrushSpatialQueryIR { + const source = paintBrushSpatialIndexes.get(index); + if (!source) fail("spatialIndex", "is invalid"); + validateBrushGateIdentities(source.vertices, options); + const center = tuple(centerValue, 3, "center") as [number, number, number]; + const radius = finite(radiusValue, "radius"); + if (radius <= 0 || radius > 100_000) fail("radius", "is outside the bounded range"); + const minimum = spatialCell(center.map((component) => component - radius), source.cellSize); + const maximum = spatialCell(center.map((component) => component + radius), source.cellSize); + const spans = maximum.map((component, axis) => component - minimum[axis] + 1); + if (spans.some((span) => !Number.isSafeInteger(span) || span <= 0) || spans[0] > PAINT_BUDGET.maxSpatialCells / spans[1] / spans[2]) fail("spatialQuery", "exceeds the visited cell budget", true); + const offsets = new Set(); + let visitedCellCount = 0; + for (let x = minimum[0]; x <= maximum[0]; x++) for (let y = minimum[1]; y <= maximum[1]; y++) for (let z = minimum[2]; z <= maximum[2]; z++) { + visitedCellCount++; + for (const offset of source.cells.get(spatialKey(x, y, z)) ?? []) offsets.add(offset); + } + const candidates = [...offsets].sort((left, right) => left - right).map((offset) => source.vertices[offset]); + return { weights: brushWeights(candidates, center, radius, strengthValue, options), candidateCount: candidates.length, visitedCellCount }; +} + export function parseUdimTilePatch(value: unknown): UdimTilePatchIR { const patch = record(value, "udimPatch"); if (patch.schemaVersion !== 1 || patch.format !== "RGBA8" || (patch.colorSpace !== "SRGB" && patch.colorSpace !== "LINEAR")) fail("udimPatch", "has an unsupported schema or pixel format"); diff --git a/web/protocol/physics-cache-playback.ts b/web/protocol/physics-cache-playback.ts new file mode 100644 index 00000000..bded96f5 --- /dev/null +++ b/web/protocol/physics-cache-playback.ts @@ -0,0 +1,182 @@ +import { decodeBrowserTransformCacheFrame, PhysicsSimulationValidationError, type BrowserTransformCacheObjectIR } from "./physics-simulation"; +import type { SceneNodeIR, SceneSnapshotIR } from "./scene-ir"; + +export interface BrowserTransformCacheFrameSource { + readonly frameStart: number; + readonly frameEnd: number; + readFrame(frame: number, signal: AbortSignal): Promise; +} + +export interface BrowserTransformCachePlaybackResult { + status: "COMPLETED" | "CANCELLED"; + appliedFrames: number; + lastFrame: number | null; +} + +function quaternionFromEuler([x, y, z]: readonly number[]): [number, number, number, number] { + const cx = Math.cos(x / 2); const sx = Math.sin(x / 2); + const cy = Math.cos(y / 2); const sy = Math.sin(y / 2); + const cz = Math.cos(z / 2); const sz = Math.sin(z / 2); + return [sx * cy * cz + cx * sy * sz, cx * sy * cz - sx * cy * sz, cx * cy * sz + sx * sy * cz, cx * cy * cz - sx * sy * sz]; +} + +function eulerFromQuaternion([x, y, z, w]: readonly number[]): [number, number, number] { + return [ + Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)), + Math.asin(Math.max(-1, Math.min(1, 2 * (w * y - z * x)))), + Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)), + ]; +} + +function composeMatrix(translation: readonly number[], quaternion: readonly number[], scale: readonly number[]): number[] { + const [x, y, z, w] = quaternion; + const x2 = x + x; const y2 = y + y; const z2 = z + z; + const xx = x * x2; const xy = x * y2; const xz = x * z2; + const yy = y * y2; const yz = y * z2; const zz = z * z2; + const wx = w * x2; const wy = w * y2; const wz = w * z2; + return [ + (1 - (yy + zz)) * scale[0], (xy + wz) * scale[0], (xz - wy) * scale[0], 0, + (xy - wz) * scale[1], (1 - (xx + zz)) * scale[1], (yz + wx) * scale[1], 0, + (xz + wy) * scale[2], (yz - wx) * scale[2], (1 - (xx + yy)) * scale[2], 0, + translation[0], translation[1], translation[2], 1, + ]; +} + +function multiplyMatrix(left: readonly number[], right: readonly number[]): number[] { + const output = new Array(16); + for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) { + output[column * 4 + row] = left[row] * right[column * 4] + left[4 + row] * right[column * 4 + 1] + left[8 + row] * right[column * 4 + 2] + left[12 + row] * right[column * 4 + 3]; + } + return output; +} + +function cacheTransform(node: SceneNodeIR, cached: BrowserTransformCacheObjectIR | undefined): { node: SceneNodeIR; quaternion: [number, number, number, number] } { + if (!cached) { + return { node: { ...node, transform: { ...node.transform }, localMatrix: [...node.localMatrix], worldMatrix: [...node.worldMatrix] }, quaternion: quaternionFromEuler(node.transform.rotationEuler) }; + } + const transform = { + ...node.transform, + translation: [...cached.translation] as [number, number, number], + rotationEuler: eulerFromQuaternion(cached.rotationQuaternion), + scale: [...cached.scale] as [number, number, number], + }; + return { node: { ...node, transform, localMatrix: composeMatrix(transform.translation, cached.rotationQuaternion, transform.scale), worldMatrix: [] }, quaternion: cached.rotationQuaternion }; +} + +/** Applies the browser-owned BTF1 transform cache as an immutable SceneIR preview. */ +export function applyBrowserTransformCachePreview(snapshot: SceneSnapshotIR, value: ArrayBuffer, expectedFrame: number): SceneSnapshotIR { + if (!Number.isSafeInteger(expectedFrame) || expectedFrame < snapshot.frame.start || expectedFrame > snapshot.frame.end) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache frame ${expectedFrame} is outside the scene range`); + } + const frame = decodeBrowserTransformCacheFrame(value); + if (frame.frame !== expectedFrame) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache frame ${frame.frame} does not match requested frame ${expectedFrame}`); + const sourceById = new Map(snapshot.nodes.map((node) => [node.id, node])); + for (const item of frame.objects) if (!sourceById.has(item.objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache references missing ${item.objectId}`); + const cachedById = new Map(frame.objects.map((item) => [item.objectId, item])); + const states = new Map(snapshot.nodes.map((node) => [node.id, cacheTransform(node, cachedById.get(node.id))])); + const resolving = new Set(); + const resolved = new Set(); + const updateWorld = (id: string): number[] => { + const state = states.get(id); + if (!state) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Scene hierarchy references missing ${id}`); + if (resolved.has(id)) return state.node.worldMatrix; + if (resolving.has(id)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Scene hierarchy contains a cycle at ${id}`); + resolving.add(id); + if (state.node.localMatrix.length !== 16) state.node.localMatrix = composeMatrix(state.node.transform.translation, state.quaternion, state.node.transform.scale); + state.node.worldMatrix = state.node.parentId ? multiplyMatrix(updateWorld(state.node.parentId), state.node.localMatrix) : [...state.node.localMatrix]; + resolving.delete(id); + resolved.add(id); + return state.node.worldMatrix; + }; + for (const node of snapshot.nodes) updateWorld(node.id); + return { ...snapshot, frame: { ...snapshot.frame, current: frame.frame }, nodes: snapshot.nodes.map((node) => states.get(node.id)!.node) }; +} + +/** Coordinates exact-frame BTF1 reads while preventing cancelled or superseded reads from publishing. */ +export class BrowserTransformCachePlaybackSession { + private generation = 0; + private controller: AbortController | null = null; + + constructor( + private readonly baseSnapshot: SceneSnapshotIR, + private readonly source: BrowserTransformCacheFrameSource, + private readonly publish: (preview: SceneSnapshotIR) => void, + ) { + if (!Number.isSafeInteger(source.frameStart) || !Number.isSafeInteger(source.frameEnd) || + source.frameEnd < source.frameStart || source.frameStart < baseSnapshot.frame.start || source.frameEnd > baseSnapshot.frame.end) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache source range is outside the scene range"); + } + } + + cancel(): void { + this.generation += 1; + this.controller?.abort(); + this.controller = null; + } + + async seek(frame: number): Promise { + this.validateRange(frame, frame); + const generation = this.begin(); + const controller = this.controller!; + try { + const data = await this.source.readFrame(frame, controller.signal); + if (!this.isCurrent(generation, controller)) return null; + const preview = applyBrowserTransformCachePreview(this.baseSnapshot, data, frame); + if (!this.isCurrent(generation, controller)) return null; + this.publish(preview); + return preview; + } + catch (error) { + if (!this.isCurrent(generation, controller)) return null; + throw error; + } + finally { + if (this.generation === generation) this.controller = null; + } + } + + async play(frameStart = this.source.frameStart, frameEnd = this.source.frameEnd): Promise { + this.validateRange(frameStart, frameEnd); + const generation = this.begin(); + const controller = this.controller!; + let appliedFrames = 0; + let lastFrame: number | null = null; + try { + for (let frame = frameStart; frame <= frameEnd; frame += 1) { + const data = await this.source.readFrame(frame, controller.signal); + if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame }; + const preview = applyBrowserTransformCachePreview(this.baseSnapshot, data, frame); + if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame }; + this.publish(preview); + appliedFrames += 1; + lastFrame = frame; + } + return { status: "COMPLETED", appliedFrames, lastFrame }; + } + catch (error) { + if (!this.isCurrent(generation, controller)) return { status: "CANCELLED", appliedFrames, lastFrame }; + throw error; + } + finally { + if (this.generation === generation) this.controller = null; + } + } + + private begin(): number { + this.controller?.abort(); + this.controller = new AbortController(); + this.generation += 1; + return this.generation; + } + + private isCurrent(generation: number, controller: AbortController): boolean { + return generation === this.generation && this.controller === controller && !controller.signal.aborted; + } + + private validateRange(frameStart: number, frameEnd: number): void { + if (!Number.isSafeInteger(frameStart) || !Number.isSafeInteger(frameEnd) || frameStart < this.source.frameStart || + frameEnd > this.source.frameEnd || frameEnd < frameStart) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache has no verified range ${frameStart}-${frameEnd}`); + } + } +} diff --git a/web/protocol/release-gate.ts b/web/protocol/release-gate.ts index 8cc3809f..422a86c7 100644 --- a/web/protocol/release-gate.ts +++ b/web/protocol/release-gate.ts @@ -3,7 +3,7 @@ import type { ErrorCode } from "./error"; export const RELEASE_GATE_SCHEMA = 3 as const; export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED"; -export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; acceptance: string[]; dependencies: string[] } +export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; excludedSlices: string[]; acceptance: string[]; dependencies: string[] } export interface ReleaseEvidenceIR { browser: { chromium: boolean }; runtime: { offline: boolean; workerRestart: boolean; opfsRecovery: boolean }; @@ -26,6 +26,7 @@ function record(value: unknown): value is Record { return typeo function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return value; } function bool(value: unknown, name: string): boolean { if (typeof value !== "boolean") throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be boolean`); return value; } function strings(value: unknown, name: string, maximum = 100_000): string[] { if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return [...value] as string[]; } +function utcTimestamp(value: unknown, name: string): string { const result = text(value, name, 128); const date = new Date(result); if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(result) || !Number.isFinite(date.getTime()) || date.toISOString() !== result) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be a canonical UTC timestamp`); return result; } function parseEvidence(value: unknown): ReleaseEvidenceIR { if (!record(value) || !record(value.browser) || !record(value.runtime) || !record(value.performance) || !record(value.faults) || !record(value.provenance)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "Release evidence groups are missing"); @@ -37,10 +38,13 @@ function parseEvidence(value: unknown): ReleaseEvidenceIR { const id = text(item.id, `${name}.id`); if (recordIds.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate evidence record ${id}`); recordIds.add(id); const fields = strings(item.fields, `${name}.fields`, 64); if (new Set(fields).size !== fields.length || fields.some((field) => !/^(browser|runtime|performance|faults|provenance)\.[A-Za-z0-9]+$/.test(field))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.fields is invalid`); if (item.exitCode !== 0 || typeof item.durationMs !== "number" || !Number.isSafeInteger(item.durationMs) || item.durationMs < 0 || item.durationMs > 86_400_000) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} did not complete successfully`); - const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if (artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is invalid`); + const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if ((fields.length > 0 && artifactSha256.length === 0) || new Set(artifactSha256).size !== artifactSha256.length || artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is missing, duplicate or invalid`); return { id, fields, command: text(item.command, `${name}.command`, 2048), exitCode: 0, durationMs: item.durationMs, output: text(item.output, `${name}.output`, 4096), artifactSha256 }; }); const parsed = { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"], records }; + const knownFields = new Map(); + for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record]>) for (const [key, enabled] of Object.entries(groupValues)) knownFields.set(`${groupName}.${key}`, enabled); + for (const evidenceRecord of records) for (const field of evidenceRecord.fields) if (knownFields.get(field) !== true) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Evidence record ${evidenceRecord.id} binds unknown or disabled field ${field}`); for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record]>) { for (const [key, enabled] of Object.entries(groupValues)) if (enabled && !records.some((item) => item.fields.includes(`${groupName}.${key}`))) throw new ReleaseGateValidationError("RELEASE_EVIDENCE_MISSING", `Enabled evidence ${groupName}.${key} has no successful record`); } @@ -55,10 +59,10 @@ function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void { export function parseReleaseManifest(value: unknown): ReleaseManifestIR { if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema"); - const ids = new Set(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; }); + const ids = new Set(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`); const declared = [...completedSlices, ...blockedSlices, ...excludedSlices]; if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one state`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, excludedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; }); assertDependencies(families); const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid"); - return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) }; + return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: utcTimestamp(value.generatedAt, "generatedAt"), families, evidence: parseEvidence(value.evidence) }; } export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR { diff --git a/web/protocol/scene-delta.ts b/web/protocol/scene-delta.ts index c1a3e826..6e94f892 100644 --- a/web/protocol/scene-delta.ts +++ b/web/protocol/scene-delta.ts @@ -1,4 +1,4 @@ -import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneNodeIR, SceneSnapshotIR } from "./scene-ir"; +import type { AnimationIR, CameraIR, LightIR, MaterialIR, MeshSummaryIR, SceneIR, SceneNodeIR, SceneSnapshotIR, WorldIR } from "./scene-ir"; export interface SceneCollectionDelta { updated: Array & Partial>; @@ -20,11 +20,13 @@ export interface SceneDelta { animations?: SceneCollectionDelta; cameras?: SceneCollectionDelta; lights?: SceneCollectionDelta; + worlds?: SceneCollectionDelta; + scenes?: SceneCollectionDelta; activeObjectId?: string | null; frame?: SceneSnapshotIR["frame"]; } -const collectionFields = ["meshes", "materials", "animations", "cameras", "lights"] as const; +const collectionFields = ["meshes", "materials", "animations", "cameras", "lights", "worlds", "scenes"] as const; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -115,6 +117,8 @@ export function diffSceneSnapshots(before: SceneSnapshotIR, after: SceneSnapshot delta.animations = diffCollection(before.animations, after.animations); delta.cameras = diffCollection(before.cameras, after.cameras); delta.lights = diffCollection(before.lights, after.lights); + delta.worlds = diffCollection(before.worlds, after.worlds); + delta.scenes = diffCollection(before.scenes, after.scenes); if (before.activeObjectId !== after.activeObjectId) delta.activeObjectId = after.activeObjectId; if (JSON.stringify(before.frame) !== JSON.stringify(after.frame)) delta.frame = after.frame; return delta; @@ -153,7 +157,14 @@ export function applySceneDelta(snapshot: SceneSnapshotIR, delta: SceneDelta): S animations: applyCollectionDelta(snapshot.animations, delta.animations), cameras: applyCollectionDelta(snapshot.cameras, delta.cameras), lights: applyCollectionDelta(snapshot.lights, delta.lights), + worlds: applyCollectionDelta(snapshot.worlds, delta.worlds), + scenes: applyCollectionDelta(snapshot.scenes, delta.scenes), activeObjectId: delta.activeObjectId === undefined ? snapshot.activeObjectId : delta.activeObjectId, frame: delta.frame ?? snapshot.frame, }; } + +export function sceneDeltaRequiresRendererRebuild(delta: SceneDelta): boolean { + return Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length || delta.meshes || delta.materials || + delta.cameras || delta.lights || delta.worlds || delta.scenes || delta.animations); +} diff --git a/web/protocol/scene-ir.ts b/web/protocol/scene-ir.ts index 29d42eaf..346528a5 100644 --- a/web/protocol/scene-ir.ts +++ b/web/protocol/scene-ir.ts @@ -371,6 +371,14 @@ export interface VFontResourceIR { packed: boolean; } +export interface NonMeshVolumePropertiesIR { + displayDensity: number; + interpolation: "NEAREST" | "LINEAR"; + stepSize: number; + velocityGrid: string; + velocityScale: number; +} + export interface NonMeshDataIR { id: string; name: string; @@ -405,6 +413,7 @@ export interface NonMeshDataIR { resourceKind?: "OPENVDB"; resourceByteLength?: number; volumeGrids?: VolumeGridMetadataIR[]; + volumeProperties?: NonMeshVolumePropertiesIR; errorCode?: "NON_MESH_DATA_UNSUPPORTED" | "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_RESOURCE_MISSING" | "NON_MESH_BINARY_INVALID" | "NON_MESH_RESOURCE_OUTSIDE_PROJECT" | "NON_MESH_VDB_BUDGET_EXCEEDED"; } diff --git a/web/protocol/scripting-platform.ts b/web/protocol/scripting-platform.ts index 47c8c6bc..def04005 100644 --- a/web/protocol/scripting-platform.ts +++ b/web/protocol/scripting-platform.ts @@ -4,7 +4,9 @@ import type { ErrorCode } from "./error"; export const SCRIPTING_PLATFORM_SCHEMA = 1 as const; export const SCRIPT_SOURCE_SCHEMA = 1 as const; -export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536 } as const; +export const SCRIPT_EXECUTION_AUDIT_SCHEMA = 1 as const; +export const SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA = 1 as const; +export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536, maxAuditEntries: 65_536 } as const; export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const; export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number]; @@ -44,6 +46,30 @@ export interface ScriptSourceIR { } export interface ScriptSourceInventoryIR { schemaVersion: typeof SCRIPT_SOURCE_SCHEMA; sources: ScriptSourceIR[] } export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" } +export interface ScriptExecutionAuditIR { + schemaVersion: typeof SCRIPT_EXECUTION_AUDIT_SCHEMA; + requestId: string; + requestedAt: string; + scriptId: string; + sourceSha256: string; + manifestSha256: string; + permissions: ScriptPermission[]; + budget: { cpuMs: number; memoryBytes: number; wallMs: number }; + approvedKey: boolean; + decision: "DENY"; + reason: "SCRIPT_SIGNATURE_INVALID" | "SCRIPT_SANDBOX_UNAVAILABLE"; + requestSha256: string; +} +export interface ScriptExecutionAuditLogEntryIR { + sequence: number; + previousEntrySha256: string | null; + audit: ScriptExecutionAuditIR; + entrySha256: string; +} +export interface ScriptExecutionAuditLogIR { + schemaVersion: typeof SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA; + entries: ScriptExecutionAuditLogEntryIR[]; +} export class ScriptingPlatformValidationError extends Error { readonly code: ErrorCode; @@ -56,6 +82,122 @@ function text(value: unknown, name: string, maximum = 256): string { if (typeof function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; } function path(value: unknown, name: string): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is outside the project`); } } function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name} exceeds the budget`); return value; } +function stableJSON(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`; + if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`; + return JSON.stringify(value); +} +async function sha256(value: string): Promise { + const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function canonicalAuditRequest(audit: Omit): Omit { + return { ...audit, permissions: [...audit.permissions].sort(), budget: { ...audit.budget } }; +} + +function isoDate(value: unknown, name: string): string { + const result = text(value, name, 64); const date = new Date(result); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(result) || !Number.isFinite(date.getTime()) || date.toISOString() !== result) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`); + return result; +} +function auditRequestId(value: unknown, name: string): string { + const result = text(value, name); + if (!/^[-A-Za-z0-9:_./]{1,256}$/.test(result)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`); + return result; +} + +function canonicalManifest(manifest: ScriptingManifestIR): ScriptingManifestIR { + return { + schemaVersion: manifest.schemaVersion, + scripts: manifest.scripts + .map((script) => ({ ...script, permissions: [...script.permissions].sort(), dependencies: script.dependencies.map((dependency) => ({ ...dependency })).sort((a, b) => a.id.localeCompare(b.id)) })) + .sort((a, b) => a.id.localeCompare(b.id)), + }; +} + +export async function createScriptExecutionAudit( + manifest: unknown, + scriptId: string, + approvedKeyIds: ReadonlySet, + options: { requestId?: string; requestedAt?: string } = {}, +): Promise { + const parsed = parseScriptingManifest(manifest); + const script = parsed.scripts.find((item) => item.id === scriptId); + if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`); + const requestId = auditRequestId(options.requestId ?? `script-audit:${scriptId}:${Date.now()}`, "requestId"); + const requestedAt = isoDate(options.requestedAt ?? new Date().toISOString(), "requestedAt"); + const approvedKey = approvedKeyIds.has(script.keyId); + const reason = approvedKey ? "SCRIPT_SANDBOX_UNAVAILABLE" : "SCRIPT_SIGNATURE_INVALID"; + const manifestSha256 = await sha256(stableJSON(canonicalManifest(parsed))); + const request = canonicalAuditRequest({ requestId, requestedAt, scriptId, sourceSha256: script.sourceSha256, manifestSha256, permissions: [...script.permissions], budget: { cpuMs: script.cpuMs, memoryBytes: script.memoryBytes, wallMs: script.wallMs }, approvedKey, decision: "DENY", reason }); + const requestSha256 = await sha256(stableJSON(request)); + return Object.freeze({ + schemaVersion: SCRIPT_EXECUTION_AUDIT_SCHEMA, + requestId, + requestedAt, + scriptId, + sourceSha256: script.sourceSha256, + manifestSha256, + permissions: Object.freeze([...script.permissions].sort()) as unknown as ScriptPermission[], + budget: Object.freeze({ cpuMs: script.cpuMs, memoryBytes: script.memoryBytes, wallMs: script.wallMs }), + approvedKey, + decision: "DENY", + reason, + requestSha256, + }); +} + +export async function parseScriptExecutionAudit(value: unknown): Promise { + if (!record(value) || value.schemaVersion !== SCRIPT_EXECUTION_AUDIT_SCHEMA || !Array.isArray(value.permissions) || !record(value.budget)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Script execution audit is invalid"); + const permissions = value.permissions.map((permission, index) => text(permission, `audit.permissions[${index}]`, 64) as ScriptPermission); + if (permissions.length > SCRIPTING_BUDGET.maxPermissions || new Set(permissions).size !== permissions.length || permissions.some((permission) => !SCRIPT_PERMISSIONS.includes(permission)) || permissions.some((permission, index) => index > 0 && permissions[index - 1] > permission)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit permissions are invalid or not canonical"); + if (typeof value.approvedKey !== "boolean" || value.decision !== "DENY" || !["SCRIPT_SIGNATURE_INVALID", "SCRIPT_SANDBOX_UNAVAILABLE"].includes(value.reason as string) || value.reason !== (value.approvedKey ? "SCRIPT_SANDBOX_UNAVAILABLE" : "SCRIPT_SIGNATURE_INVALID")) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", "Audit decision is inconsistent with the default-deny policy"); + const request = canonicalAuditRequest({ + requestId: auditRequestId(value.requestId, "audit.requestId"), + requestedAt: isoDate(value.requestedAt, "audit.requestedAt"), + scriptId: text(value.scriptId, "audit.scriptId"), + sourceSha256: digest(value.sourceSha256, "audit.sourceSha256"), + manifestSha256: digest(value.manifestSha256, "audit.manifestSha256"), + permissions, + budget: { cpuMs: integer(value.budget.cpuMs, "audit.budget.cpuMs", 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(value.budget.memoryBytes, "audit.budget.memoryBytes", 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(value.budget.wallMs, "audit.budget.wallMs", 1, SCRIPTING_BUDGET.maxWallMs) }, + approvedKey: value.approvedKey, + decision: "DENY", + reason: value.reason as ScriptExecutionAuditIR["reason"], + }); + const requestSha256 = digest(value.requestSha256, "audit.requestSha256"); + if (await sha256(stableJSON(request)) !== requestSha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit request digest does not match its canonical content"); + return { schemaVersion: SCRIPT_EXECUTION_AUDIT_SCHEMA, ...request, requestSha256 }; +} + +export async function parseScriptExecutionAuditLog(value: unknown): Promise { + if (!record(value) || value.schemaVersion !== SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA || !Array.isArray(value.entries)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script execution audit log schema"); + if (value.entries.length > SCRIPTING_BUDGET.maxAuditEntries) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script audit log exceeds the entry budget"); + const entries: ScriptExecutionAuditLogEntryIR[] = []; const requestIds = new Set(); + for (const [index, entryValue] of value.entries.entries()) { + if (!record(entryValue) || !record(entryValue.audit) || entryValue.sequence !== index + 1) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} has an invalid sequence`); + const audit = await parseScriptExecutionAudit(entryValue.audit); + if (requestIds.has(audit.requestId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit request ${audit.requestId} is replayed`); + if (entries.length > 0 && audit.requestedAt <= entries[entries.length - 1].audit.requestedAt) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit log timestamps are not strictly increasing"); + const previousEntrySha256 = index === 0 ? null : entries[index - 1].entrySha256; + if (entryValue.previousEntrySha256 !== previousEntrySha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} breaks the hash chain`); + const entrySha256 = digest(entryValue.entrySha256, `entries[${index}].entrySha256`); + if (await sha256(stableJSON({ sequence: index + 1, previousEntrySha256, audit })) !== entrySha256) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit log entry ${index} digest does not match`); + requestIds.add(audit.requestId); entries.push({ sequence: index + 1, previousEntrySha256, audit, entrySha256 }); + } + return { schemaVersion: SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA, entries }; +} + +export async function appendScriptExecutionAudit(logValue: unknown, auditValue: unknown): Promise { + const log = await parseScriptExecutionAuditLog(logValue); const audit = await parseScriptExecutionAudit(auditValue); + if (log.entries.length >= SCRIPTING_BUDGET.maxAuditEntries) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script audit log exceeds the entry budget"); + if (log.entries.some((entry) => entry.audit.requestId === audit.requestId)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Audit request ${audit.requestId} is replayed`); + const previous = log.entries.at(-1); + if (previous && audit.requestedAt <= previous.audit.requestedAt) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Audit log timestamps must be strictly increasing"); + const sequence = log.entries.length + 1; const previousEntrySha256 = previous?.entrySha256 ?? null; + const entrySha256 = await sha256(stableJSON({ sequence, previousEntrySha256, audit })); + return parseScriptExecutionAuditLog({ schemaVersion: SCRIPT_EXECUTION_AUDIT_LOG_SCHEMA, entries: [...log.entries, { sequence, previousEntrySha256, audit, entrySha256 }] }); +} export function parseScriptSourceInventory(value: unknown): ScriptSourceInventoryIR { if (!record(value) || value.schemaVersion !== SCRIPT_SOURCE_SCHEMA || !Array.isArray(value.sources)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script source inventory schema"); diff --git a/web/protocol/selection-history.ts b/web/protocol/selection-history.ts index 43864b05..3ed6575a 100644 --- a/web/protocol/selection-history.ts +++ b/web/protocol/selection-history.ts @@ -1,4 +1,4 @@ -import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; +import { readyGate, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; export const SELECTION_HISTORY_SCHEMA = 2 as const; @@ -291,6 +291,5 @@ export function parseRaycastSelectionHit(value: unknown, expectedRevision: numbe } export function gateSelectionInteraction(operation: "RAYCAST" | "HISTORY" | "GIZMO"): CapabilityGateResult { - if (operation !== "GIZMO") return readyGate("N-015", operation); - return blockedGate("N-015", operation, [capabilityIssue("CAPABILITY_MISSING", "Curve gizmo preview remains unavailable; bounded multi-handle commit is supported")]); + return readyGate("N-015", operation); } diff --git a/web/protocol/sequencer.ts b/web/protocol/sequencer.ts index cf3c7a64..25683788 100644 --- a/web/protocol/sequencer.ts +++ b/web/protocol/sequencer.ts @@ -67,6 +67,21 @@ export interface SequencerRuntimeCapabilityIR { localEncoding: "BLOCKED"; } +export interface SequencerFrameStripIR { + stripId: string; + channel: number; + sourceFrame: number; + dependencyStripIds: string[]; +} + +export interface SequencerTransitionFrameIR { + effectStripId: string; + effectType: "CROSS" | "GAMMA_CROSS"; + factor: number; + from: { stripId: string; sourceFrame: number }; + to: { stripId: string; sourceFrame: number }; +} + export class SequencerValidationError extends Error { readonly code: ErrorCode; @@ -236,6 +251,54 @@ export function sequencerSourceFrame(strip: SequencerStripIR, timelineFrame: num return Math.min(strip.sourceEnd, Math.max(strip.sourceStart, strip.sourceStart + (timelineFrame - strip.frameStart) * strip.speed)); } +export function resolveSequencerFrame(value: unknown, timelineFrame: number): SequencerFrameStripIR[] { + const timeline = parseSequencerTimeline(value); + if (!Number.isFinite(timelineFrame) || timelineFrame < timeline.frameStart || timelineFrame > timeline.frameEnd) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Timeline frame ${timelineFrame} is outside the scene range`); + const active = timeline.strips.filter((strip) => !strip.muted && timelineFrame >= strip.frameStart && timelineFrame < strip.frameEnd); + const activeIds = new Set(active.map((strip) => strip.id)); + const hiddenByMeta = new Set(active.filter((strip) => strip.type === "META").flatMap((strip) => strip.childStripIds ?? [])); + const result = active.filter((strip) => !hiddenByMeta.has(strip.id)).map((strip): SequencerFrameStripIR => { + const dependencies = [...(strip.inputStripIds ?? []), ...(strip.childStripIds ?? [])]; + if (dependencies.some((id) => !activeIds.has(id))) throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has an inactive frame dependency`); + return { stripId: strip.id, channel: strip.channel, sourceFrame: sequencerSourceFrame(strip, timelineFrame), dependencyStripIds: [...dependencies] }; + }); + result.sort((left, right) => left.channel - right.channel || left.stripId.localeCompare(right.stripId)); + return result; +} + +/** Resolves the ordered inputs and bounded progress for the verified cross-transition subset. */ +export function resolveSequencerTransitionFrame( + value: unknown, + effectStripId: string, + timelineFrame: number, +): SequencerTransitionFrameIR { + const timeline = parseSequencerTimeline(value); + const effect = timeline.strips.find((strip) => strip.id === effectStripId); + if (!effect || effect.type !== "EFFECT" || + (effect.effectType !== "CROSS" && effect.effectType !== "GAMMA_CROSS") || + effect.inputStripIds?.length !== 2) { + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${effectStripId} is not a supported two-input cross transition`); + } + if (!Number.isFinite(timelineFrame) || timelineFrame < effect.frameStart || timelineFrame >= effect.frameEnd) { + throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${effectStripId} is inactive at frame ${timelineFrame}`); + } + const [fromId, toId] = effect.inputStripIds; + const from = timeline.strips.find((strip) => strip.id === fromId); + const to = timeline.strips.find((strip) => strip.id === toId); + if (!from || !to || from.muted || to.muted || timelineFrame < from.frameStart || timelineFrame >= from.frameEnd || + timelineFrame < to.frameStart || timelineFrame >= to.frameEnd) { + throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${effectStripId} has an inactive transition input`); + } + const factor = (timelineFrame - effect.frameStart) / (effect.frameEnd - effect.frameStart); + return { + effectStripId, + effectType: effect.effectType, + factor, + from: { stripId: from.id, sourceFrame: sequencerSourceFrame(from, timelineFrame) }, + to: { stripId: to.id, sourceFrame: sequencerSourceFrame(to, timelineFrame) }, + }; +} + export function sequencerRuntimeCapabilities(scope: typeof globalThis = globalThis): SequencerRuntimeCapabilityIR { return { webCodecsVideo: "VideoDecoder" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE", diff --git a/web/protocol/tracking-mask.ts b/web/protocol/tracking-mask.ts index 7620e862..37e20a8f 100644 --- a/web/protocol/tracking-mask.ts +++ b/web/protocol/tracking-mask.ts @@ -118,6 +118,24 @@ export interface TrackingMaskProjectIR { bindings: TrackingMaskBindingIR[]; } +export interface MaskRaycastHitIR { + maskId: string; + layerId: string; + splineId: string; + kind: "POINT" | "SEGMENT"; + pointId: string; + nextPointId?: string; + distance: number; + parameter?: number; +} + +export interface MaskPointSelectionIR { + maskId: string; + layerId: string; + splineId: string; + pointId: string; +} + export type TrackingMaskEditIR = | { type: "SET_MARKER"; revision: number; clipId: string; trackId: string; marker: TrackingMarkerIR } | { type: "DELETE_MARKER"; revision: number; clipId: string; trackId: string; frame: number } @@ -327,3 +345,86 @@ export function gateTrackingOperation(operation: "MARKER_EDIT" | "MASK_EDIT" | " if (operation === "BROWSER_TRACKING" && browserProbe === "VERIFIED") return readyGate("N-022", operation); return blockedGate("N-022", operation, [capabilityIssue("TRACKING_SOLVE_UNAVAILABLE", operation === "CAMERA_SOLVE" ? "Camera solve requires a verified server Blender implementation" : "Browser tracking requires an explicit feature probe")]); } + +function bezierPoint(a: Vec2, b: Vec2, c: Vec2, d: Vec2, t: number): Vec2 { + const inverse = 1 - t; + return [inverse ** 3 * a[0] + 3 * inverse ** 2 * t * b[0] + 3 * inverse * t ** 2 * c[0] + t ** 3 * d[0], inverse ** 3 * a[1] + 3 * inverse ** 2 * t * b[1] + 3 * inverse * t ** 2 * c[1] + t ** 3 * d[1]]; +} + +export function raycastMaskProject(value: unknown, positionValue: unknown, thresholdValue = 0.02, segmentSamples = 24): MaskRaycastHitIR | null { + const project = parseTrackingMaskProject(value); + const position = vec2(positionValue, "position", -4, 4); + const threshold = finite(thresholdValue, "threshold", 0.000001, 1); + const samples = integer(segmentSamples, "segmentSamples", 2, 128); + let best: MaskRaycastHitIR | null = null; + const consider = (hit: MaskRaycastHitIR): void => { if (hit.distance <= threshold && (!best || hit.distance < best.distance || (hit.distance === best.distance && hit.kind === "POINT" && best.kind === "SEGMENT"))) best = hit; }; + for (const mask of project.masks) for (const layer of mask.layers) { + if (!layer.visible || layer.locked || layer.opacity <= 0) continue; + for (const spline of layer.splines) { + for (const point of spline.points) consider({ maskId: mask.id, layerId: layer.id, splineId: spline.id, kind: "POINT", pointId: point.id, distance: Math.hypot(point.co[0] - position[0], point.co[1] - position[1]) }); + const segmentCount = spline.cyclic ? spline.points.length : spline.points.length - 1; + for (let segment = 0; segment < segmentCount; segment++) { + const first = spline.points[segment]; const next = spline.points[(segment + 1) % spline.points.length]; + for (let sample = 0; sample <= samples; sample++) { + const parameter = sample / samples; + const point = bezierPoint(first.co, first.handleRight, next.handleLeft, next.co, parameter); + consider({ maskId: mask.id, layerId: layer.id, splineId: spline.id, kind: "SEGMENT", pointId: first.id, nextPointId: next.id, distance: Math.hypot(point[0] - position[0], point[1] - position[1]), parameter }); + } + } + } + } + return best; +} + +function maskSelectionKey(selection: MaskPointSelectionIR): string { + return `${selection.maskId}\0${selection.layerId}\0${selection.splineId}\0${selection.pointId}`; +} + +/** Applies deterministic replace/add/toggle marquee selection to editable Mask control points. */ +export function selectMaskPointsInBounds( + value: unknown, + minimumValue: unknown, + maximumValue: unknown, + currentValue: unknown = [], + mode: "REPLACE" | "ADD" | "TOGGLE" = "REPLACE", +): MaskPointSelectionIR[] { + const project = parseTrackingMaskProject(value); + if (!(["REPLACE", "ADD", "TOGGLE"] as const).includes(mode)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", "Mask selection mode is invalid"); + const minimum = vec2(minimumValue, "minimum", -4, 4); + const maximum = vec2(maximumValue, "maximum", -4, 4); + if (minimum[0] > maximum[0] || minimum[1] > maximum[1]) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", "Mask selection bounds are inverted"); + if (!Array.isArray(currentValue) || currentValue.length > TRACKING_MASK_BUDGET.maxMaskPoints) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask selection exceeds the point budget"); + const all: MaskPointSelectionIR[] = []; + const editable = new Set(); + for (const mask of project.masks) for (const layer of mask.layers) for (const spline of layer.splines) for (const point of spline.points) { + const selection = { maskId: mask.id, layerId: layer.id, splineId: spline.id, pointId: point.id }; + all.push(selection); + if (layer.visible && !layer.locked && layer.opacity > 0) editable.add(maskSelectionKey(selection)); + } + const allKeys = new Set(all.map(maskSelectionKey)); + const current = new Set(); + currentValue.forEach((item, index) => { + if (!record(item)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `current[${index}] is invalid`); + const selection = { maskId: text(item.maskId, `current[${index}].maskId`), layerId: text(item.layerId, `current[${index}].layerId`), splineId: text(item.splineId, `current[${index}].splineId`), pointId: text(item.pointId, `current[${index}].pointId`) }; + const key = maskSelectionKey(selection); + if (!allKeys.has(key)) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `current[${index}] references a missing Mask point`); + if (current.has(key)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `current[${index}] is duplicated`); + current.add(key); + }); + const hits = new Set(); + for (const mask of project.masks) for (const layer of mask.layers) { + if (!layer.visible || layer.locked || layer.opacity <= 0) continue; + for (const spline of layer.splines) for (const point of spline.points) { + if (point.co[0] >= minimum[0] && point.co[0] <= maximum[0] && point.co[1] >= minimum[1] && point.co[1] <= maximum[1]) { + hits.add(maskSelectionKey({ maskId: mask.id, layerId: layer.id, splineId: spline.id, pointId: point.id })); + } + } + } + const selected = mode === "REPLACE" ? new Set() : new Set(current); + for (const key of hits) { + if (!editable.has(key)) continue; + if (mode === "TOGGLE" && selected.has(key)) selected.delete(key); + else selected.add(key); + } + return all.filter((selection) => selected.has(maskSelectionKey(selection))); +} diff --git a/web/protocol/volume-vdb.ts b/web/protocol/volume-vdb.ts index ec72a325..499c47c2 100644 --- a/web/protocol/volume-vdb.ts +++ b/web/protocol/volume-vdb.ts @@ -1,9 +1,38 @@ import { normalizeProjectAssetPath } from "./asset-path"; +import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; +import type { ErrorCode } from "./error"; import type { VolumeGridMetadataIR } from "./scene-ir"; +export const VDB_PIPELINE_SCHEMA = 1; export const VDB_MAX_RESOURCE_BYTES = 512 * 1024 * 1024; export const VDB_MAX_ACTIVE_VOXELS = 64_000_000; export const VDB_MAX_GRIDS = 64; +export const NANOVDB_MAX_BUNDLE_BYTES = 1024 * 1024 * 1024; +export const NANOVDB_MAX_CHUNKS = 8192; +export const NANOVDB_MAX_CHUNK_BYTES = 16 * 1024 * 1024; +export const NANOVDB_MAX_GPU_RESIDENT_BYTES = 512 * 1024 * 1024; + +const ID_PATTERN = /^[a-zA-Z0-9._-]+$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const SUPPORTED_GRID_TYPES = new Set(["FLOAT32", "FLOAT16", "VEC3F32", "VEC4F32"]); + +export type VDBExecutionTarget = "DESKTOP" | "SERVER"; +export type NanoVDBGridValueType = "FLOAT32" | "FLOAT16" | "VEC3F32" | "VEC4F32"; +export type NanoVDBGridClass = "FOG_VOLUME" | "LEVEL_SET" | "STAGGERED" | "UNKNOWN"; +export type NanoVDBGridSemantic = "DENSITY" | "TEMPERATURE" | "COLOR" | "EMISSION" | "VELOCITY" | "CUSTOM"; +export type NanoVDBPipelineStage = + | "RAW_VDB_BROWSER_DECODE" + | "DESKTOP_CONVERSION" + | "SERVER_CONVERSION" + | "NANOVDB_STREAM" + | "WEBGPU_VOLUME_RENDER"; + +export class VDBPipelineError extends Error { + constructor(public readonly code: ErrorCode, message: string) { + super(`${code}: ${message}`); + this.name = "VDBPipelineError"; + } +} export interface VDBResourceManifest { projectId: string; @@ -13,67 +42,496 @@ export interface VDBResourceManifest { grids: VolumeGridMetadataIR[]; } -export interface VDBDecodeRequest extends VDBResourceManifest { +export interface VDBConversionInput extends VDBResourceManifest { data: ArrayBuffer; } -export interface VDBDecodeResult { +export interface PreparedVDBConversionInput { metadata: VDBResourceManifest; - decodedByteLength: number; + data: ArrayBuffer; } -export type VDBDecoder = (request: VDBDecodeRequest, signal: AbortSignal) => Promise; - -function invalid(message: string): never { - throw new Error(`NON_MESH_BINARY_INVALID: ${message}`); +export interface VDBConverterIdentityIR { + target: VDBExecutionTarget; + blenderVersion: string; + openVDBVersion: string; + nanoVDBVersion: string; + executableSha256: string; } -export function validateVDBManifest(manifest: VDBResourceManifest): VDBResourceManifest { - if (!manifest.projectId || !/^[a-zA-Z0-9._-]+$/.test(manifest.projectId)) invalid("VDB projectId is invalid"); - let sourcePath: string; +export interface VDBConversionRequestIR { + schemaVersion: typeof VDB_PIPELINE_SCHEMA; + jobId: string; + source: VDBResourceManifest; + sourceBlendSha256?: string; + outputPath: string; + selectedGrids: string[]; + quantization: "LOSSLESS" | "FP16" | "FP8"; + chunkByteLength: number; + converter: VDBConverterIdentityIR; +} + +export interface NanoVDBChunkIR { + index: number; + byteOffset: number; + byteLength: number; + sha256: string; +} + +export interface NanoVDBGridIR { + name: string; + valueType: NanoVDBGridValueType; + gridClass: NanoVDBGridClass; + semantic: NanoVDBGridSemantic; + activeVoxelCount: number; + segmentByteOffset: number; + segmentByteLength: number; + byteOffset: number; + byteLength: number; + indexBounds: { min: [number, number, number]; max: [number, number, number] }; + worldBounds: { min: [number, number, number]; max: [number, number, number] }; + voxelSize: [number, number, number]; + indexToWorld: [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number]; +} + +export interface NanoVDBMaterialIR { + densityGrid: string; + temperatureGrid?: string; + colorGrid?: string; + emissionGrid?: string; + velocityGrid?: string; + densityScale: number; + emissionScale: number; + temperatureScale: number; + anisotropy: number; + interpolation: "NEAREST" | "LINEAR"; + color?: [number, number, number]; + emissionColor?: [number, number, number]; +} + +export interface NanoVDBGpuLayoutIR { + representation: "NANOVDB_STORAGE_BUFFER"; + byteAlignment: 32; + pageByteLength: number; + maxResidentBytes: number; + shaderSemanticVersion: "volume-wgsl-v1"; + float32TreeLayout?: NanoVDBFloat32TreeLayoutIR; + vec3fTreeLayout?: NanoVDBFloat32TreeLayoutIR; +} + +export interface NanoVDBFloat32TreeLayoutIR { + gridDataBytes: number; + treeDataBytes: number; + treeRootOffsetOffset: number; + rootDataBytes: number; + rootTableSizeOffset: number; + rootTileBytes: number; + rootTileKeyOffset: number; + rootTileChildOffset: number; + rootTileStateOffset: number; + rootTileValueOffset: number; + upperNodeBytes: number; + upperValueMaskOffset: number; + upperChildMaskOffset: number; + upperTableOffset: number; + lowerNodeBytes: number; + lowerValueMaskOffset: number; + lowerChildMaskOffset: number; + lowerTableOffset: number; + leafNodeBytes: number; + leafValueMaskOffset: number; + leafValuesOffset: number; +} + +export interface NanoVDBBundleManifestIR { + schemaVersion: typeof VDB_PIPELINE_SCHEMA; + projectId: string; + sourcePath: string; + sourceSha256: string; + conversionRequestSha256: string; + bundlePath: string; + bundleByteLength: number; + bundleSha256: string; + converter: VDBConverterIdentityIR; + grids: NanoVDBGridIR[]; + chunks: NanoVDBChunkIR[]; + material: NanoVDBMaterialIR; + gpu: NanoVDBGpuLayoutIR; +} + +export interface VDBProjectBindingIR { + schemaVersion: typeof VDB_PIPELINE_SCHEMA; + projectId: string; + sourceBlendSha256: string; + sourcePath: string; + sourceSha256: string; + conversionRequestSha256: string; + bundleSha256: string; + bundleByteLength: number; + manifestSha256: string; + converter: VDBConverterIdentityIR; + shaderSemanticVersion: NanoVDBGpuLayoutIR["shaderSemanticVersion"]; + material: NanoVDBMaterialIR; + committedAt: string; +} + +export interface VDBProjectReopenContextIR { + projectId: string; + sourceBlendSha256: string; + sourcePath: string; + sourceSha256: string; + converter: VDBConverterIdentityIR; + shaderSemanticVersion: NanoVDBGpuLayoutIR["shaderSemanticVersion"]; +} + +export interface VDBProjectBindingStatusIR { + status: "READY" | "BLOCKED"; + code?: "VDB_BINDING_MISSING" | "VDB_SOURCE_CHANGED" | "VDB_CONVERTER_CHANGED" | "NANOVDB_HASH_MISMATCH" | "VOLUME_SHADER_UNAVAILABLE"; + message?: string; +} + +export interface NanoVDBRangeIR { + chunkIndex: number; + start: number; + endExclusive: number; + sha256: string; +} + +export interface NanoVDBPipelineContext { + desktopConverterConfigured?: boolean; + serverConverterConfigured?: boolean; + manifestValidated?: boolean; + rangeReaderAvailable?: boolean; + webgpuAvailable?: boolean; + volumeRendererAvailable?: boolean; +} + +function fail(code: ErrorCode, message: string): never { + throw new VDBPipelineError(code, message); +} + +function safeInteger(value: number, name: string, min: number, max: number): number { + if (!Number.isSafeInteger(value) || value < min || value > max) fail("NANOVDB_MANIFEST_INVALID", `${name} is outside the bounded integer range`); + return value; +} + +function finite(value: number, name: string): number { + if (!Number.isFinite(value)) fail("NANOVDB_MANIFEST_INVALID", `${name} must be finite`); + return value; +} + +function projectPath(sourcePath: string, extension: string, label: string): string { + let normalized: string; try { - sourcePath = normalizeProjectAssetPath(manifest.sourcePath); + normalized = normalizeProjectAssetPath(sourcePath); } catch { - throw new Error("NON_MESH_RESOURCE_OUTSIDE_PROJECT: VDB path is outside the project asset root"); + fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", `${label} path is outside the project asset root`); } - if (!sourcePath.toLowerCase().endsWith(".vdb")) invalid("Volume resources must use the .vdb extension"); - if (!Number.isSafeInteger(manifest.byteLength) || manifest.byteLength <= 0 || manifest.byteLength > VDB_MAX_RESOURCE_BYTES) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: VDB resource size is outside the bounded range"); - if (!/^[a-f0-9]{64}$/.test(manifest.sha256)) invalid("VDB SHA-256 is invalid"); - if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: VDB grid count is outside the bounded range"); - const names = new Set(); - let activeVoxels = 0; - for (const grid of manifest.grids) { - if (!grid.name || names.has(grid.name) || !grid.valueType) invalid("VDB grid identity is missing or duplicated"); - names.add(grid.name); - const count = grid.activeVoxelCount ?? grid.voxelCount; - if (!Number.isSafeInteger(count) || count < 0) invalid(`VDB grid ${grid.name} has an invalid active voxel count`); - activeVoxels += count; - if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: VDB active voxel budget exceeded"); - if (grid.bounds && grid.bounds.min.some((value, index) => !Number.isFinite(value) || value > grid.bounds!.max[index])) invalid(`VDB grid ${grid.name} bounds are invalid`); + if (!normalized.toLowerCase().endsWith(extension)) fail("NON_MESH_BINARY_INVALID", `${label} must use the ${extension} extension`); + return normalized; +} + +function validateIdentity(value: VDBConverterIdentityIR): VDBConverterIdentityIR { + if (value.target !== "DESKTOP" && value.target !== "SERVER") fail("VDB_CONVERSION_INVALID", "Converter target is invalid"); + for (const [name, version] of Object.entries({ blenderVersion: value.blenderVersion, openVDBVersion: value.openVDBVersion, nanoVDBVersion: value.nanoVDBVersion })) { + if (typeof version !== "string" || version.length === 0 || version.length > 128) fail("VDB_CONVERSION_INVALID", `${name} is invalid`); + } + if (!SHA256_PATTERN.test(value.executableSha256)) fail("VDB_CONVERSION_INVALID", "Converter executable SHA-256 is invalid"); + return { ...value }; +} + +function validateBounds( + bounds: { min: [number, number, number]; max: [number, number, number] }, + name: string, + integer: boolean, +): void { + if (!bounds || bounds.min.length !== 3 || bounds.max.length !== 3) fail("NANOVDB_MANIFEST_INVALID", `${name} bounds are invalid`); + bounds.min.forEach((value, index) => { + if (!Number.isFinite(value) || value > bounds.max[index] || (integer && (!Number.isSafeInteger(value) || !Number.isSafeInteger(bounds.max[index])))) { + fail("NANOVDB_MANIFEST_INVALID", `${name} bounds are invalid`); + } + }); +} + +function validateColor(value: [number, number, number] | undefined, name: string): void { + if (value === undefined) return; + if (!Array.isArray(value) || value.length !== 3 || value.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 1000000)) { + fail("NANOVDB_MANIFEST_INVALID", `${name} must contain three finite non-negative channels`); } - return { ...manifest, sourcePath }; } function hex(bytes: Uint8Array): string { return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); } -export async function decodeVDBResource( - request: VDBDecodeRequest, - decoder: VDBDecoder | undefined, - signal: AbortSignal, -): Promise { - const metadata = validateVDBManifest(request); - if (signal.aborted) throw new DOMException("VDB decode cancelled", "AbortError"); - if (request.data.byteLength !== metadata.byteLength) invalid("VDB byte length does not match its manifest"); - if (!globalThis.crypto?.subtle) invalid("SHA-256 is unavailable"); - const digest = hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", request.data))); - if (digest !== metadata.sha256) invalid("VDB bytes do not match the manifest SHA-256"); - if (signal.aborted) throw new DOMException("VDB decode cancelled", "AbortError"); - if (!decoder) throw new Error("VOLUME_SHADER_UNAVAILABLE: no bounded OpenVDB decoder is installed"); - const result = await decoder({ ...request, ...metadata }, signal); - if (signal.aborted) throw new DOMException("VDB decode cancelled", "AbortError"); - if (!Number.isSafeInteger(result.decodedByteLength) || result.decodedByteLength < 0 || result.decodedByteLength > VDB_MAX_RESOURCE_BYTES * 2) throw new Error("NON_MESH_VDB_BUDGET_EXCEEDED: decoded VDB memory budget exceeded"); - return { ...result, metadata }; +async function sha256(data: ArrayBuffer): Promise { + if (!globalThis.crypto?.subtle) fail("NON_MESH_BINARY_INVALID", "SHA-256 is unavailable"); + return hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", data))); +} + +export function validateVDBManifest(manifest: VDBResourceManifest): VDBResourceManifest { + if (!manifest.projectId || !ID_PATTERN.test(manifest.projectId)) fail("NON_MESH_BINARY_INVALID", "VDB projectId is invalid"); + const sourcePath = projectPath(manifest.sourcePath, ".vdb", "VDB resource"); + if (!Number.isSafeInteger(manifest.byteLength) || manifest.byteLength <= 0 || manifest.byteLength > VDB_MAX_RESOURCE_BYTES) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB resource size is outside the bounded range"); + if (!SHA256_PATTERN.test(manifest.sha256)) fail("NON_MESH_BINARY_INVALID", "VDB SHA-256 is invalid"); + if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB grid count is outside the bounded range"); + const names = new Set(); + let activeVoxels = 0; + for (const grid of manifest.grids) { + if (!grid.name || names.has(grid.name) || !grid.valueType) fail("NON_MESH_BINARY_INVALID", "VDB grid identity is missing or duplicated"); + names.add(grid.name); + const count = grid.activeVoxelCount ?? grid.voxelCount; + if (!Number.isSafeInteger(count) || count < 0) fail("NON_MESH_BINARY_INVALID", `VDB grid ${grid.name} has an invalid active voxel count`); + activeVoxels += count; + if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB active voxel budget exceeded"); + if (grid.bounds) validateBounds(grid.bounds, `VDB grid ${grid.name}`, false); + } + return { ...manifest, sourcePath, grids: manifest.grids.map((grid) => ({ ...grid })) }; +} + +export async function prepareVDBConversionInput(request: VDBConversionInput, signal: AbortSignal): Promise { + const metadata = validateVDBManifest(request); + if (signal.aborted) throw new DOMException("VDB source validation cancelled", "AbortError"); + if (!(request.data instanceof ArrayBuffer) || request.data.byteLength !== metadata.byteLength) fail("NON_MESH_BINARY_INVALID", "VDB byte length does not match its manifest"); + if (await sha256(request.data) !== metadata.sha256) fail("NANOVDB_HASH_MISMATCH", "VDB bytes do not match the source manifest SHA-256"); + if (signal.aborted) throw new DOMException("VDB source validation cancelled", "AbortError"); + return { metadata, data: request.data }; +} + +export function validateVDBConversionRequest(request: VDBConversionRequestIR): VDBConversionRequestIR { + if (request.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(request.jobId)) fail("VDB_CONVERSION_INVALID", "Conversion request schema or job ID is invalid"); + const source = validateVDBManifest(request.source); + const outputPath = projectPath(request.outputPath, ".nvdb", "NanoVDB output"); + if (request.sourceBlendSha256 !== undefined && !SHA256_PATTERN.test(request.sourceBlendSha256)) fail("VDB_CONVERSION_INVALID", "Source blend SHA-256 is invalid"); + if (!Array.isArray(request.selectedGrids) || request.selectedGrids.length === 0 || request.selectedGrids.length > VDB_MAX_GRIDS) fail("VDB_CONVERSION_INVALID", "Selected grid list is invalid"); + const available = new Set(source.grids.map((grid) => grid.name)); + const selected = new Set(); + request.selectedGrids.forEach((name) => { + if (!available.has(name) || selected.has(name)) fail("VDB_CONVERSION_INVALID", `Selected grid ${name} is missing or duplicated`); + selected.add(name); + }); + if (!["LOSSLESS", "FP16", "FP8"].includes(request.quantization)) fail("VDB_CONVERSION_INVALID", "NanoVDB quantization is invalid"); + if (!Number.isSafeInteger(request.chunkByteLength) || request.chunkByteLength < 64 * 1024 || request.chunkByteLength > NANOVDB_MAX_CHUNK_BYTES || request.chunkByteLength % 32 !== 0) fail("VDB_CONVERSION_INVALID", "Chunk size must be 32-byte aligned and within 64 KiB to 16 MiB"); + return { ...request, source, outputPath, selectedGrids: [...request.selectedGrids], converter: validateIdentity(request.converter) }; +} + +export function serializeVDBConversionRequest(value: VDBConversionRequestIR): string { + const request = validateVDBConversionRequest(value); + return JSON.stringify({ + schemaVersion: request.schemaVersion, + source: { + byteLength: request.source.byteLength, + sha256: request.source.sha256, + grids: request.source.grids.map((grid) => ({ + name: grid.name, + valueType: grid.valueType, + voxelCount: grid.voxelCount, + ...(grid.activeVoxelCount === undefined ? {} : { activeVoxelCount: grid.activeVoxelCount }), + ...(grid.bounds === undefined ? {} : { bounds: grid.bounds }), + })), + }, + ...(request.sourceBlendSha256 === undefined ? {} : { sourceBlendSha256: request.sourceBlendSha256 }), + selectedGrids: request.selectedGrids, + quantization: request.quantization, + chunkByteLength: request.chunkByteLength, + converter: request.converter, + }); +} + +export async function hashVDBConversionRequest(value: VDBConversionRequestIR): Promise { + const encoded = new TextEncoder().encode(serializeVDBConversionRequest(value)); + return sha256(encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength) as ArrayBuffer); +} + +export function validateNanoVDBBundleManifest(manifest: NanoVDBBundleManifestIR): NanoVDBBundleManifestIR { + if (manifest.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(manifest.projectId)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB schema or project ID is invalid"); + const sourcePath = projectPath(manifest.sourcePath, ".vdb", "VDB source"); + const bundlePath = projectPath(manifest.bundlePath, ".nvdb", "NanoVDB bundle"); + if (!SHA256_PATTERN.test(manifest.sourceSha256) || !SHA256_PATTERN.test(manifest.conversionRequestSha256) || !SHA256_PATTERN.test(manifest.bundleSha256)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB source, conversion request, or bundle SHA-256 is invalid"); + safeInteger(manifest.bundleByteLength, "bundleByteLength", 1, NANOVDB_MAX_BUNDLE_BYTES); + const converter = validateIdentity(manifest.converter); + if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0 || manifest.chunks.length > NANOVDB_MAX_CHUNKS) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB chunk count is outside the bounded range"); + let nextOffset = 0; + const chunks = manifest.chunks.map((chunk, position) => { + if (chunk.index !== position || chunk.byteOffset !== nextOffset || chunk.byteOffset % 32 !== 0) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${position} is not contiguous or aligned`); + safeInteger(chunk.byteLength, `chunks[${position}].byteLength`, 1, NANOVDB_MAX_CHUNK_BYTES); + if (position < manifest.chunks.length - 1 && chunk.byteLength % 32 !== 0) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${position} length is not aligned`); + if (!SHA256_PATTERN.test(chunk.sha256)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB chunk ${position} SHA-256 is invalid`); + nextOffset += chunk.byteLength; + if (!Number.isSafeInteger(nextOffset) || nextOffset > manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB chunk ranges exceed the bundle"); + return { ...chunk }; + }); + if (nextOffset !== manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB chunks do not cover the complete bundle"); + + if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid count is outside the bounded range"); + const names = new Set(); + let activeVoxels = 0; + const grids = manifest.grids.map((grid) => { + if (!grid.name || names.has(grid.name)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid identity is missing or duplicated"); + names.add(grid.name); + if (!SUPPORTED_GRID_TYPES.has(grid.valueType)) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB grid ${grid.name} uses unsupported value type ${grid.valueType}`); + if (!["FOG_VOLUME", "LEVEL_SET", "STAGGERED", "UNKNOWN"].includes(grid.gridClass) || !["DENSITY", "TEMPERATURE", "COLOR", "EMISSION", "VELOCITY", "CUSTOM"].includes(grid.semantic)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} class or semantic is invalid`); + safeInteger(grid.activeVoxelCount, `${grid.name}.activeVoxelCount`, 0, VDB_MAX_ACTIVE_VOXELS); + activeVoxels += grid.activeVoxelCount; + if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "NanoVDB active voxel budget exceeded"); + safeInteger(grid.segmentByteOffset, `${grid.name}.segmentByteOffset`, 0, manifest.bundleByteLength - 1); + safeInteger(grid.segmentByteLength, `${grid.name}.segmentByteLength`, 1, manifest.bundleByteLength); + safeInteger(grid.byteOffset, `${grid.name}.byteOffset`, 0, manifest.bundleByteLength - 1); + safeInteger(grid.byteLength, `${grid.name}.byteLength`, 1, manifest.bundleByteLength); + if (grid.segmentByteOffset + grid.segmentByteLength > manifest.bundleByteLength || grid.byteOffset < grid.segmentByteOffset || grid.byteOffset + grid.byteLength > grid.segmentByteOffset + grid.segmentByteLength) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} segment or payload range is invalid`); + validateBounds(grid.indexBounds, `NanoVDB grid ${grid.name} index`, true); + validateBounds(grid.worldBounds, `NanoVDB grid ${grid.name} world`, false); + if (grid.voxelSize.length !== 3 || grid.voxelSize.some((value) => !Number.isFinite(value) || value <= 0)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} voxel size is invalid`); + if (grid.indexToWorld.length !== 16 || grid.indexToWorld.some((value) => !Number.isFinite(value))) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} transform is invalid`); + return { ...grid, indexBounds: { min: [...grid.indexBounds.min], max: [...grid.indexBounds.max] }, worldBounds: { min: [...grid.worldBounds.min], max: [...grid.worldBounds.max] }, voxelSize: [...grid.voxelSize], indexToWorld: [...grid.indexToWorld] } as NanoVDBGridIR; + }); + const orderedRanges = [...grids].sort((left, right) => left.segmentByteOffset - right.segmentByteOffset); + for (let index = 1; index < orderedRanges.length; index += 1) { + if (orderedRanges[index - 1].segmentByteOffset + orderedRanges[index - 1].segmentByteLength > orderedRanges[index].segmentByteOffset) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid segments overlap"); + } + + const material = { ...manifest.material }; + const references: Array<[keyof NanoVDBMaterialIR, NanoVDBGridSemantic]> = [ + ["densityGrid", "DENSITY"], ["temperatureGrid", "TEMPERATURE"], ["colorGrid", "COLOR"], + ["emissionGrid", "EMISSION"], ["velocityGrid", "VELOCITY"], + ]; + for (const [field, semantic] of references) { + const gridName = material[field]; + if (typeof gridName !== "string") continue; + const grid = grids.find((candidate) => candidate.name === gridName); + if (!grid || grid.semantic !== semantic) fail("NANOVDB_MANIFEST_INVALID", `Material ${field} does not reference a ${semantic} grid`); + } + finite(material.densityScale, "material.densityScale"); + finite(material.emissionScale, "material.emissionScale"); + finite(material.temperatureScale, "material.temperatureScale"); + validateColor(material.color, "material.color"); + validateColor(material.emissionColor, "material.emissionColor"); + if (material.densityScale < 0 || material.emissionScale < 0 || material.temperatureScale < 0 || !Number.isFinite(material.anisotropy) || material.anisotropy < -0.99 || material.anisotropy > 0.99 || !["NEAREST", "LINEAR"].includes(material.interpolation)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB material parameters are invalid"); + + const gpu = { ...manifest.gpu }; + if (gpu.representation !== "NANOVDB_STORAGE_BUFFER" || gpu.byteAlignment !== 32 || gpu.shaderSemanticVersion !== "volume-wgsl-v1") fail("NANOVDB_MANIFEST_INVALID", "NanoVDB GPU representation is unsupported"); + if (!Number.isSafeInteger(gpu.pageByteLength) || gpu.pageByteLength < 64 * 1024 || gpu.pageByteLength > NANOVDB_MAX_CHUNK_BYTES || gpu.pageByteLength % 32 !== 0) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB GPU page size is invalid"); + if (!Number.isSafeInteger(gpu.maxResidentBytes) || gpu.maxResidentBytes < gpu.pageByteLength || gpu.maxResidentBytes > NANOVDB_MAX_GPU_RESIDENT_BYTES) fail("NANOVDB_GPU_BUDGET_EXCEEDED", "NanoVDB GPU resident budget is invalid"); + if (gpu.float32TreeLayout !== undefined) { + const layout = gpu.float32TreeLayout; + const expected: NanoVDBFloat32TreeLayoutIR = { + gridDataBytes: 672, treeDataBytes: 64, treeRootOffsetOffset: 24, + rootDataBytes: 64, rootTableSizeOffset: 24, rootTileBytes: 32, rootTileKeyOffset: 0, rootTileChildOffset: 8, rootTileStateOffset: 16, rootTileValueOffset: 20, + upperNodeBytes: 270400, upperValueMaskOffset: 32, upperChildMaskOffset: 4128, upperTableOffset: 8256, + lowerNodeBytes: 33856, lowerValueMaskOffset: 32, lowerChildMaskOffset: 544, lowerTableOffset: 1088, + leafNodeBytes: 2144, leafValueMaskOffset: 16, leafValuesOffset: 96, + }; + for (const [name, expectedValue] of Object.entries(expected)) if (!Number.isSafeInteger(layout[name as keyof NanoVDBFloat32TreeLayoutIR]) || layout[name as keyof NanoVDBFloat32TreeLayoutIR] !== expectedValue) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB Float32 layout ${name} is unsupported`); + } + if (gpu.vec3fTreeLayout !== undefined) { + const layout = gpu.vec3fTreeLayout; + const expected: NanoVDBFloat32TreeLayoutIR = { + gridDataBytes: 672, treeDataBytes: 64, treeRootOffsetOffset: 24, + rootDataBytes: 96, rootTableSizeOffset: 24, rootTileBytes: 32, rootTileKeyOffset: 0, rootTileChildOffset: 8, rootTileStateOffset: 16, rootTileValueOffset: 20, + upperNodeBytes: 532544, upperValueMaskOffset: 32, upperChildMaskOffset: 4128, upperTableOffset: 8256, + lowerNodeBytes: 66624, lowerValueMaskOffset: 32, lowerChildMaskOffset: 544, lowerTableOffset: 1088, + leafNodeBytes: 6272, leafValueMaskOffset: 16, leafValuesOffset: 128, + }; + for (const [name, expectedValue] of Object.entries(expected)) if (!Number.isSafeInteger(layout[name as keyof NanoVDBFloat32TreeLayoutIR]) || layout[name as keyof NanoVDBFloat32TreeLayoutIR] !== expectedValue) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB Vec3f layout ${name} is unsupported`); + } + + return { ...manifest, sourcePath, bundlePath, converter, chunks, grids, material, gpu }; +} + +export function validateVDBProjectBinding(value: VDBProjectBindingIR): VDBProjectBindingIR { + if (value.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(value.projectId)) fail("NANOVDB_MANIFEST_INVALID", "VDB project binding schema or project id is invalid"); + const sourcePath = projectPath(value.sourcePath, ".vdb", "VDB binding source"); + for (const [name, digest] of Object.entries({ + sourceBlendSha256: value.sourceBlendSha256, + sourceSha256: value.sourceSha256, + conversionRequestSha256: value.conversionRequestSha256, + bundleSha256: value.bundleSha256, + manifestSha256: value.manifestSha256, + })) if (!SHA256_PATTERN.test(digest)) fail("NANOVDB_MANIFEST_INVALID", `VDB binding ${name} is invalid`); + safeInteger(value.bundleByteLength, "binding.bundleByteLength", 1, NANOVDB_MAX_BUNDLE_BYTES); + if (value.shaderSemanticVersion !== "volume-wgsl-v1") fail("NANOVDB_MANIFEST_INVALID", "VDB binding shader semantic version is unsupported"); + if (typeof value.committedAt !== "string" || !Number.isFinite(Date.parse(value.committedAt))) fail("NANOVDB_MANIFEST_INVALID", "VDB binding commit timestamp is invalid"); + const converter = validateIdentity(value.converter); + const synthetic: NanoVDBBundleManifestIR = { + schemaVersion: VDB_PIPELINE_SCHEMA, + projectId: value.projectId, + sourcePath, + sourceSha256: value.sourceSha256, + conversionRequestSha256: value.conversionRequestSha256, + bundlePath: "//cache/binding.nvdb", + bundleByteLength: value.bundleByteLength, + bundleSha256: value.bundleSha256, + converter, + grids: [{ name: value.material.densityGrid, valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 0, segmentByteOffset: 0, segmentByteLength: 1, byteOffset: 0, byteLength: 1, 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: [{ index: 0, byteOffset: 0, byteLength: value.bundleByteLength, sha256: value.bundleSha256 }], + material: { ...value.material, temperatureGrid: undefined, colorGrid: undefined, emissionGrid: undefined, velocityGrid: undefined }, + gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: Math.min(NANOVDB_MAX_CHUNK_BYTES, Math.max(64 * 1024, Math.ceil(Math.min(value.bundleByteLength, NANOVDB_MAX_CHUNK_BYTES) / 32) * 32)), maxResidentBytes: NANOVDB_MAX_GPU_RESIDENT_BYTES, shaderSemanticVersion: value.shaderSemanticVersion }, + }; + // Reuse bounded scalar material checks without requiring all referenced grids in this binding record. + finite(synthetic.material.densityScale, "binding.material.densityScale"); + finite(synthetic.material.emissionScale, "binding.material.emissionScale"); + finite(synthetic.material.temperatureScale, "binding.material.temperatureScale"); + validateColor(synthetic.material.color, "binding.material.color"); + validateColor(synthetic.material.emissionColor, "binding.material.emissionColor"); + if (synthetic.material.densityScale < 0 || synthetic.material.emissionScale < 0 || synthetic.material.temperatureScale < 0 || !Number.isFinite(synthetic.material.anisotropy) || synthetic.material.anisotropy < -0.99 || synthetic.material.anisotropy > 0.99 || !["NEAREST", "LINEAR"].includes(synthetic.material.interpolation)) fail("NANOVDB_MANIFEST_INVALID", "VDB binding material is invalid"); + return { ...value, sourcePath, converter, material: { ...value.material } }; +} + +export function evaluateVDBProjectBinding(value: VDBProjectBindingIR | undefined, context: VDBProjectReopenContextIR): VDBProjectBindingStatusIR { + if (!value) return { status: "BLOCKED", code: "VDB_BINDING_MISSING", message: "The project has no committed NanoVDB binding" }; + const binding = validateVDBProjectBinding(value); + if (binding.projectId !== context.projectId || binding.sourcePath !== projectPath(context.sourcePath, ".vdb", "VDB reopen source") || binding.sourceBlendSha256 !== context.sourceBlendSha256 || binding.sourceSha256 !== context.sourceSha256) { + return { status: "BLOCKED", code: "VDB_SOURCE_CHANGED", message: "The blend or VDB source changed after conversion" }; + } + const converter = validateIdentity(context.converter); + if (serializeIdentity(binding.converter) !== serializeIdentity(converter)) return { status: "BLOCKED", code: "VDB_CONVERTER_CHANGED", message: "The VDB converter identity changed" }; + if (binding.shaderSemanticVersion !== context.shaderSemanticVersion) return { status: "BLOCKED", code: "VOLUME_SHADER_UNAVAILABLE", message: "The volume shader semantic version changed" }; + return { status: "READY" }; +} + +function serializeIdentity(value: VDBConverterIdentityIR): string { + return `${value.target}\n${value.blenderVersion}\n${value.openVDBVersion}\n${value.nanoVDBVersion}\n${value.executableSha256}`; +} + +export function planNanoVDBRanges(value: NanoVDBBundleManifestIR): NanoVDBRangeIR[] { + const manifest = validateNanoVDBBundleManifest(value); + return manifest.chunks.map((chunk) => ({ chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunk.byteOffset + chunk.byteLength, sha256: chunk.sha256 })); +} + +export async function verifyNanoVDBChunk(chunk: NanoVDBChunkIR, data: ArrayBuffer): Promise { + if (!(data instanceof ArrayBuffer) || data.byteLength !== chunk.byteLength) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${chunk.index} byte length is incomplete`); + if (await sha256(data) !== chunk.sha256) fail("NANOVDB_HASH_MISMATCH", `NanoVDB chunk ${chunk.index} SHA-256 mismatch`); +} + +export async function verifyNanoVDBBundle(manifestValue: NanoVDBBundleManifestIR, data: ArrayBuffer): Promise { + const manifest = validateNanoVDBBundleManifest(manifestValue); + if (!(data instanceof ArrayBuffer) || data.byteLength !== manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB bundle byte length is incomplete"); + if (await sha256(data) !== manifest.bundleSha256) fail("NANOVDB_HASH_MISMATCH", "NanoVDB bundle SHA-256 mismatch"); +} + +export function gateNanoVDBPipeline(stage: NanoVDBPipelineStage, context: NanoVDBPipelineContext = {}): CapabilityGateResult { + if (stage === "RAW_VDB_BROWSER_DECODE") { + return blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERSION_REQUIRED", "Raw OpenVDB must be converted by the desktop or server OpenVDB toolchain; browser decoding is intentionally unavailable")]); + } + if (stage === "DESKTOP_CONVERSION") { + return context.desktopConverterConfigured + ? readyGate("N-015", stage) + : blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERTER_UNAVAILABLE", "The desktop OpenVDB to NanoVDB converter is not configured")]); + } + if (stage === "SERVER_CONVERSION") { + return context.serverConverterConfigured + ? readyGate("N-015", stage) + : blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERTER_UNAVAILABLE", "The server OpenVDB to NanoVDB job endpoint is not configured")]); + } + if (stage === "NANOVDB_STREAM") { + return context.manifestValidated && context.rangeReaderAvailable + ? readyGate("N-015", stage) + : blockedGate("N-015", stage, [capabilityIssue("NANOVDB_STREAM_INCOMPLETE", "A validated NanoVDB manifest and bounded range reader are required")]); + } + if (!context.webgpuAvailable) return blockedGate("N-015", stage, [capabilityIssue("WEBGPU_RENDERER_UNAVAILABLE", "WebGPU is unavailable in this browser or device")]); + if (!context.manifestValidated || !context.rangeReaderAvailable) return blockedGate("N-015", stage, [capabilityIssue("NANOVDB_STREAM_INCOMPLETE", "Volume rendering requires a validated and readable NanoVDB stream")]); + return context.volumeRendererAvailable + ? readyGate("N-015", stage) + : blockedGate("N-015", stage, [capabilityIssue("VOLUME_SHADER_UNAVAILABLE", "The NanoVDB WGSL traversal and volume material renderer have not been installed")]); } diff --git a/web/protocol/web-engine.ts b/web/protocol/web-engine.ts index d244c5e5..75bb5602 100644 --- a/web/protocol/web-engine.ts +++ b/web/protocol/web-engine.ts @@ -113,6 +113,7 @@ export type WebEngineEditCommand = | { type: "setFontProperties"; dataId: string; properties: Partial } | { type: "setFontAdvanced"; dataId: string; characters: NonMeshFontCharacterIR[]; textBoxes: NonMeshFontTextBoxIR[]; activeTextBox: number } | { 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" } diff --git a/web/tests/e2e/device-loss.spec.ts b/web/tests/e2e/device-loss.spec.ts new file mode 100644 index 00000000..e7a8a7a8 --- /dev/null +++ b/web/tests/e2e/device-loss.spec.ts @@ -0,0 +1,37 @@ +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("recovers the main-thread Chromium viewport after a real WebGL context loss", async ({ page }) => { + await page.goto("/"); + await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 3"); + const canvas = page.locator("canvas.viewport-canvas"); + await expect(canvas).toHaveAttribute("data-device-status", "ready"); + const result = await canvas.evaluate(async (element) => { + const gl = element.getContext("webgl2") ?? element.getContext("webgl"); + const extension = gl?.getExtension("WEBGL_lose_context"); + if (!gl || !extension) return { supported: false, pixels: 0 }; + const waitFor = (status: string): Promise => new Promise((resolve, reject) => { + const started = performance.now(); + const poll = (): void => { + if (element.dataset.deviceStatus === status) { resolve(); return; } + if (performance.now() - started > 10_000) { reject(new Error(`Timed out waiting for device status ${status}`)); return; } + requestAnimationFrame(poll); + }; + poll(); + }); + extension.loseContext(); + await waitFor("lost"); + extension.restoreContext(); + await waitFor("ready"); + const pixels = new Uint8Array(16 * 16 * 4); + gl.readPixels(0, 0, 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + return { supported: true, pixels: pixels.reduce((total, value) => total + (value > 0 ? 1 : 0), 0) }; + }); + expect(result.supported).toBe(true); + expect(result.pixels).toBeGreaterThan(0); + await page.getByRole("button", { name: "添加立方体" }).click(); + await expect(page.getByTestId("engine-status")).toContainText("SceneIR r2 (4 objects)"); +}); diff --git a/web/tests/e2e/network-interruption.spec.ts b/web/tests/e2e/network-interruption.spec.ts new file mode 100644 index 00000000..44383088 --- /dev/null +++ b/web/tests/e2e/network-interruption.spec.ts @@ -0,0 +1,29 @@ +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("keeps Main edit and save-reopen available during a Chromium network interruption", async ({ context, page }) => { + await page.goto("/"); + await expect(page.getByTestId("engine-status")).toContainText("Engine: ready", { timeout: 20_000 }); + await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 3"); + + await context.setOffline(true); + try { + await page.getByRole("button", { name: "添加立方体" }).click(); + await expect(page.getByTestId("engine-status")).toContainText("SceneIR r2 (4 objects)"); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "保存项目" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("blender-web.blend"); + const savedPath = await download.path(); + expect(savedPath).not.toBeNull(); + await page.setInputFiles("[data-testid=blend-file-input]", savedPath!); + await expect(page.getByTestId("engine-status")).toContainText("SceneIR r3 (4 objects)"); + await expect(page.getByTestId("scene-stats")).toContainText("Objects 4"); + } + finally { + await context.setOffline(false); + } +}); diff --git a/web/tests/e2e/simulation-cache-performance.spec.ts b/web/tests/e2e/simulation-cache-performance.spec.ts new file mode 100644 index 00000000..843a34f8 --- /dev/null +++ b/web/tests/e2e/simulation-cache-performance.spec.ts @@ -0,0 +1,110 @@ +import { expect, test } from "@playwright/test"; + +test("meets the Chromium OPFS Simulation cache playback performance gate", async ({ page }) => { + test.setTimeout(45_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 frameCount = 600; + const frameBytes = 88; + const digest = async (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(""); + }; + const payload = new ArrayBuffer(frameCount * frameBytes); + const payloadBytes = new Uint8Array(payload); + const objectId = new TextEncoder().encode("object:CacheTarget"); + for (let frame = 1; frame <= frameCount; frame += 1) { + const offset = (frame - 1) * frameBytes; + const view = new DataView(payload, offset, frameBytes); + view.setUint32(0, 0x31465442, true); + view.setUint16(4, 1, true); + view.setUint16(6, 16, true); + view.setInt32(8, frame, true); + view.setUint32(12, 1, true); + view.setUint8(16, objectId.length); + payloadBytes.set(objectId, offset + 17); + [frame / 10, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true)); + } + 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)), + }))); + 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, + graphId: "geometry-node-tree:simulation-performance", + graphHash: fixedHash, + sourceBlendSha256: await digest(sourceBlend), + inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer), + cacheSha256: await digest(payload), + blenderVersion: "5.2.0", + frameStart: 1, + frameEnd: frameCount, + byteLength: payload.byteLength, + frames, + }; + const projectId = `simulation-performance-${Date.now()}`; + const started = performance.now(); + const writer = new StorageClient(); + const saved = await writer.saveProject(projectId, 1, sourceBlend.slice(0)); + const stored = await writer.putSimulationCache(projectId, manifest, payload); + writer.terminate(); + const storedAt = performance.now(); + + const reader = new StorageClient(); + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const scene = { + schemaVersion: 1 as const, + revision: 1, + sceneId: "scene:CachePerformance", + 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: frameCount }, + 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 publishedFrames = 0; + let lastTranslation = 0; + const playback = new BrowserTransformCachePlaybackSession(scene, { + frameStart: 1, + frameEnd: frameCount, + readFrame: async (frame, signal) => { + if (signal.aborted) throw new DOMException("Playback aborted", "AbortError"); + const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame); + if (signal.aborted) throw new DOMException("Playback aborted", "AbortError"); + return read.data; + }, + }, (preview) => { + publishedFrames += 1; + lastTranslation = preview.nodes[0].transform.translation[0]; + }); + const playbackResult = await playback.play(); + const finished = performance.now(); + reader.terminate(); + return { + backend: saved.backend, + frameCount, + byteLength: manifest.byteLength, + status: playbackResult.status, + appliedFrames: playbackResult.appliedFrames, + lastFrame: playbackResult.lastFrame, + publishedFrames, + lastTranslation, + storeMs: Math.round(storedAt - started), + playbackMs: Math.round(finished - storedAt), + elapsedMs: Math.round(finished - started), + }; + }); + expect(result.backend).toBe("opfs"); + expect(result).toMatchObject({ frameCount: 600, byteLength: 52_800, status: "COMPLETED", appliedFrames: 600, lastFrame: 600, publishedFrames: 600 }); + expect(result.lastTranslation).toBeCloseTo(60, 5); + expect(result.storeMs).toBeLessThan(30_000); + expect(result.playbackMs).toBeLessThan(30_000); + expect(result.elapsedMs).toBeLessThan(30_000); +}); diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 2c5a60c6..419b7bab 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -7,6 +7,9 @@ const animationBlend = path.resolve(import.meta.dirname, "../../../tests/files/w const riggedBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/rigged_shape_scene.blend"); const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"); const greasePencilBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend"); +const compositorBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/compositor_scene.blend"); +const sequencerBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/sequencer_scene.blend"); +const maskBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/mask_scene.blend"); const localTexturePng = path.resolve(import.meta.dirname, "../../../tests/golden/W-010/desktop-1440x900.png"); const deformationGolden = path.resolve(import.meta.dirname, "../../../tests/golden/W-079/blender-deformation.json"); @@ -298,7 +301,9 @@ test("reads bounded non-mesh data blocks and previews supported geometry", async await expect(page.getByText("WebVolumeObject", { exact: true })).toBeVisible(); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-non-mesh-count", "6"); - await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2"); + await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "1"); + await expect(canvas).toHaveAttribute("data-volume-status", "blocked"); + await expect(canvas).toHaveAttribute("data-volume-error-code", "NON_MESH_RESOURCE_MISSING"); const renderedPixels = await canvas.evaluate((element) => { const gl = element.getContext("webgl2") ?? element.getContext("webgl"); if (!gl) return 0; @@ -518,6 +523,45 @@ test("validates the N-016 Grease Pencil editor context transaction boundary", as expect(result.missingPoint).toContain("GREASE_PENCIL_EDITOR_INVALID"); }); +test("raycasts and highlights N-016 Grease Pencil points with stable drawing identity", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/grease-pencil-viewport-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({}); + })); + expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 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]]); + expect(result.proxyCount).toBe(1); +}); + +for (const offscreen of [false, true]) test(`previews N-016 Grease Pencil points and commits Main once in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => { + await page.goto(offscreen ? "/?offscreen=1" : "/"); + await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); + await expect(page.getByText("GreasePencilObject", { exact: true })).toBeVisible({ timeout: 20_000 }); + await page.getByText("GreasePencilObject", { exact: true }).click(); + await page.getByRole("button", { name: "Object Mode" }).click(); + await page.getByRole("button", { name: "Select All" }).click(); + await expect(page.getByText(/\d+ vert selected/)).toBeVisible(); + const revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1"); + const axis = page.getByRole("button", { name: "X 轴变换手柄" }); + const bounds = await axis.boundingBox(); + if (!bounds) throw new Error("Grease Pencil gizmo X axis is unavailable"); + const x = bounds.x + bounds.width / 2; + const y = bounds.y + bounds.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 24, y, { steps: 3 }); + const canvas = page.locator("canvas.viewport-canvas"); + await expect(canvas).toHaveAttribute("data-grease-pencil-preview", /[1-9]\d*/); + await page.mouse.up(); + await expect(canvas).toHaveAttribute("data-grease-pencil-preview", "0"); + await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1); +}); + test("edits N-016 Grease Pencil layers and frames from the bounded editor panel", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); @@ -533,6 +577,21 @@ test("edits N-016 Grease Pencil layers and frames from the bounded editor panel" await expect(editor).toContainText("2 layers / 2 frames / 1 strokes"); }); +test("navigates real N-016 Grease Pencil drawing frames in the bounded Dope Sheet", async ({ page }) => { + await page.goto("/"); + await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); + await page.getByText("GreasePencilObject", { exact: true }).click(); + const dopeSheet = page.getByLabel("Dope Sheet"); + await expect(dopeSheet).toContainText("GreasePencilData"); + await expect(dopeSheet.getByRole("button", { name: "Grease Pencil 帧 1", exact: true })).toBeVisible(); + await page.getByLabel("当前帧").fill("12"); + await expect(page.locator("output.frame-number")).toHaveText("12"); + await page.getByTestId("grease-pencil-editor").getByRole("button", { name: "Add Frame" }).click(); + await expect(dopeSheet.getByRole("button", { name: "Grease Pencil 帧 12" })).toBeVisible({ timeout: 20_000 }); + await dopeSheet.getByRole("button", { name: "Grease Pencil 帧 1", exact: true }).click(); + await expect(page.locator("output.frame-number")).toHaveText("1"); +}); + test("moves an N-016 Grease Pencil point through one revision-bound Main transaction", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); @@ -544,6 +603,48 @@ test("moves an N-016 Grease Pencil point through one revision-bound Main transac await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1, 0, 0", { timeout: 20_000 }); }); +test("reopens an edited N-016 Grease Pencil drawing after WebEngine Worker restart", async ({ page }) => { + await page.goto("/"); + const bytes = await import("node:fs").then((fs) => fs.readFileSync(greasePencilBlend)); + const result = await page.evaluate(async (input) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const first = new WebEngineClient({ timeoutMs: 20_000 }); + const opened = await first.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); + const data = opened.snapshot.greasePencils?.[0]; + const layer = data?.layers[0]; + const drawingFrame = layer?.frames[0]; + if (!data || !layer || !drawingFrame) throw new Error("Grease Pencil fixture is incomplete"); + const strokes = drawingFrame.drawing.strokes.map((stroke, strokeIndex) => ({ + cyclic: stroke.cyclic, + materialIndex: stroke.materialIndex, + points: (stroke.points ?? []).map((point, pointIndex) => ({ + ...point, + position: strokeIndex === 0 && pointIndex === 0 ? [point.position[0] + 0.25, point.position[1], point.position[2]] as [number, number, number] : [...point.position] as [number, number, number], + })), + })); + const edited = await first.applyCommand({ type: "setGreasePencilStrokes", dataId: data.id, layerId: layer.id, frame: drawingFrame.frame, baseRevision: opened.snapshot.revision, strokes }); + const saved = await first.saveBlend(); + first.terminate(); + const restarted = new WebEngineClient({ timeoutMs: 20_000 }); + const reopened = await restarted.openBlend(saved); + restarted.terminate(); + const reopenedData = reopened.snapshot.greasePencils?.find((candidate) => candidate.id === data.id); + const point = reopenedData?.layers.find((candidate) => candidate.id === layer.id)?.frames.find((candidate) => candidate.frame === drawingFrame.frame)?.drawing.strokes[0]?.points?.[0]; + return { + revision: edited.snapshot.revision, + identity: [reopenedData?.id, reopenedData?.layers[0]?.id, reopenedData?.layers[0]?.frames[0]?.drawing.id], + position: point?.position, + radius: point?.radius, + opacity: point?.opacity, + }; + }, new Uint8Array(bytes)); + expect(result.revision).toBeGreaterThan(0); + expect(result.identity).toEqual(["grease-pencil:GreasePencilData", "grease-pencil-layer:GreasePencilData:Lines", "grease-pencil-drawing:GreasePencilData:0"]); + expect(result.position).toEqual([-1.25, 0, 0]); + expect(result.radius).toBeGreaterThan(0); + expect(result.opacity).toBeCloseTo(0.9); +}); + test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { @@ -557,6 +658,19 @@ test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", as expect(result.hit).toContain("PAINT_SCHEMA_INVALID"); expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED"); expect(result.brush).toEqual([{ index: 1, weight: 0.4 }, { index: 2, weight: 0.8 }]); + expect(result.spatialBrush).toEqual([16, 64, [450, 549, 550, 551, 650]]); + expect(result.selectedMasked).toEqual([{ index: 450, weight: expect.closeTo(0.005823, 5) }, { index: 550, weight: 0.5 }]); + expect(result.spatialVisibility).toContain("PAINT_SCHEMA_INVALID"); + expect(result.spatialSelection).toContain("PAINT_SCHEMA_INVALID"); + expect(result.selectionDuplicate).toContain("PAINT_SCHEMA_INVALID"); + expect(result.maskInvalid).toContain("PAINT_SCHEMA_INVALID"); + expect(result.selectionUnknown).toContain("PAINT_SCHEMA_INVALID"); + expect(result.spatialForgery).toContain("PAINT_SCHEMA_INVALID"); + expect(result.spatialMutation).toBe(16); + expect(result.weightPatch).toMatchObject({ indices: [0, 2], values: [1, 0.7], normalize: false }); + expect(result.colorPatch).toEqual({ indices: [0, 2], colors: [0, 1, 0, 0.5, 0.75, 0.25, 0, 0.875] }); + expect(result.patchRevision).toContain("REVISION_CONFLICT"); + expect(result.patchIdentity).toContain("PAINT_SCHEMA_INVALID"); expect((result.udim as number[]).slice(4, 8)).toEqual([10, 20, 30, 255]); expect(result.udimRevision).toContain("REVISION_CONFLICT"); expect(result.udimStale).toContain("PAINT_TILE_HASH_MISMATCH"); @@ -607,6 +721,28 @@ test("commits N-017 vertex color and weight patches from the bounded paint panel await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 }); }); +test("blends N-017 selection-masked color and weight patches through one Main transaction each", async ({ page }) => { + await page.goto("/"); + await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend); + await page.getByText("AttributeMeshObject", { exact: true }).click(); + await page.getByRole("button", { name: /Object Mode/ }).click(); + await page.getByRole("button", { name: "1 Vertex" }).click(); + await page.getByRole("button", { name: "Select All" }).click(); + const editor = page.getByTestId("paint-editor"); + await editor.getByLabel("Paint selection mask").fill("0.5"); + await editor.getByLabel("Paint vertex color").fill("#0080ff"); + let revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1"); + await editor.getByRole("button", { name: "Blend Color" }).click(); + await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1); + await expect(editor.getByTestId("paint-color-attribute")).toHaveText("WebPaintColor POINT"); + revision += 1; + await editor.getByLabel("Paint vertex group").fill("SelectionMaskPaint"); + await editor.getByLabel("Paint vertex weight").fill("0.8"); + await editor.getByRole("button", { name: "Blend Weight" }).click(); + await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1); + await expect(editor.getByTestId("paint-vertex-group")).toHaveText("SelectionMaskPaint"); +}); + test("validates the N-018 physics capability and cache manifests without claiming solvers", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { @@ -625,7 +761,12 @@ test("validates the N-018 physics capability and cache manifests without claimin expect(result.solver).toBe("PHYSICS_SOLVER_UNAVAILABLE"); expect(result.manifest).toBe("READY"); expect(result.browserPlayback).toEqual([7, "object:Cloth", [1, 2, 3]]); + expect(result.browserPreview).toEqual([7, [1, 2, 3], [1, 2, 3]]); + expect(result.browserFrameMismatch).toContain("PHYSICS_CACHE_FRAME_MISMATCH"); expect(result.browserRotation).toContain("PHYSICS_CACHE_FRAME_MISMATCH"); + expect(result.browserSession).toEqual(["COMPLETED", 2, 8, [7, 8]]); + expect(result.browserSupersede).toEqual([true, 8, [8]]); + expect(result.browserCancel).toEqual([true, []]); }); test("maps N-019 Scene exposure and light shadow metadata without using legacy World exposure", async ({ page }) => { @@ -637,7 +778,7 @@ test("maps N-019 Scene exposure and light shadow metadata without using legacy W await expect(canvas).toHaveAttribute("data-view-look", "None"); await expect(canvas).toHaveAttribute("data-mist", "disabled"); const mapping = await page.evaluate(async () => { - const { blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts"); + const { blenderLightColor, blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts"); const { Object3D } = await import("/src/vendor/three/three.module.js"); const definition = { id: "light:test", name: "Test", lightType: 0, color: [1, 1, 1], energy: 100, exposure: 2, @@ -650,9 +791,44 @@ test("maps N-019 Scene exposure and light shadow metadata without using legacy W selectable: true, localMatrix: new Array(16).fill(0), worldMatrix: new Array(16).fill(0), transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 }, }, new Object3D()); - return { intensity: blenderLightIntensity(definition), castShadow: light.castShadow, sourceShadow: light.userData.blenderCastsShadow }; + const warmDefinition = { ...definition, color: [1, 1, 1] as [number, number, number], useTemperature: true, temperature: 5000 }; + const neutralDefinition = { ...warmDefinition, temperature: 6500 }; + const disabledDefinition = { ...warmDefinition, color: [0.25, 0.5, 0.75] as [number, number, number], useTemperature: false }; + return { + intensity: blenderLightIntensity(definition), + castShadow: light.castShadow, + sourceShadow: light.userData.blenderCastsShadow, + warm: blenderLightColor(warmDefinition), + neutral: blenderLightColor(neutralDefinition), + disabled: blenderLightColor(disabledDefinition), + appliedWarm: createPBRLight(warmDefinition).color.toArray(), + }; }); - expect(mapping).toEqual({ intensity: 40, castShadow: false, sourceShadow: false }); + expect(mapping.intensity).toBe(40); + expect(mapping.castShadow).toBe(false); + expect(mapping.sourceShadow).toBe(false); + expect(mapping.neutral).toEqual([1, 1, 1]); + expect(mapping.disabled).toEqual([0.25, 0.5, 0.75]); + expect(mapping.warm[0]).toBe(1); + expect(mapping.warm[1]).toBeGreaterThan(0.7); + expect(mapping.warm[1]).toBeLessThan(0.9); + expect(mapping.warm[2]).toBeGreaterThan(0.5); + expect(mapping.warm[2]).toBeLessThan(0.75); + expect(mapping.appliedWarm).toEqual(mapping.warm); +}); + +test("preserves N-019 World and Scene color management in renderer-bound SceneDelta", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { + const worker = new Worker("/src/workers/scene-delta-render-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({}); + })); + expect(result.collections).toEqual([1, 1]); + expect(result.applied).toEqual([[0.8, 0.4, 0.2], 2, "Standard", 1]); + expect(result.rebuild).toBe(true); + expect(result.invalid).toContain("SceneDelta.worlds is invalid"); }); test("executes the bounded N-020 CPU compositor and preserves unsupported nodes as gates", async ({ page }) => { @@ -670,6 +846,34 @@ test("executes the bounded N-020 CPU compositor and preserves unsupported nodes expect(result.unsupported).toBe("COMPOSITOR_NODE_UNSUPPORTED"); expect(result.budget).toContain("COMPOSITOR_BUDGET_EXCEEDED"); expect(result.cancelled).toContain("COMPOSITOR_CANCELLED"); + 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 }) => { + await page.goto("/"); + const bytes = await import("node:fs").then((fs) => fs.readFileSync(compositorBlend)); + const result = await page.evaluate(async (input) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const { executeCompositorGraph, gateCompositorGraph } = await import("/src/compositor/CompositorExecutor.ts"); + const client = new WebEngineClient({ timeoutMs: 20_000 }); + try { + 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 }); + return { + status: scene.compositorStatus, + pixel: Array.from(execution.composite.data), + evaluated: execution.evaluatedNodeIds.map((id) => scene.compositorGraph!.nodes.find((node) => node.id === id)?.name), + 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.gate).toBe("COMPOSITOR_NODE_UNSUPPORTED"); }); test("validates N-021 sequencer strips, deterministic edits, sandbox paths and codec gates", async ({ page }) => { @@ -682,14 +886,39 @@ test("validates N-021 sequencer strips, deterministic edits, sandbox paths and c })); expect(result.valid).toBe(105); expect(result.edit).toEqual([5, [["strip:Movie", 15, 20, 100, 105], ["strip:MovieRight", 20, 25, 105, 110]]]); + expect(result.frame).toEqual([["strip:MovieRight", 3, 105]]); expect(result.revision).toContain("REVISION_CONFLICT"); expect(result.path).toContain("SEQUENCER_RESOURCE_OUTSIDE_PROJECT"); expect(result.cycle).toContain("SEQUENCER_DEPENDENCY_CYCLE"); expect(result.budget).toContain("SEQUENCER_BUDGET_EXCEEDED"); expect(result.codec).toBe("SEQUENCER_CODEC_UNSUPPORTED"); + expect(result.transition).toEqual(["CROSS", 0.5, "strip:From", 105, "strip:To", 205]); + expect(result.transitionBoundary).toContain("SEQUENCER_SCHEMA_INVALID"); expect((result.runtime as { localEncoding: string }).localEncoding).toBe("BLOCKED"); }); +test("resolves the N-021 transition frame from a real Blender 5.2 sequencer", async ({ page }) => { + await page.goto("/"); + const bytes = await import("node:fs").then((fs) => fs.readFileSync(sequencerBlend)); + const result = await page.evaluate(async (input) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const { resolveSequencerTransitionFrame } = await import("/src/sequencer/SequencerTimeline.ts"); + const client = new WebEngineClient({ timeoutMs: 20_000 }); + try { + const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); + const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "SequencerScene"); + const timeline = scene?.sequencerTimeline; + const effect = timeline?.strips.find((strip) => strip.name === "WebCross"); + if (!timeline || !effect) throw new Error("Real sequencer transition is missing"); + const transition = resolveSequencerTransitionFrame(timeline, effect.id, 22); + const names = new Map(timeline.strips.map((strip) => [strip.id, strip.name])); + return { status: scene.sequencerStatus, type: transition.effectType, factor: transition.factor, from: [names.get(transition.from.stripId), transition.from.sourceFrame], to: [names.get(transition.to.stripId), transition.to.sourceFrame] }; + } + finally { client.terminate(); } + }, new Uint8Array(bytes)); + expect(result).toEqual({ status: "AVAILABLE", type: "CROSS", factor: 0.5, from: ["WebImage", 1], to: ["WebImageB", 1] }); +}); + test("validates N-022 tracking markers, masks, resource bindings and solve gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { @@ -699,6 +928,7 @@ test("validates N-022 tracking markers, masks, resource bindings and solve gates worker.postMessage({}); })); expect(result.edit).toEqual([6, [1, 10], [0.4, 0.6]]); + expect(result.raycast).toMatchObject({ maskId: "mask:1", layerId: "layer:1", splineId: "spline:1", kind: "POINT", pointId: "point:1", distance: 0 }); expect(result.revision).toContain("REVISION_CONFLICT"); expect(result.path).toContain("TRACKING_RESOURCE_OUTSIDE_PROJECT"); expect(result.binding).toContain("TRACKING_BINDING_MISSING"); @@ -708,6 +938,34 @@ test("validates N-022 tracking markers, masks, resource bindings and solve gates expect(result.solveGate).toBe("BLOCKED"); }); +test("raycasts and marquee-selects editable N-022 points from a real Blender 5.2 Mask", async ({ page }) => { + await page.goto("/"); + const bytes = await import("node:fs").then((fs) => fs.readFileSync(maskBlend)); + const result = await page.evaluate(async (input) => { + const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); + const { raycastMaskProject, selectMaskPointsInBounds } = await import("/src/tracking/MaskSelection.ts"); + const client = new WebEngineClient({ timeoutMs: 20_000 }); + try { + const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); + const project = opened.snapshot.trackingMasks; + if (!project) throw new Error("Real Mask project is missing"); + const locked = raycastMaskProject(project, [0.1, 0.2], 0.001); + const editable = raycastMaskProject(project, [0.2, 0.2], 0.001); + const selected = selectMaskPointsInBounds(project, [0.05, 0.15], [0.25, 0.25]); + const toggled = selectMaskPointsInBounds(project, [0.05, 0.15], [0.25, 0.25], selected, "TOGGLE"); + const layerNames = new Map(project.masks[0].layers.map((layer) => [layer.id, layer.name])); + return { status: opened.snapshot.trackingMaskStatus, locked, editable: editable && { ...editable, layerName: layerNames.get(editable.layerId) }, selected: selected.map((item) => [layerNames.get(item.layerId), item.pointId]), toggled }; + } + finally { client.terminate(); } + }, new Uint8Array(bytes)); + expect(result.status).toBe("AVAILABLE"); + expect(result.locked).toBeNull(); + expect(result.editable).toMatchObject({ kind: "POINT", layerName: "WebEditableLayer", pointId: "mask-point:1:0:0" }); + expect(result.editable?.distance).toBeLessThan(1e-7); + expect(result.selected).toEqual([["WebEditableLayer", "mask-point:1:0:0"]]); + expect(result.toggled).toEqual([]); +}); + test("validates N-023 asset catalogs, library graphs, archive budgets and IO gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { @@ -721,11 +979,19 @@ test("validates N-023 asset catalogs, library graphs, archive budgets and IO gat expect(result.license).toContain("ASSET_LICENSE_MISSING"); expect(result.cycle).toContain("LIBRARY_DEPENDENCY_CYCLE"); expect(result.archive).toContain("IO_ARCHIVE_UNSAFE"); + expect(result.archivePath).toContain("IO_ARCHIVE_UNSAFE"); + expect(result.archiveLength).toContain("IO_ARCHIVE_UNSAFE"); + expect(result.archivePlan).toEqual([ + { path: "a.bin", compressedBytes: 2, uncompressedBytes: 2, compressedOffset: 0 }, + { path: "z.bin", compressedBytes: 3, uncompressedBytes: 4, compressedOffset: 2 }, + ]); expect(result.uri).toContain("IO_EXTERNAL_URI_BLOCKED"); expect(result.glb).toBe("READY"); expect(result.obj).toBe("IO_FORMAT_UNSUPPORTED"); expect(result.library).toBe("BLOCKED"); expect((result.storage as { contentAddressedIndex: string }).contentAddressedIndex).toBe("LOCAL_BOUNDED"); + expect(result.preview).toMatch(/^[a-f0-9]{64}$/); + expect(result.previewSize).toContain("ASSET_MANIFEST_INVALID"); }); test("validates N-024 editor context, selection sync, layout budgets and workflow gates", async ({ page }) => { @@ -743,6 +1009,9 @@ test("validates N-024 editor context, selection sync, layout budgets and workflo expect(result.view).toBe("READY"); expect(result.writer).toBe("EDITOR_WRITER_UNAVAILABLE"); expect(result.gizmo).toBe("EDITOR_GIZMO_UNAVAILABLE"); + expect(result.keymap).toBe("object.delete"); + expect(result.keymapConflict).toContain("EDITOR_KEYMAP_INVALID"); + expect(result.scopedKeymap).toEqual(["view.command", "timeline.command"]); }); test("validates N-025 script policy, signatures, budgets and platform gates", async ({ page }) => { @@ -755,6 +1024,11 @@ test("validates N-025 script policy, signatures, budgets and platform gates", as })); expect(result.valid).toBe("scripts/clean.py"); expect(result.exec).toBe("SCRIPT_SANDBOX_UNAVAILABLE"); + expect(result.audit).toEqual(["DENY", "SCRIPT_SANDBOX_UNAVAILABLE", true, ["READ_MAIN"], 1000, expect.stringMatching(/^[a-f0-9]{64}$/), expect.stringMatching(/^[a-f0-9]{64}$/)]); + expect(result.auditLog).toEqual([2, null, expect.stringMatching(/^[a-f0-9]{64}$/), expect.stringMatching(/^[a-f0-9]{64}$/)]); + expect(result.auditReplay).toContain("SCRIPT_MANIFEST_INVALID"); + expect(result.auditTamper).toContain("SCRIPT_MANIFEST_INVALID"); + expect(result.auditDate).toContain("SCRIPT_MANIFEST_INVALID"); expect(result.server).toBe("SERVER_JOB_UNAVAILABLE"); expect(result.path).toContain("SCRIPT_MANIFEST_INVALID"); expect(result.policy).toContain("SCRIPT_POLICY_DENIED"); @@ -777,6 +1051,10 @@ test("keeps N-026 release manifest deterministic and blocks missing evidence", a expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE"); expect(result.status).toContain("RELEASE_MANIFEST_INVALID"); expect(result.unbound).toContain("RELEASE_EVIDENCE_MISSING"); + expect(result.excludedOverlap).toContain("RELEASE_MANIFEST_INVALID"); + expect(result.disabledEvidence).toContain("RELEASE_MANIFEST_INVALID"); + expect(result.emptyArtifact).toContain("RELEASE_MANIFEST_INVALID"); + expect(result.generatedAt).toContain("RELEASE_MANIFEST_INVALID"); }); test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => { @@ -797,7 +1075,7 @@ test("keeps N-015 selection history bounded and rejects stale raycast hits", asy expect(result.handleHit).toEqual(["object:1", "HANDLE_RIGHT"]); expect(result.rangePatch).toEqual([["curve:1", [1, 2, 4]]]); expect(result.migrated).toEqual([2, "mesh:1"]); - expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]); + expect(result.gates).toEqual(["READY", "READY", "READY"]); }); test("validates the N-015 curve gizmo interaction transaction boundary", async ({ page }) => { @@ -808,23 +1086,324 @@ test("validates the N-015 curve gizmo interaction transaction boundary", async ( worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); + expect(result.preview).toEqual([3, [1.25, 2, 3]]); expect(result.commit).toEqual([4, [1.25, 2, 3]]); + expect(result.rendererPreview).toEqual([[ -0.25, -0.25 ], -0.25, -0.5]); + expect(result.localFrame).toEqual([[0, 1, 0], [[0, 1, 0], [-1, 0, 0], [0, -0, 1]], [0, 1.25, 0]]); expect(result.stale).toContain("REVISION_CONFLICT"); expect(result.duplicate).toContain("CURVE_GIZMO_INVALID"); expect(result.axis).toContain("CURVE_GIZMO_INVALID"); }); -test("validates bounded OpenVDB metadata, SHA and cancellation", async ({ page }) => { +for (const offscreen of [false, true]) test(`previews an N-015 Curve handle drag and commits Main once in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(offscreen ? "/?offscreen=1" : "/"); + await page.setInputFiles("[data-testid=blend-file-input]", nonMeshBlend); + await expect(page.getByText("WebCurveObject", { exact: true })).toBeVisible({ timeout: 20_000 }); + await page.getByText("WebCurveObject", { exact: true }).click(); + await page.getByRole("button", { name: "Object Mode" }).click(); + const bytes = await import("node:fs").then((fs) => fs.readFileSync(nonMeshBlend)); + const handle = await page.evaluate((input) => new Promise<[number, number, number]>((resolve, reject) => { + const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); + worker.onmessage = (event) => { + if (event.data.kind !== "result" || event.data.requestId !== "curve-handle-open") return; + worker.terminate(); + if (!event.data.ok) { reject(new Error(event.data.error?.message ?? "Curve fixture open failed")); return; } + const curve = event.data.result?.snapshot?.nonMeshData?.find((item: { id: string }) => item.id === "curve:WebCurveData"); + if (!curve?.handlePoints?.length) { reject(new Error("Curve handle metadata is missing")); return; } + resolve(curve.handlePoints.slice(0, 3)); + }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); + worker.postMessage({ requestId: "curve-handle-open", command: { type: "openBlend", buffer } }, [buffer]); + }), new Uint8Array(bytes)); + const hit = await page.locator("canvas.viewport-canvas").evaluate(async (canvas, input) => { + 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.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + camera.updateProjectionMatrix(); + const projected = new Vector3(input.position[0], input.position[2], -input.position[1]).project(camera); + return { x: bounds.left + (projected.x + 1) * bounds.width / 2, y: bounds.top + (1 - projected.y) * bounds.height / 2 }; + }, { position: handle, offscreen }); + await page.mouse.click(hit.x, hit.y); + if (!offscreen) await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-non-mesh-last-pick", /HANDLE_LEFT/); + await expect(page.getByText("1 vert selected", { exact: true })).toBeVisible(); + const gizmo = page.getByLabel("变换 Gizmo"); + await expect(gizmo).toHaveAttribute("data-gizmo-space", "HANDLE_LOCAL"); + const localAxis = await page.getByRole("button", { name: "X 轴变换手柄" }).getAttribute("data-local-axis"); + expect(localAxis).toMatch(/^-?\d+\.\d{6},-?\d+\.\d{6},-?\d+\.\d{6}$/); + const revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1"); + const axis = page.getByRole("button", { name: "X 轴变换手柄" }); + const bounds = await axis.boundingBox(); + if (!bounds) throw new Error("Curve gizmo X axis is unavailable"); + const screenAxis = (await axis.getAttribute("data-screen-axis"))?.split(",").map(Number) ?? []; + expect(screenAxis).toHaveLength(2); + expect(Math.hypot(screenAxis[0], screenAxis[1])).toBeGreaterThan(0.5); + await page.screenshot({ path: testInfo.outputPath(`curve-handle-local-${offscreen ? "offscreen" : "main"}-1440x900.png`), fullPage: true }); + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width / 2 + screenAxis[0] * 24, bounds.y + bounds.height / 2 + screenAxis[1] * 24, { steps: 3 }); + await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-curve-gizmo-preview", "1"); + await page.mouse.up(); + await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-curve-gizmo-preview", "0"); + await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1); +}); + +test("validates the VDB conversion boundary and NanoVDB streaming contract", async ({ page }) => { await page.goto("/"); - const result = await page.evaluate(() => new Promise<{ decodedByteLength: number; outsideProject: string; cancelled: boolean }>((resolve, reject) => { + const result = await page.evaluate(() => new Promise<{ + preparedByteLength: number; + conversionTarget: string; + conversionRequestSha256: string; + relocationKeepsContentKey: boolean; + ranges: Array<{ chunkIndex: number; start: number; endExclusive: number }>; + consumed: number[]; + progress: number[]; + stream: { completedChunks: number; completedBytes: number; totalBytes: number }; + httpRangeByteLength: number; + invalidHttpRange: string; + outsideProject: string; + tamperedChunk: string; + incompleteStream: string; + cancelled: boolean; + rawBrowserGate: { status: string; issues: Array<{ code: string }> }; + streamGate: { status: string }; + renderGate: { status: string; issues: Array<{ code: string }> }; + }>((resolve, reject) => { const worker = new Worker("/src/workers/vdb-test.worker.ts", { type: "module" }); worker.onmessage = (event) => { worker.terminate(); if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); - expect(result.decodedByteLength).toBe(4); + expect(result.preparedByteLength).toBe(64); + expect(result.conversionTarget).toBe("SERVER"); + expect(result.conversionRequestSha256).toMatch(/^[a-f0-9]{64}$/); + expect(result.relocationKeepsContentKey).toBe(true); + expect(result.ranges).toEqual([ + { chunkIndex: 0, start: 0, endExclusive: 32, sha256: expect.any(String) }, + { chunkIndex: 1, start: 32, endExclusive: 64, sha256: expect.any(String) }, + ]); + expect(result.consumed).toEqual([0, 1]); + expect(result.progress).toEqual([32, 64]); + expect(result.stream).toMatchObject({ completedChunks: 2, completedBytes: 64, totalBytes: 64 }); + expect(result.httpRangeByteLength).toBe(32); + expect(result.invalidHttpRange).toContain("NANOVDB_STREAM_INCOMPLETE"); expect(result.outsideProject).toContain("NON_MESH_RESOURCE_OUTSIDE_PROJECT"); + expect(result.tamperedChunk).toContain("NANOVDB_HASH_MISMATCH"); + expect(result.incompleteStream).toContain("NANOVDB_STREAM_INCOMPLETE"); expect(result.cancelled).toBe(true); + expect(result.rawBrowserGate.status).toBe("BLOCKED"); + expect(result.rawBrowserGate.issues[0].code).toBe("VDB_CONVERSION_REQUIRED"); + expect(result.streamGate.status).toBe("READY"); + expect(result.renderGate.status).toBe("BLOCKED"); + expect(result.renderGate.issues[0].code).toBe("VOLUME_SHADER_UNAVAILABLE"); +}); + +test("commits and reopens a hash-bound NanoVDB project through OPFS", async ({ page }) => { + await page.goto("/"); + const run = (action: "commit" | "reopen", state?: unknown) => page.evaluate(({ action, state }) => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/vdb-opfs-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 committed = await run("commit"); + expect(committed.committed).toMatchObject({ chunks: committed.realChunkCount, deduplicated: false }); + expect(committed.deduplicated).toBe(true); + expect(committed.cancelled).toBe(true); + expect(committed.recovered.removedIncompleteBundles).toBe(1); + expect(committed.realBundleBytes).toBeGreaterThan(10_000_000); + expect(committed.realChunkCount).toBeGreaterThan(1); + + // A new Worker proves discovery does not depend on temporary in-memory state. + const reopened = await run("reopen", committed.state); + expect(reopened.bindingStatus.status).toBe("READY"); + expect(reopened.staleStatus).toMatchObject({ status: "BLOCKED", code: "VDB_SOURCE_CHANGED" }); + expect(reopened.bundleHash).toBe(reopened.expectedHash); + expect(reopened.tamperedChunk).toContain("NANOVDB_HASH_MISMATCH"); + expect(reopened.manifestRollback).toContain("NANOVDB_HASH_MISMATCH"); + expect(reopened.pruned).toMatchObject({ removed: [committed.state.bundleSha256], retainedBytes: 0 }); +}); + +test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/vdb-webgpu-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.capability.available).toBe(true); + expect(result.payloadBytes).toBeGreaterThan(1_000_000); + expect(result.cpuSamples).toHaveLength(result.nativeSamples.length); + expect(result.gpuSamples).toHaveLength(result.nativeSamples.length); + result.nativeSamples.forEach((sample: any, index: number) => { + expect(result.cpuSamples[index].active).toBe(sample.active); + expect(result.cpuSamples[index].value).toBeCloseTo(sample.value, 6); + expect(result.gpuSamples[index].valid).toBe(true); + expect(result.gpuSamples[index].active).toBe(sample.active); + expect(result.gpuSamples[index].value).toBeCloseTo(sample.value, 6); + }); + expect(result.visiblePixels).toBeGreaterThan(500); + expect(result.alphaSum).toBeGreaterThan(10_000); + expect(result.imageSha256).toBe("7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978"); + expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]); + expect(result.materialMapping.material).toMatchObject({ interpolation: "LINEAR", color: [0.7, 0.8, 0.95], emissionColor: [1, 0.35, 0.1] }); + expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual([ + "VOLUME_COLOR_GRID_UNSUPPORTED", + "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED", + "VOLUME_VELOCITY_RENDER_UNSUPPORTED", + ]); +}); + +test("renders a real NanoVDB volume through both production viewport backends", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const [{ loadNanoVDBViewportAsset }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([ + import("/src/volume/nanovdb-viewport.ts"), + import("/src/three-adapter/viewport.ts"), + import("/src/three-adapter/offscreen-viewport.ts"), + ]); + const asset = await loadNanoVDBViewportAsset("volume:ViewportSmoke", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal); + const snapshot: any = { + schemaVersion: 1, revision: 1, sceneId: "scene:Volume", source: { kind: "mock" }, + coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 }, + nodes: [{ + id: "object:Volume", name: "Viewport Volume", type: "VOLUME", dataId: asset.dataId, parentId: null, visible: true, + localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1] }, + }], + meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [{ id: "scene:Volume", name: "Volume" }], + nonMeshData: [{ id: asset.dataId, name: "Viewport Volume", type: "VOLUME", geometryStatus: "blocked", pointCount: 0, splineCount: 0, sourcePath: "//volumes/generated-smoke.vdb", resourceKind: "OPENVDB" }], + activeObjectId: "object:Volume", frame: { current: 1, start: 1, end: 250 }, + }; + const waitFor = async (condition: () => boolean, timeoutMs = 20_000): Promise => { + const deadline = performance.now() + timeoutMs; + while (!condition()) { + if (performance.now() > deadline) throw new Error("viewport volume timed out"); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + }; + const createCanvas = (): HTMLCanvasElement => { + 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); + return canvas; + }; + + const mainCanvas = createCanvas(); + const main = new ViewportRenderer(mainCanvas); + main.setSnapshot(snapshot); + main.setVolumeAssets([asset]); + await waitFor(() => mainCanvas.dataset.volumeStatus === "ready"); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + let mainVolumeObjects = 0; + main.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) mainVolumeObjects++; }); + const mainPixels = new Uint8Array(64 * 64 * 4); + const gl = main.renderer.getContext(); + 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, mainPixels); + const mainVisible = Array.from({ length: 64 * 64 }, (_, index) => mainPixels[index * 4 + 3] > 0 && (mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2]) > 40).filter(Boolean).length; + main.dispose(); + mainCanvas.remove(); + + const offscreenCanvas = createCanvas(); + const offscreen = new OffscreenViewportRenderer(offscreenCanvas); + offscreen.setSnapshot(snapshot); + offscreen.setVolumeAssets([asset]); + await waitFor(() => offscreenCanvas.dataset.volumeStatus === "ready" && Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0); + const offscreenResult = { status: offscreenCanvas.dataset.volumeStatus, count: Number(offscreenCanvas.dataset.volumeCount), visible: Number(offscreenCanvas.dataset.rendererPixels) }; + offscreen.dispose(); + offscreenCanvas.remove(); + return { + payloadBytes: asset.grids[0].data.byteLength, + main: { status: mainCanvas.dataset.volumeStatus, count: Number(mainCanvas.dataset.volumeCount), volumeObjects: mainVolumeObjects, visible: mainVisible }, + offscreen: offscreenResult, + }; + }); + expect(result.payloadBytes).toBeGreaterThan(1_000_000); + expect(result.main).toMatchObject({ status: "ready", count: 1, volumeObjects: 1 }); + expect(result.main.visible).toBeGreaterThan(100); + expect(result.offscreen).toMatchObject({ status: "ready", count: 1 }); + expect(result.offscreen.visible).toBeGreaterThan(10); +}); + +test("recovers NanoVDB paging from network, Worker and WebGPU device faults", async ({ page }) => { + await page.goto("/"); + const gpu = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/vdb-fault-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(gpu.network.attempts).toBe(3); + expect(gpu.network.ifRanges[0]).toBe(""); + expect(gpu.network.ifRanges[2]).toMatch(/^"vdb-/); + expect(gpu.network.resumeRanges).toHaveLength(2); + expect(gpu.network.resumeRanges[0]).toMatch(/^bytes=\d+-\d+$/); + const originalStart = Number(gpu.network.resumeRanges[0].match(/^bytes=(\d+)-/)?.[1]); + const resumedStart = Number(gpu.network.resumeRanges[1].match(/^bytes=(\d+)-/)?.[1]); + expect(resumedStart).toBe(originalStart + 4096); + expect(gpu.network.resumeIfRanges[0]).toBe(""); + expect(gpu.network.resumeIfRanges[1]).toMatch(/^"vdb-/); + expect(gpu.network.resumedBytes).toBe(gpu.network.firstBytes); + expect(gpu.network.shortResponse).toContain("NANOVDB_STREAM_INCOMPLETE"); + expect(gpu.network.changedEtag).toContain("NANOVDB_HASH_MISMATCH"); + expect(gpu.network.outOfOrderResponse).toContain("NANOVDB_STREAM_INCOMPLETE"); + expect(gpu.lru).toMatchObject({ residentPages: 2, residentBytes: 128 * 1024, evictions: 1, keys: ["page-a", "page-c"] }); + expect(gpu.oom).toContain("NANOVDB_GPU_BUDGET_EXCEEDED"); + 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.samplesStable).toBe(true); + + const interrupted = await page.evaluate(() => new Promise((resolve, reject) => { + const worker = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" }); + const timeout = setTimeout(() => { worker.terminate(); reject(new Error("OPFS interrupt gate timed out")); }, 20_000); + worker.onmessage = (event) => { + if (!event.data.staged) return; + clearTimeout(timeout); + worker.terminate(); + setTimeout(() => { + const recovery = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" }); + recovery.onmessage = (recoveryEvent) => { recovery.terminate(); recoveryEvent.data.error ? reject(new Error(recoveryEvent.data.error)) : resolve(recoveryEvent.data); }; + recovery.onerror = (error) => { recovery.terminate(); reject(new Error(error.message)); }; + recovery.postMessage({ action: "recoverInterrupted" }); + }, 100); + }; + worker.onerror = (error) => { clearTimeout(timeout); worker.terminate(); reject(new Error(error.message)); }; + worker.postMessage({ action: "interrupt" }); + })); + expect(interrupted.removedIncompleteBundles).toBeGreaterThanOrEqual(1); + + const runOPFS = (action: "prepareQuota" | "quota" | "verifyQuota", state?: unknown): Promise => page.evaluate(({ action, state }) => new Promise((resolve, reject) => { + const holder = window as unknown as { vdbQuotaWorker?: Worker }; + const worker = holder.vdbQuotaWorker ?? new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" }); + holder.vdbQuotaWorker = worker; + worker.onmessage = (event) => { event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; + worker.onerror = (error) => { reject(new Error(error.message)); }; + worker.postMessage({ action, state }); + }), { action, state }); + const baseline = await runOPFS("prepareQuota"); + const cdp = await page.context().newCDPSession(page); + const origin = new URL(page.url()).origin; + const usage = await cdp.send("Storage.getUsageAndQuota", { origin }); + await cdp.send("Storage.overrideQuotaForOrigin", { origin, quotaSize: usage.usage + 96 * 1024 }); + const quota = await runOPFS("quota", baseline.state); + expect(quota.quotaError).toMatch(/QuotaExceededError|quota/i); + if (quota.recoveryWhileQuotaLimited) expect(quota.recoveryWhileQuotaLimited).toMatch(/QuotaExceededError|quota/i); + await cdp.send("Storage.overrideQuotaForOrigin", { origin, quotaSize: usage.usage + 512 * 1024 * 1024 }); + const verified = await runOPFS("verifyQuota", baseline.state); + expect(verified.previousBundleReadable).toBe(true); + expect((quota.recoveredWhileQuotaLimited?.removedIncompleteBundles ?? 0) + verified.recovered.removedIncompleteBundles).toBeGreaterThanOrEqual(1); + await page.evaluate(() => { + const holder = window as unknown as { vdbQuotaWorker?: Worker }; + holder.vdbQuotaWorker?.terminate(); + delete holder.vdbQuotaWorker; + }); }); test("returns real Blender evaluations for legacy non-mesh geometry", async ({ page }) => { @@ -865,7 +1444,9 @@ test("keeps N-015 non-mesh previews in the OffscreenCanvas renderer", async ({ p const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-renderer-backend", "offscreen-worker"); await expect(canvas).toHaveAttribute("data-non-mesh-count", "6", { timeout: 20_000 }); - await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2"); + await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "1"); + await expect(canvas).toHaveAttribute("data-volume-status", "blocked"); + await expect(canvas).toHaveAttribute("data-volume-error-code", "NON_MESH_RESOURCE_MISSING"); await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); expect(await canvas.getAttribute("data-renderer-error")).toBeNull(); }); @@ -1025,16 +1606,27 @@ test("persists and revalidates content-addressed Simulation caches across Worker 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 digest = async (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(""); }; + const transformFrame = (frame: number, x: number): ArrayBuffer => { + const bytes = new ArrayBuffer(88); + const view = new DataView(bytes); + view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, frame, true); view.setUint32(12, 1, true); + const id = new TextEncoder().encode("object:CacheTarget"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id); + [x, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true)); + return bytes; + }; const projectId = `simulation-e2e-${Date.now()}`; const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44]).buffer; - const source = Uint8Array.from([11, 12, 13, 21, 22, 23, 24]); - const frameOne = source.buffer.slice(0, 3); - const frameTwo = source.buffer.slice(3); - const payload = source.buffer.slice(0); + const frameOne = transformFrame(1, 1); + const frameTwo = transformFrame(2, 2); + const payloadBytes = new Uint8Array(frameOne.byteLength + frameTwo.byteLength); + payloadBytes.set(new Uint8Array(frameOne), 0); + 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, @@ -1048,8 +1640,8 @@ test("persists and revalidates content-addressed Simulation caches across Worker frameEnd: 2, byteLength: payload.byteLength, frames: [ - { frame: 1, byteOffset: 0, byteLength: 3, sha256: await digest(frameOne) }, - { frame: 2, byteOffset: 3, byteLength: 4, sha256: await digest(frameTwo) }, + { frame: 1, byteOffset: 0, byteLength: frameOne.byteLength, sha256: await digest(frameOne) }, + { frame: 2, byteOffset: frameOne.byteLength, byteLength: frameTwo.byteLength, sha256: await digest(frameTwo) }, ], }; const first = new StorageClient(); @@ -1061,6 +1653,12 @@ test("persists and revalidates content-addressed Simulation caches across Worker const listed = await restarted.listSimulationCaches(projectId); const read = await restarted.readSimulationCache(projectId, stored.cacheKey); const frameRead = await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 2); + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + 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; }); + await playback.seek(2); let missingFrameCode = ""; try { await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 3); @@ -1070,7 +1668,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker } let corruptCode = ""; try { - await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, source.buffer.slice(0)); + await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, frameOne.slice(0)); } catch (error) { corruptCode = String((error as Error & { code?: string }).code ?? ""); @@ -1080,10 +1678,13 @@ test("persists and revalidates content-addressed Simulation caches across Worker cacheKey: stored.cacheKey, path: stored.path, listed: listed.caches.map((cache) => cache.cacheKey), - bytes: Array.from(new Uint8Array(read.data)), + bytes: read.data.byteLength, frame: frameRead.frame, frameOffset: frameRead.byteOffset, - frameBytes: Array.from(new Uint8Array(frameRead.data)), + frameBytes: frameRead.data.byteLength, + frameMagic: new DataView(frameRead.data).getUint32(0, true), + publishedFrame, + publishedTranslation, missingFrameCode, corruptCode, }; @@ -1091,10 +1692,13 @@ test("persists and revalidates content-addressed Simulation caches across Worker expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/); 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).toEqual([11, 12, 13, 21, 22, 23, 24]); + expect(result.bytes).toBe(176); expect(result.frame).toBe(2); - expect(result.frameOffset).toBe(3); - expect(result.frameBytes).toEqual([21, 22, 23, 24]); + expect(result.frameOffset).toBe(88); + expect(result.frameBytes).toBe(88); + expect(result.frameMagic).toBe(0x31465442); + expect(result.publishedFrame).toBe(2); + expect(result.publishedTranslation).toEqual([2, 0, 0]); expect(result.missingFrameCode).toBe("SIMULATION_CACHE_MISSING"); expect(result.corruptCode).toBe("SIMULATION_CACHE_HASH_MISMATCH"); }); @@ -1386,15 +1990,35 @@ test("discovers and reuses a valid LOD cache after an application refresh", asyn await expect(page.locator("[data-testid=engine-status]")).toContainText("cached LOD mesh", { timeout: 15_000 }); }); -test("workspace context routes mode and operator search state", async ({ page }) => { +test("operator search executes context-filtered workspace, mode and Main commands", async ({ page }) => { await page.goto("/"); + await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); + await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Modeling" }).click(); await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Modeling"); - await page.getByRole("button", { name: "Object Mode" }).click(); + + await page.keyboard.press("F3"); + await page.getByRole("textbox", { name: "搜索操作" }).fill("switch to animation"); + await page.keyboard.press("Enter"); + await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Animation"); + await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0); + + await page.keyboard.press("F3"); + await page.getByRole("textbox", { name: "搜索操作" }).fill("enter edit"); + await page.keyboard.press("Enter"); await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible(); - await page.getByRole("button", { name: "操作搜索" }).click(); + await page.keyboard.press("F3"); + await page.getByRole("textbox", { name: "搜索操作" }).fill("cube"); + await expect(page.getByRole("button", { name: "Add Cube" })).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0); + + await page.getByRole("button", { name: "Edit Mode" }).click(); + await page.keyboard.press("F3"); await page.getByRole("textbox", { name: "搜索操作" }).fill("cube"); await expect(page.getByRole("button", { name: "Add Cube" })).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r2 (4 objects)"); }); test("keeps Outliner selection bound to SceneIR activeObjectId", async ({ page }) => { @@ -1425,6 +2049,42 @@ test("timeline transport controls update the imported frame range", async ({ pag await expect(page.locator(".frame-number")).toHaveText("1"); }); +test("serializes concurrent WebEngine init and blend open before the first Main edit", async ({ page }) => { + await page.goto("/"); + const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); + const result = await page.evaluate((input) => new Promise<{ initReady: boolean; frame: number; revision: number }>((resolve, reject) => { + const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); + let initReady = false; + worker.onmessage = (event) => { + if (event.data.kind !== "result") return; + if (!event.data.ok) { + worker.terminate(); + reject(new Error(event.data.error?.message ?? "WebEngine request failed")); + return; + } + if (event.data.requestId === "concurrent-init") { + initReady = event.data.result?.status?.ready === true; + return; + } + if (event.data.requestId === "concurrent-open") { + worker.postMessage({ requestId: "concurrent-edit", command: { type: "applyCommand", payload: { type: "setFrame", frame: 24 } } }); + return; + } + if (event.data.requestId === "concurrent-edit") { + const snapshot = event.data.result?.snapshot; + worker.terminate(); + if (!snapshot) { reject(new Error("WebEngine edit returned no SceneIR")); return; } + resolve({ initReady, frame: snapshot.frame.current, revision: snapshot.revision }); + } + }; + worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; + const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); + worker.postMessage({ requestId: "concurrent-init", command: { type: "init" } }); + worker.postMessage({ requestId: "concurrent-open", command: { type: "openBlend", buffer } }, [buffer]); + }), new Uint8Array(bytes)); + expect(result).toEqual({ initReady: true, frame: 24, revision: 2 }); +}); + test("loads the local web_engine WASM worker", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ ok: boolean; ready?: boolean; error?: string }>((resolve) => { diff --git a/web/tests/e2e/texture-4k-performance.spec.ts b/web/tests/e2e/texture-4k-performance.spec.ts new file mode 100644 index 00000000..f10f2e12 --- /dev/null +++ b/web/tests/e2e/texture-4k-performance.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; + +test("decodes, uploads and renders a validated 4K texture in Chromium", async ({ page }) => { + test.setTimeout(45_000); + await page.goto("/"); + const result = await page.evaluate(async () => { + const { createGPUTextureAsset } = await import("/src/render/RenderAssets.ts"); + const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts"); + const { Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, WebGLRenderer } = await import("/src/vendor/three/three.module.js"); + const source = document.createElement("canvas"); + source.width = 4096; source.height = 4096; + const context = source.getContext("2d", { alpha: false }); + if (!context) throw new Error("2D texture fixture context is unavailable"); + context.fillStyle = "#dd3322"; context.fillRect(0, 0, 2048, 4096); + context.fillStyle = "#22bb66"; context.fillRect(2048, 0, 2048, 4096); + const blob = await new Promise((resolve, reject) => source.toBlob((value) => value ? resolve(value) : reject(new Error("4K PNG encoding failed")), "image/png")); + const data = await blob.arrayBuffer(); + const asset = await createGPUTextureAsset({ assetId: "asset:4k", imageId: "image:4k", mimeType: "image/png", width: 4096, height: 4096, usage: "BASE_COLOR", colorSpace: "SRGB" }, data); + const started = performance.now(); + const store = new GPUTextureStore(); + const status = await store.upload([asset]); + const canvas = document.createElement("canvas"); canvas.width = 64; canvas.height = 64; document.body.append(canvas); + const renderer = new WebGLRenderer({ canvas, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false); + const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1; + const material = new MeshBasicMaterial({ map: store.get("image:4k", "BASE_COLOR") }); + scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera); + const gl = renderer.getContext(); const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60) colored += 1; + const elapsedMs = Math.round(performance.now() - started); + material.dispose(); renderer.dispose(); store.dispose(); canvas.remove(); + return { status, byteLength: data.byteLength, colored, elapsedMs }; + }); + expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength }); + expect(result.byteLength).toBeGreaterThan(0); + expect(result.colored).toBeGreaterThan(3_000); + expect(result.elapsedMs).toBeLessThan(30_000); +}); diff --git a/web/tests/e2e/texture-8k-performance.spec.ts b/web/tests/e2e/texture-8k-performance.spec.ts new file mode 100644 index 00000000..1b7533a8 --- /dev/null +++ b/web/tests/e2e/texture-8k-performance.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +test("decodes, uploads and renders a validated 8K texture in Chromium", async ({ page }) => { + test.setTimeout(60_000); + await page.goto("/"); + const result = await page.evaluate(async () => { + const { createGPUTextureAsset } = await import("/src/render/RenderAssets.ts"); + const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts"); + const { Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, WebGLRenderer } = await import("/src/vendor/three/three.module.js"); + const target = document.createElement("canvas"); target.width = 64; target.height = 64; document.body.append(target); + const renderer = new WebGLRenderer({ canvas: target, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false); + const gl = renderer.getContext(); const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number; + if (maxTextureSize < 8192) { renderer.dispose(); target.remove(); return { supported: false, maxTextureSize, byteLength: 0, colored: 0, elapsedMs: 0, status: { loaded: 0, rejected: 0, bytes: 0 } }; } + const source = document.createElement("canvas"); source.width = 8192; source.height = 8192; + const context = source.getContext("2d", { alpha: false }); + if (!context) throw new Error("2D texture fixture context is unavailable"); + context.fillStyle = "#2266dd"; context.fillRect(0, 0, 4096, 8192); + context.fillStyle = "#ddcc22"; context.fillRect(4096, 0, 4096, 8192); + const blob = await new Promise((resolve, reject) => source.toBlob((value) => value ? resolve(value) : reject(new Error("8K PNG encoding failed")), "image/png")); + const data = await blob.arrayBuffer(); + const asset = await createGPUTextureAsset({ assetId: "asset:8k", imageId: "image:8k", mimeType: "image/png", width: 8192, height: 8192, usage: "BASE_COLOR", colorSpace: "SRGB" }, data); + const started = performance.now(); const store = new GPUTextureStore(); const status = await store.upload([asset]); + const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1; + const material = new MeshBasicMaterial({ map: store.get("image:8k", "BASE_COLOR") }); scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera); + const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60 || pixels[index + 2] > 60) colored += 1; + const elapsedMs = Math.round(performance.now() - started); + material.dispose(); renderer.dispose(); store.dispose(); target.remove(); + return { supported: true, maxTextureSize, status, byteLength: data.byteLength, colored, elapsedMs }; + }); + expect(result.supported, `WebGL MAX_TEXTURE_SIZE=${result.maxTextureSize}`).toBe(true); + expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength }); + expect(result.byteLength).toBeGreaterThan(0); + expect(result.colored).toBeGreaterThan(3_000); + expect(result.elapsedMs).toBeLessThan(45_000); +});