Compare commits

...

2 Commits

Author SHA1 Message Date
mes123456
68d50f810f Advance WebGPU volume and bounded workflows 2026-08-14 18:08:29 -04:00
mes123456
3da1dfc804 Advance bounded editor and cache workflows 2026-08-12 19:31:41 -04:00
123 changed files with 9534 additions and 423 deletions

View File

@@ -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<WebMetaballElementEdit> elements;
const json source_elements = command.value("elements", json::array());

View File

@@ -735,6 +735,28 @@ std::vector<float> node_socket_default_values(const ParsedBlend &blend, const El
return read_float_array(*blend.sdna, *default_value, "value", 4);
}
std::optional<ElementRef> 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<ElementRef> node_socket_default_element(const ParsedBlend &blend,
const ElementRef &node,
const std::string &identifier)
{
const std::optional<ElementRef> socket = node_socket_by_identifier(blend, node, identifier);
if (!socket) return std::nullopt;
const std::optional<uint64_t> pointer = read_pointer(*blend.sdna, *socket, "default_value");
return pointer && *pointer != 0 ? element_for_pointer(blend, *pointer) : std::nullopt;
}
std::optional<ElementRef> raw_array_element(const ParsedBlend &blend,
uint64_t pointer,
const std::string &type_name,
@@ -813,6 +835,36 @@ std::optional<json> compositor_graph_from_scene(const ParsedBlend &blend,
node_ir["properties"] = {{"color", {values[0], values[1], values[2], values[3]}}};
}
}
else if (blender_type == "CompositorNodeExposure") {
const std::optional<ElementRef> exposure = node_socket_default_element(
blend, node, "Exposure");
const std::optional<float> 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<ElementRef> factor = node_socket_default_element(blend, node, "Fac");
const std::optional<ElementRef> invert_color = node_socket_default_element(
blend, node, "Invert Color");
const std::optional<ElementRef> invert_alpha = node_socket_default_element(
blend, node, "Invert Alpha");
const std::optional<float> factor_value = factor ? read_float(*blend.sdna, *factor, "value") :
std::nullopt;
const std::optional<int64_t> color_value = invert_color ?
read_integer(*blend.sdna, *invert_color, "value") : std::nullopt;
const std::optional<int64_t> 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<json> 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<ElementRef> 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<ElementRef> 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<ElementRef> 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<uint64_t> packed_file = read_pointer(*blend.sdna, element, "packedfile");
const std::vector<uint8_t> packed_bytes = packed_file ?
packed_file_bytes(blend, *packed_file) :
@@ -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<ElementRef> 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);

View File

@@ -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<WebMetaballElementEdit> &elements,

View File

@@ -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<float, 3> 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<std::string, 4> &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<WebMetaballElementEdit> &elements,

View File

@@ -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 bakeWASM 求值逐 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 bakeWASM 求值逐 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 proxycodec 能力探测;最终编码可服务端 | 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 proxycodec 能力探测;最终编码可服务端 | 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 mappingVolume 保持项目路径与 VDB grid/decoder 安全门。
材质槽和 source face mappingVolume 保留 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-ALayer/Frame/Drawing/Stroke/Point/Attribute schema 与最大 layer/frame/point 预算。
2. N-016-BMain create/remove/reorder layerinsert/remove framedraw/erase stroke transaction。
3. N-016-Cmaterial、onion skin、cyclic stroke、radius/opacity/color 和基础 modifier reader。
4. N-016-DChromium 主线程/Offscreen 的 3D current-frame stroke preview 已完成2D editor、
stroke/point selection、timeline/dope integration、gizmo 和 worker restart 继续推进。
4. N-016-DChromium 主线程/Offscreen 的 3D current-frame stroke preview、点 raycast、多选
高亮、连续 drag preview、单次 gizmo Main commit、真实 drawing frame 的有界 Dope 导航和
Worker 保存重开已完成;完整 2D canvas/marquee 与完整 timeline/dope 编辑继续推进。
5. N-016-Edesktop drawing hash、像素 golden、save/reopen 和 GLB/USD loss report。
## N-017 Paint 与权重
1. N-017-AVertex Color/Weight/Texture Paint stroke schema、PBVH/UV hit 和 brush 预算。
1. N-017-AVertex Color/Weight/Texture Paint stroke schema、UV hit、uniform-grid 候选查询、
显式深度可见性门和 brush 预算;真实 PBVH/深度采样仍阻断。
2. N-017-Bvertex color 与 vertex group Main transaction、normalize/limit/mirror 权重。
3. N-017-Cpacked/UDIM image tile transaction、色彩空间、dirty tile 和原子保存。
4. N-017-Darmature deform golden、seam bleed、mask/face selection 和 undo/redo。
@@ -123,21 +131,25 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
1. N-018-ARigid 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 bakeframe seek 不允许复用错误帧。
3. N-018-Cframe 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-ACamera、Light、World、View Transform、Exposure、Mist、Shadow 的完整 SceneIR。
2. N-019-BThree/WebGPU 映射和 capability report材质/灯光变化使用增量更新。
2. N-019-BThree/WebGPU 映射和 capability report共享 PBR 路径已应用有界 Kelvin 线性灯光
颜色,材质/灯光变化使用增量更新。
3. N-019-CEevee 有限特性矩阵、shadow map、transparent sorting、probe 和后处理。
4. N-019-DCycles/Freestyle/硬件 denoise 使用服务端 Blender job 协议与结果 hash。
5. N-019-Edesktop/Web 像素误差、色彩管理、设备丢失和 1M 三角形性能门。
## N-020 Compositor
1. N-020-ACompositor GraphIR、socket/link、image/render-layer 资源和 cycle 校验
1. N-020-ACompositor GraphIR、socket/link、image/render-layer 资源和 cycle 校验;真实 Main
Exposure 与默认 Invert 参数链已进入 CPU executor其他参数节点仍按 Unsupported 保留。
2. N-020-BTransform/Color/Alpha/Blur/Mix 等有限 node 的 CPU/WebGPU executor。
3. N-020-Cviewer/composite output、frame cache、tile budget 和 worker cancellation。
4. N-020-Dunsupported node 保留 metadata 并可提交服务端 Blender不删图继续执行。
@@ -146,7 +158,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
## N-021 Sequencer 与音频
1. N-021-AScene/Movie/Image/Sound/Effect/Meta strip schema、channel、range 和 proxy metadata。
2. N-021-BMain add/remove/move/trim/split、transition 和 modifier 白名单
2. N-021-BMain add/remove/move/trim/split、transition 和 modifier 白名单CROSS/GAMMA_CROSS
已有真实依赖顺序、进度和静帧 source-frame 解析,尚未接媒体解码。
3. N-021-CWebCodecs/HTMLMedia capability probe、waveform/proxy 和精确 timeline seek。
4. N-021-D浏览器不支持的 codec/混音/编码提交服务端;资源路径必须沙箱化。
5. N-021-EA/V sync、frame hash、save/reopen、丢帧和损坏媒体测试。
@@ -156,7 +169,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
1. N-022-AMovieClip、Track/Marker、Plane Track、Mask Layer/Spline/Point schema。
2. N-022-BMain marker/mask edit、selection、keyframe 和 camera solve metadata。
3. N-022-C浏览器 tracking feature probe完整 solve 可使用服务端 Blender。
4. N-022-DClip/Mask editors、overlay 和 compositor/scene resource binding
4. N-022-DClip/Mask editors、overlay 和 compositor/scene resource binding;真实 Main Mask 的
锁定过滤、稳定点 raycast 与 replace/add/toggle marquee 已验证。
5. N-022-Edesktop solve/error golden、媒体故障、保存和重开。
## N-023 Asset、Library 与 IO
@@ -165,7 +179,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
2. N-023-BAppend/Link/Library Override Main transaction、reload/relocate 和循环依赖门。
3. N-023-CglTF/OBJ/PLY/STL 首批本地 IOUSD/Alembic 按编译能力或服务端执行。
4. N-023-D每格式 import -> save -> reopen -> export -> desktop reimport 语义比较。
5. N-023-Ezip bomb、路径穿越、外部 URI、许可证和大文件流式预算
5. N-023-Ezip bomb、路径穿越、外部 URI、许可证和大文件流式预算archive 重复/前缀冲突、
累计压缩预算和确定性 range plan 已落地,真实 ZIP 解码仍阻断。
## N-024 Editors 与工作流
@@ -175,6 +190,9 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
4. N-024-DBlender-compatible keymap、可配置 shortcut、gizmo、drag preview/commit。
5. N-024-Edesktop/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-DCUDA/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`,并至少执行:

View File

@@ -133,9 +133,9 @@ transmission material 数、shadow caster 数、WASM/GPU 内存压力和帧时
- PBR-007-Adone_current_scope`render-assets.ts` 定义 schema v1、SHA-256、MIME/尺寸/字节预算和稳定错误码App 从 `requestAsset` 生成载荷并同时写入 OPFS。
- PBR-007-Bdone_current_scopeBase Color/Emission 使用 sRGBNormal/Data 使用 non-color/linear主线程和 Worker 使用相同 `GPUTextureStore`
- PBR-007-Cin_progressImageBitmap 解码、旧 Texture dispose、状态回传已完成mipmap、anisotropy、GPU context-loss 恢复和全局显存预算仍未开放。
- PBR-007-Cin_progressImageBitmap 解码、旧 Texture dispose、状态回传和主线程 WebGL context-loss 恢复已完成mipmap、anisotropy、Offscreen context-loss 恢复和全局显存预算仍未开放。
- PBR-007-DNormal Map 切线空间、UV Map 选择、wrap/filter 和缺失纹理 fallback。
- PBR-007-Ein_progress双后端同一载荷正例已覆盖,context-loss/Worker crash 后重新上传仍属于 PBR-011 故障注入门。
- PBR-007-Ein_progress双后端同一载荷正例和主线程真实 `WEBGL_lose_context` 恢复已覆盖Offscreen context-loss/Worker crash 后重新上传仍属于 PBR-011 故障注入门。
### PBR-008 UDIM 与外部资源in_progressmanifest/阻断已完成,多 tile 采样未声明)
@@ -169,7 +169,10 @@ transmission material 数、shadow caster 数、WASM/GPU 内存压力和帧时
- PBR-012-Adone_current_scope`queryRenderCapability` 明确返回 `WEBGPU_RENDERER_UNAVAILABLE`,没有 WebGPU renderer bundle 时不伪装为 WebGPU。
- PBR-012-Bdone_current_scope安全门BLOOM/SSAO/SSR/TAA/DOF/MOTION_BLUR 未有真实 pass 时返回 `POSTPROCESS_PASS_UNAVAILABLE`,未声明假实现。
- PBR-012-Cdone_current_scope安全门Volume/SSS 返回独立 `VOLUME_SHADER_UNAVAILABLE`/`SUBSURFACE_SHADER_UNAVAILABLE`,任意 Shader node 返回 `SHADER_NODE_UNSUPPORTED`
- PBR-012-Cin_progressVolume 已重新纳入 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. 当前验收

View File

@@ -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/UVnative WBG1、资源 Delta 和 mesh byte-range patch | 已完成当前数据契约 |
| Sculpt 属性 | Mask/Face Set 的 SceneIR、WBG1 header v2、Delta、Main、undo 和保存重开 | 已完成属性子集;有界四笔刷 Main stroke 已开放PBVH/完整求值未开放 |
| Simulation cache | Blender 5.2/source blend/hash/frame manifest、OPFS 内容寻址、schema 6、重启逐帧复验 | 已完成缓存安全层Simulation evaluator 未开放 |
| 发布性能/故障门 | 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_progressVDB 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 整体 BLOCKEDVDB 资源 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/USDChromium 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/USDChromium 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 消费同一 chunksGLB 只接受真实 evaluated meshUSD 提供
`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"
```

View File

@@ -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 presetOpenVDB 只在该目标开启;固定 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 APIcontent 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 3WebGPU 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 deltadevice 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 goldenN-015 与 N-019 仍为
`BLOCKED`
- Chromium 是当前唯一浏览器门;不配置 Firefox/WebKit也不把未测浏览器写成兼容。

View File

@@ -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 proxydepsgraph implicit mesh | `READY` |
| PointCloud | position/radius/POINT 属性 | WNM 分块 Points | `READY`(空数据为 `summary-only` |
| Curves/Hair | position/radius/curve/POINT 属性 | WNM 分块 Points/curve line | `READY`(缺数据阻断) |
| Volume | 项目内 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 metadataraw `.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``<builtin>` 字体
@@ -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 场景中做绝对坐标 previewpointer 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
widthstyped WNM 属性映射 primvarsGLB 属性损失结构化报告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 identityUI 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 hashchunk 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-D2evaluated preview 与源控制笼分层显示、WebGPU 等价。
6. N-015-E1补齐 Volume 的完整 loss fixturePointCloud/Curves/Hair、Curve line bake、真实
4x4 NURBS Surface mesh、desktop geometry golden、7 对象 GLB 与 USDA desktop round-trip 已通过。
7. N-015-E2100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quotaChromium
3. N-015-D1跨对象 selection history、多目标 element identity、range patch、左右 handle
raycast多 handle preview、handle-local gizmo 与单次 Main 写回已完成。
4. N-015-D2evaluated preview 与源控制笼分层显示、WebGPU 等价。
5. N-015-B3/D2/E1Volume真实资源、desktop/server converter、可续传 HTTP range、OPFS、
有界网络/Worker/quota/tamper/device-loss 故障门、Float32 WGSL core 和 Main Volume 属性重开已完成;
继续执行生产 GPU paging、Principled Volume grid 语义、GLB/USD loss、大 bundle/OOM 和
desktop/Chromium golden未完成项保持阻断。
6. N-015-E2100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quotaChromium
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 不得进入 GLBGLB 只能消费 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"
```

View File

@@ -1,7 +1,7 @@
# N-016 Grease Pencil
状态:`BLOCKED`(协议、有限 reader/Main transaction、当前帧及相邻帧 onion preview 已落地;
完整 2D/3D 编辑、modifier 语义和桌面 golden 仍阻断)
完整 2D canvas/Dope 编辑、modifier 语义和桌面 golden 仍阻断)
## 已验证切片
@@ -24,12 +24,28 @@
point selection 和 revision拒绝 stale/重复/超 1M selectionProperties 面板已连接真实 Main
layer create/remove、当前 frame insert/remove 和整帧 clear transaction。完整 2D stroke/point
画布选择和 dope sheet integration 仍阻断。
7. N-016-D点编辑部分Properties 面板可在当前 layer/frame 选择实际 stroke/point执行
有界 X 轴平移;协议先校验 point identity、revision、有限坐标和 1M 点预算,再保留 radius、
opacity、vertex color、cyclic、material index 并合并为一个 `setGreasePencilStrokes` Main
transaction。它是点级整帧原子写回不等同于完整 2D 画布选择或连续 gizmo。
8. N-016-D视口点编辑部分当前 drawing 为每条 stroke 建立独立 Three `Points` 代理,
主线程和 OffscreenCanvas 使用 data/layer/frame/stroke/point 五级身份 raycastEdit Mode
可高亮、Shift/Ctrl 多选同一 drawing 的点,并由轴向 gizmo 合并为一个 revision-bound
`setGreasePencilStrokes` Main transaction。跨 drawing 追加会重置选择,避免错误混写。
9. N-016-D连续预览部分轴向 gizmo 的 pointer move 从当前 SceneIR 绝对计算选中点坐标,
同时更新真实 stroke 线和隐藏 point raycast proxypointer 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-Cmaterial datablock 事务、onion fade/range 完整语义和完整 Grease Pencil modifier reader。
- N-016-D2D editor、stroke/point selection、timeline/dope integration、gizmo 和 worker restart
3D current-frame stroke viewport 已完成。
- N-016-D完整 2D canvas/marquee 和完整 timeline/dope 编辑3D current-frame 点 raycast、
多点高亮、连续 preview、单次 gizmo commit、有界 drawing frame 导航和 worker restart 已完成。
- N-016-Edesktop drawing hash、Chromium 像素 golden、GLB/USD loss report 和 OPFS。
## 验收

View File

@@ -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 生命周期未实现)
## 已验证切片
@@ -21,14 +21,29 @@
7. Properties paint 面板消费 viewport 的真实 VERT selection可向选中顶点提交 `POINT`
`WebPaintColor` 和 vertex group weight每次操作只发一个既有 Main transaction继续使用
revision、undo/redo、save/reopen 边界。它不是 PBVH brush 或 texture paint。
8. N-017-A/C协议边界有界 CPU brush 对候选顶点执行 smoothstep falloff、遮挡标记过滤和
front-face 法线门;它不宣称 PBVH 加速或桌面 brush 等价。`UdimTilePatchIR` 约束 1001-1999
tile、RGBA8、色彩空间、尺寸/256 MiB 预算、revision、base/result SHA-256并在内存中验证
原子 range patch尚未绑定 Blender packed image/UDIM Main 写回与保存。
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-APBVH 加速结构、遮挡/背面选择和 brush falloff 桌面语义的对应;基础 Mesh/UV
raycast hit 已完成。
- N-017-A真实 PBVH、由 viewport 深度缓冲生成可见 vertex 集合,以及 brush falloff 桌面
golden基础 Mesh/UV hit、uniform-grid 候选查询和可见性硬门已完成。
- N-017-Blimit/clean、已验证拓扑映射上的 mirror、桌面 brush/falloff 对照。
- N-017-Cpacked/UDIM tile transaction、色彩空间、dirty tile 和原子保存
- N-017-D/Earmature golden、seam bleed、mask/selection、GPU dispose、quota、坏图和 UI
- N-017-CBlender packed/UDIM tile Main transaction、dirty tile、色彩转换和原子保存;当前
只有内容哈希绑定的浏览器内存 patch 边界
- N-017-D/Earmature golden、seam bleed、face mask、GPU dispose、quota、坏图和桌面 UI 对照;
vertex selection 与数值 mask 门已完成,不等同于完整 Paint 面/纹理遮罩系统。
## 验收

View File

@@ -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 未实现)
## 已验证切片
@@ -20,11 +20,26 @@ cache playback、WASM solver 和 bake job 未实现)
Particle/Hair 数据;只输出实际存在的 system绑定 owner stable ID、有界 settings、collision
dependency 和浏览器可复算 SHA-256。当前 fixture 实证覆盖禁用 Cloth、Soft Body 和 Collision
依赖save/reopen 保持一致;未用合成 system 填充其余 family。
7. N-018-C浏览器格式边界版本化 `BTF1` 二进制帧可解码有界 object transform校验
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-Cdesktop bake payload 的 family decoder、depsgraph frame playback 和 100 帧 golden。
- N-018-Cdesktop bake payload 的 family decoder、正式 viewport UI 播放控制和 100 帧
golden浏览器自有 transform frame decoder/SceneIR 播放会话已完成但不替代 Blender bake。
- N-018-D逐 family WASM solver 初始化、线程、内存和确定性验证。
- N-018-Ebake start/cancel/commit、服务端 job、故障恢复、进度与 UI。
@@ -36,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`

View File

@@ -13,20 +13,29 @@ Three exposure/shadow 映射已落地Scene 颜色管理 writer 和渲染等
3. N-019-B部分主线程和 OffscreenCanvas renderer 都优先使用 Scene color-management
exposureWorld 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/rationear/far 与范围在修改前
拒绝,已通过 undo/redo 和 save/reopen。
7. N-019-B色温部分`useTemperature:true` 时,共享 PBR 适配器把 80020000 K 的有界
黑体近似归一到 6500 K 中性白并转换为线性 RGB再乘入 Light color关闭色温时原始颜色
保持不变。主线程与 Offscreen Worker 共用该实现,数值门和原生 temperature 保存重开通过。
## 仍然阻断
- N-019-AScene color-management writer直接 DNA 写入会破坏 white balance必须改接
Blender/RNA 颜色管理 API 后才能开放。Camera writer 已完成。
- N-019-B/CAgX/Standard/Raw 的视觉等价、temperature 颜色、Area spread、Mist、DOF、
- N-019-B/CAgX/Standard/Raw 的视觉等价、Area spread、Mist、DOF、
transparent sorting、probe 和高级 shadow 参数。
- N-019-DCycles/Freestyle/denoise 服务端 job 协议与结果 hash。
- N-019-Edesktop/Chromium 像素 golden、设备丢失和 1M triangles。
- N-019-B/C/EVolumeFloat32 NanoVDB WGSL 树遍历和有界 density ray integration 已有真实
Chromium 数值/图像专项门GPU page allocator、生产视口深度合成、temperature/color/emission
grid 语义和 desktop/Chromium 三视角 golden 仍阻断,不能据此声明发布级体渲染。
## 验收

View File

@@ -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→CompositeRGBA 结果通过;未连接的 Glare 仍保留
Unsupported 并使完整图 capability gate 保持阻断。
## 仍然阻断
- N-020-AMain graph 写回,以及 Transform/Invert/Exposure/Alpha Over/Blur/Mix/Image/Render Layer
的逐节点参数与 resource/pass reader基础真实图结构、常量色、Viewer/Composite 已完成。
- N-020-B/CWebGPU executor、tile/frame cache、增量 invalidation 和 GPU dispose
- N-020-AMain graph 写回,以及 Transform、非默认 InvertAlpha OverBlurMixImage/Render
Layer 的逐节点参数与 resource/pass reader基础真实图结构、常量色、Exposure、默认 Invert、
Viewer/Composite 已完成
- N-020-B/CWebGPU executor、tile scheduler、OPFS 持久 frame cache、增量 invalidation 和 GPU
disposeCPU 周期取消和内容寻址内存 LRU 已完成。
- N-020-D服务端 Blender job、source hash 和结果提交。
- N-020-Edesktop HDR/alpha/color-space golden、OOM/fault/device-loss。

View File

@@ -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 需要精确 probecodec 必须
4. N-021-B/C部分`resolveSequencerFrame` 在切分边界解析当前顶层 strip、source frame
与依赖META 覆盖的子 strip 和 inactive dependency 不会泄漏到渲染帧。
5. N-021-C/D运行时只报告 WebCodecs/HTMLMedia 需要精确 probecodec 必须
出现在已验证 MIME 集合中才可放行,本地编码固定为 `BLOCKED`
5. N-021-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-Btransition 子集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/BMain strip 写回、transition/modifier 完整参数、undo/redo 和 save/reopen
基础真实 reader 已完成。
- N-021-A/BMain strip 写回、非 CROSS transition/modifier 完整参数、undo/redo 和 save/reopen
基础真实 reader 与 CROSS/GAMMA_CROSS 帧描述已完成。
- N-021-CWebCodecs 精确 seek/decode、音频 waveform、proxy 生成、A/V sync、丢帧和
损坏媒体处理。
- N-021-D浏览器不支持的 codec、混音与最终编码的服务端 Blender job。

View File

@@ -13,17 +13,26 @@
修改使用 revision 事务,锁定项和 stale revision 会拒绝,提交后再次完整解析。
4. N-022-Cmarker/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 与
selection1024 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/reopenMask reader
已完成。
- N-022-C浏览器 tracking 实现、完整 camera/plane solve 与服务端 job/hash 提交。
- N-022-DClip/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-Edesktop solve/error golden、媒体故障和 Chromium 测试。
## 验收

View File

@@ -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 dependency1024 library/每库 1024 dependency、
重复 ID、缺依赖、依赖环与项目外路径由 SceneIR 再校验。缺外部库字节时返回
`LINKED_LIBRARY_RESOURCE_REQUIRED`,不从路径伪造 SHA-256 或声称已加载。
7. N-023-Earchive 结构门):除 traversal 与展开比预算外,重复规范路径、文件/目录前缀冲突、
累计压缩/解压字节和声明源长度不一致均拒绝;`planIOArchiveRanges` 按路径生成确定性、安全整数
compressed offset 计划。它为后续流式解包提供边界,不代表已有 ZIP decoder。
8. N-023-ENanoVDB 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/license14 条记录逐项绑定
byte length 与 SHA-256官方 sphere 使用 CC-BY-4.0 和 Git LFS 权威 hash未放入仓库工作树。
## 仍然阻断
- N-023-BAppend/Link/Library Override、reload/relocate 和真实 Main transaction只读 library
inventory 已完成。
- N-023-C/DGLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。
- N-023-Ezip fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件流式性能。
- N-023-E真实 zip decoder/fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件
流式性能archive 路径冲突、双向字节预算与确定性 range plan 已完成。
- N-023-EVDBGPU page resident LRU 和 64 MiB1 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
```

View File

@@ -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 staterect 边界与不可重叠布局经过校验。
3. N-024-C部分selection sync 以 revision 事务更新 active object 与 selected IDs
active object 必须属于 selection过期 revision 会拒绝。
4. N-024-Dkeymap 绑定和预算可验证writer、gizmo 与 touch drag 保持能力阻断。
5. N-024-BMain reader从 Blender `WorkSpace``WorkSpaceLayout``bScreen``ScrArea`
4. N-024-D部分key chord 统一规范化 modifier、key 大小写和排序,启用绑定的冲突会在
resolver 层拒绝禁用绑定不参与冲突writer、gizmo 与 touch drag 保持能力阻断。
5. N-024-Dkeymap 绑定和预算可验证writer、gizmo 与 touch drag 保持能力阻断。
6. N-024-BMain 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-Coperator searchF3 搜索结果来自当前 workspace/mode/Main 状态;命令可执行
workspace/mode 切换、Add Cube、Apply Transform、Undo/Redo/Save。Chromium 用真实 `.blend`
验证了 Edit Mode 隐藏 Object-only Add CubeObject Mode 执行后 Main revision 只增加一次。
## 仍然阻断
- N-024-B/CProperties、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/EBlender-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"
```

View File

@@ -1,7 +1,7 @@
# N-025 Scripting 与平台
状态:`BLOCKED`(默认拒绝策略、真实 Main Text 来源清单、签名 manifest、权限/资源预算、
平台报告服务端 hash 门已落地;本地隔离执行、真实 server job 与发布审计未实现)
平台报告服务端 hash 门和请求决策审计已落地;本地隔离执行、真实 server job 与发布审计未实现)
## 已验证切片
@@ -16,17 +16,27 @@
5. N-025-AMain reader从 Blender `Text`/`TextLine` 重建最多 1 MiB、65,536 行的完整
UTF-8 内嵌来源,记录项目相对外部路径、`use_module` autorun 请求和 SHA-256所有来源均为
只读且 `executionStatus=BLOCKED`autorun 请求按默认拒绝处理,不引入 Python 执行入口。
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 适配、恶意脚本/依赖混淆/逃逸/重放、
审计日志和发布门。
审计日志持久化和发布门;本地请求级审计凭证已完成,不代表隔离执行完成
## 验收
```bash
npm --prefix web run test:script-main-reader
npm --prefix web run test:scripting-isolation
WEB_TEST_PORT=5325 npm --prefix web run test:e2e -- --grep "N-025 script"
```

View File

@@ -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/CChromium 主线程/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/SceneIR25 秒动画量级的写入加播放总耗时须小于
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-256manifest `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 全部传递依赖的发布级法律审计。

View File

@@ -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"],
"blockedSlices": ["C-material-modifier-full-semantics", "D-full-2d-stroke-point-editor-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", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", "B-selected-vertex-color-weight-ui-transaction-partial"],
"blockedSlices": ["A-pbvh-occlusion-falloff", "B-clean-mirror-desktop-brush", "C-packed-udim-color-dirty-atomic", "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"],
"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,9 +110,9 @@
"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", "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:e2e:N-025 script"],
"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"]
}

View File

@@ -1,13 +1,13 @@
{
"schemaVersion": 3,
"source": "docs/status/parity-ledger.json",
"sourceSha256": "086c3882eeabfa9f5bb746aca8da6294bff3415299a91106cc5cb40167907156",
"generatedAt": "2026-08-12T21:11:21.310Z",
"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"
],
@@ -86,11 +111,16 @@
"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-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-stroke-point-editor-dope-gizmo-restart",
"D-full-2d-canvas-marquee-dope-editor",
"E-desktop-browser-golden-export-opfs"
],
"acceptance": [
@@ -110,15 +140,21 @@
"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-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-pbvh-occlusion-falloff",
"A-real-pbvh-depth-occlusion-desktop-falloff",
"B-clean-mirror-desktop-brush",
"C-packed-udim-color-dirty-atomic",
"D-E-mask-selection-gpu-quota-golden-ui"
"C-blender-packed-udim-dirty-atomic-save",
"D-E-face-mask-gpu-quota-desktop-golden"
],
"acceptance": [
"web:test:paint-roundtrip",
@@ -139,7 +175,11 @@
"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-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",
@@ -165,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",
@@ -191,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": [
@@ -220,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": [
@@ -247,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",
@@ -272,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": [
@@ -302,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",
@@ -328,8 +395,11 @@
"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"
"D-platform-capability-report",
"E-bounded-replay-resistant-audit-hash-chain"
],
"blockedSlices": [
"A",
@@ -340,6 +410,7 @@
],
"acceptance": [
"web:test:script-main-reader",
"web:test:scripting-isolation",
"web:e2e:N-025 script"
],
"dependencies": [
@@ -355,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",
@@ -370,7 +449,8 @@
"B",
"C",
"D",
"E"
"E",
"C-vdb-server-webgpu-save-reopen-golden-evidence"
],
"acceptance": [
"web:e2e:N-026 release",
@@ -408,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
},
@@ -435,7 +515,7 @@
],
"command": "npm --prefix web run release:sbom",
"exitCode": 0,
"durationMs": 352,
"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",
@@ -443,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": [
@@ -453,10 +548,10 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser",
"exitCode": 0,
"durationMs": 137892,
"output": "D profiles at the protocol boundary (1.0s)\n ✓ 71 tests/e2e/smoke.spec.ts:1826:1 normalizes skin influences and rejects shape-key loss explicitly (1.0s)\n ✓ 72 tests/e2e/smoke.spec.ts:1851:1 remaps skin weights and shape keys through the native Collapse worker path (1.3s)\n ✓ 73 tests/e2e/smoke.spec.ts:1907:1 reports GLB export blockers before any binary export (1.0s)\n ✓ 74 tests/e2e/smoke.spec.ts:1919:1 blocks Shader graphs that cannot be mapped to glTF PBR (978ms)\n ✓ 75 tests/e2e/smoke.spec.ts:1946:1 maps bounded RGB and Value Shader constants to glTF PBR factors (981ms)\n ✓ 76 tests/e2e/smoke.spec.ts:1979:1 exports a local SceneIR mesh as a standards-shaped GLB (992ms)\n ✓ 77 tests/e2e/smoke.spec.ts:2012:1 keeps per-vertex UV and color attributes in GLB output (978ms)\n ✓ 78 tests/e2e/smoke.spec.ts:2039:1 evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2054:1 embeds local textures and exports glTF skin and animation records (999ms)\n ✓ 80 tests/e2e/smoke.spec.ts:2104:1 reports lightweight budget violations without altering usage (1.0s)\n ✓ 81 tests/e2e/smoke.spec.ts:2122:1 aggregates project, collection, object and LOD budgets without double counting LOD (979ms)\n ✓ 82 tests/e2e/smoke.spec.ts:2148:1 round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 83 tests/e2e/smoke.spec.ts:2172:1 blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 84 tests/e2e/smoke.spec.ts:2215:1 extracts Blender packed image bytes through the local asset request API (1.3s)\n ✓ 85 tests/e2e/smoke.spec.ts:2260:1 matches Blender Depsgraph deformation golden within the declared error budget (1.3s)\n ✓ 86 tests/e2e/smoke.spec.ts:2299:1 evaluates the full Blender Depsgraph or reports its safe capability gate (1.4s)\n ✓ 87 tests/e2e/smoke.spec.ts:2359:1 exports layered Action keyframes through SceneIR (1.3s)\n ✓ 88 tests/e2e/smoke.spec.ts:2400:1 patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 89 tests/e2e/smoke.spec.ts:2424:1 renders through the capability-gated OffscreenCanvas worker (1.3s)\n ✓ 90 tests/e2e/smoke.spec.ts:2437:1 coalesces linked mesh objects into a raycastable instance group (1.9s)\n ✓ 91 tests/e2e/smoke.spec.ts:2445:1 returns structured gates for the undeclared capability protocols (1.2s)\n ✓ 92 tests/e2e/smoke.spec.ts:2504:1 exposes PBR-007 to PBR-012 renderer security gates (1.0s)\n ✓ 93 tests/e2e/smoke.spec.ts:2538:1 transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 94 tests/e2e/smoke.spec.ts:2547:1 uses the same packed texture payload in the OffscreenCanvas renderer (1.5s)\n\n 94 passed (2.2m)\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.6s)\n ✓ 2 [chromium] tests/e2e/cross-browser.spec.ts:32:1 keeps content-addressed asset recovery available in Chromium (1.1s)\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.5s)\n\n[WebServer] (node:1291271) 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:1291283) 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:1295654) 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:1295666) 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"
]
},
{
@@ -466,10 +561,25 @@
],
"command": "npm --prefix web run test:release-performance",
"exitCode": 0,
"durationMs": 86656,
"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\":85598,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":519,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.2532260000007227 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.07346400000096764 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.06714100000681356 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 0.033462000006693415 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.5294449999928474 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4542700000019977 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.41858699999284 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.3971560000063619 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.3646670000016456 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"
]
},
{
@@ -479,12 +589,68 @@
],
"command": "npm --prefix web run test:malicious-blends",
"exitCode": 0,
"durationMs": 509,
"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": [
@@ -492,10 +658,10 @@
],
"command": "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library",
"exitCode": 0,
"durationMs": 3993,
"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:672:1 validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.2s)\n\n[WebServer] (node:1296131) 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:1296143) 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"
]
},
{
@@ -503,11 +669,11 @@
"fields": [],
"command": "npm --prefix web run test:release-package",
"exitCode": 0,
"durationMs": 4738,
"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...✓ 44 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-awSIju8U.js 242.93 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-CALwrj_3.js 884.99 kB │ gzip: 238.44 kB\n\n✓ built in 598ms\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=31928384\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"
]
},
{
@@ -518,12 +684,12 @@
],
"command": "npm --prefix web run release:offline",
"exitCode": 0,
"durationMs": 35842,
"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...✓ 44 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-awSIju8U.js 242.93 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-CALwrj_3.js 884.99 kB │ gzip: 238.44 kB\n\n✓ built in 582ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7537432 source=205687508 sha256=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7537432 source=205687508 sha256=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac\noffline-reproducibility-ok binary=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac source=8fd6799861efbee5d8dcc1702adc97ff5612f5da646776f727bc97573db13585\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": [
"b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac",
"8fd6799861efbee5d8dcc1702adc97ff5612f5da646776f727bc97573db13585",
"46c691c2ec192486527ac5b3b94091f9f0e53c1a2185bf18ec3276001167f948"
"078f45513ab579800ab3688f7c5e35d31be0d9dc283db5cee1a68d77553bfb34",
"ca97bef8cbe7943586e7af1df631775ce6ea041677c8bbb98da2fcc73baa9e1a",
"39c7f9be555f2727519a733237de22e66a33d33ac1b0aecfacd2ae695de10b28"
]
}
]

View File

@@ -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"
]
}

View File

@@ -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 |

View File

@@ -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",

Binary file not shown.

27
tools/vdb/CMakeLists.txt Normal file
View File

@@ -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()

17
tools/vdb/README.md Normal file
View File

@@ -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.

View File

@@ -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`);

16
tools/vdb/build-native-tools.sh Executable file
View File

@@ -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"

View File

@@ -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`);

View File

@@ -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"

View File

@@ -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)));
}

View File

@@ -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);
}
});
}

View File

@@ -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`);

View File

@@ -0,0 +1,96 @@
#include <openvdb/openvdb.h>
#include <openvdb/tools/LevelSetSphere.h>
#include <openvdb/tools/LevelSetUtil.h>
#include <filesystem>
#include <iostream>
#include <string>
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<openvdb::FloatGrid>(
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;
}
}

484
tools/vdb/vdb_to_nanovdb.cc Normal file
View File

@@ -0,0 +1,484 @@
#include <openvdb/openvdb.h>
#include <nanovdb/io/IO.h>
#include <nanovdb/tools/CreateNanoGrid.h>
#include <algorithm>
#include <atomic>
#include <array>
#include <chrono>
#include <csignal>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include <unistd.h>
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<std::string> grids;
std::string quantization = "LOSSLESS";
fs::path cancel_file;
uint64_t timeout_ms = 0;
};
static std::atomic<bool> 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<double, 16> index_to_world{};
struct ScalarSample {
openvdb::Coord coord;
float value;
bool active;
};
struct VectorSample {
openvdb::Coord coord;
std::array<float, 3> value;
bool active;
};
std::vector<ScalarSample> scalar_samples;
std::vector<VectorSample> 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<std::string>(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::milliseconds>(
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<double, 16> 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<nanovdb::HostBuffer> convert_grid(
const openvdb::GridBase::Ptr &grid, const std::string &quantization)
{
if (grid->isType<openvdb::FloatGrid>()) {
auto typed = openvdb::GridBase::grid<openvdb::FloatGrid>(grid);
if (quantization == "FP16") {
nanovdb::tools::CreateNanoGrid<openvdb::FloatGrid> converter(*typed);
converter.setStats(nanovdb::tools::StatsMode::All);
converter.setChecksum(nanovdb::CheckMode::Full);
return converter.getHandle<nanovdb::Fp16>();
}
}
else if (!grid->isType<openvdb::Vec3SGrid>()) {
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<GridReport> &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<nanovdb::NanoUpper<float>>;
using FloatUpperData = nanovdb::InternalData<nanovdb::NanoLower<float>, 5>;
using FloatLowerData = nanovdb::InternalData<nanovdb::NanoLeaf<float>, 4>;
using FloatLeafData = nanovdb::LeafData<float, nanovdb::Coord, nanovdb::Mask, 3>;
using Vec3RootData = nanovdb::RootData<nanovdb::NanoUpper<nanovdb::Vec3f>>;
using Vec3UpperData = nanovdb::InternalData<nanovdb::NanoLower<nanovdb::Vec3f>, 5>;
using Vec3LowerData = nanovdb::InternalData<nanovdb::NanoLeaf<nanovdb::Vec3f>, 4>;
using Vec3LeafData = nanovdb::LeafData<nanovdb::Vec3f, nanovdb::Coord, nanovdb::Mask, 3>;
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<uint64_t>(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<std::string> selected(options.grids.begin(), options.grids.end());
std::set<std::string> 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<GridReport> 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<uint64_t>(output.tellp());
nanovdb::io::writeGrid(output, handle, nanovdb::io::Codec::NONE);
const uint64_t segment_end = static_cast<uint64_t>(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<openvdb::FloatGrid>() ? "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<openvdb::FloatGrid>()) {
const nanovdb::NanoGrid<float> *nano_grid = handle.grid<float>();
if (!nano_grid) throw std::runtime_error("NanoVDB Float32 grid payload is unavailable");
const std::array<openvdb::Coord, 5> 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<openvdb::Vec3SGrid>()) {
const nanovdb::NanoGrid<nanovdb::Vec3f> *nano_grid = handle.grid<nanovdb::Vec3f>();
if (!nano_grid) throw std::runtime_error("NanoVDB Vec3f grid payload is unavailable");
const std::array<openvdb::Coord, 5> 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;
}
}

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");

View File

@@ -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);

View File

@@ -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 {

View File

@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
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 temporary = fs.mkdtempSync(path.join(os.tmpdir(), "scripting-isolation-"));
const require = createRequire(import.meta.url);
try {
for (const name of ["asset-path", "capability-gates", "scripting-platform"]) {
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
const result = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
fileName: `${name}.ts`,
});
const output = result.outputText
.replace('require("./asset-path")', 'require("./asset-path.cjs")')
.replace('require("./capability-gates")', 'require("./capability-gates.cjs")');
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
}
const { createScriptExecutionAudit, gateScriptExecution, gateServerScriptJob } = require(path.join(temporary, "scripting-platform.cjs"));
const digest = "a".repeat(64);
const manifest = {
schemaVersion: 1,
scripts: [{
id: "clean",
name: "Clean",
entryPath: "scripts/clean.py",
sourceSha256: digest,
publisher: "local",
signature: "b".repeat(128),
keyId: "approved-key",
permissions: ["READ_MAIN"],
dependencies: [],
cpuMs: 1000,
memoryBytes: 64 * 1024 * 1024,
wallMs: 2000,
network: false,
autorun: false,
driverExpressions: false,
addonInstall: false,
}],
};
const unsigned = gateScriptExecution(manifest, "clean", new Set());
const approved = gateScriptExecution(manifest, "clean", new Set(["approved-key"]));
const server = gateServerScriptJob({ scriptId: "clean", sourceSha256: digest }, manifest, digest);
assert.equal(unsigned.status, "BLOCKED");
assert.equal(unsigned.issues[0]?.code, "SCRIPT_SIGNATURE_INVALID");
assert.equal(approved.status, "BLOCKED");
assert.equal(approved.issues[0]?.code, "SCRIPT_SANDBOX_UNAVAILABLE");
assert.equal(server.status, "BLOCKED");
assert.equal(server.issues[0]?.code, "SERVER_JOB_UNAVAILABLE");
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 });
}

View File

@@ -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");

View File

@@ -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`);

View File

@@ -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`);

View File

@@ -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 });
}

View File

@@ -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`);

View File

@@ -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)

View File

@@ -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)

View File

@@ -13,7 +13,7 @@
"id": "web-engine-bootstrap",
"fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm",
"sha256": "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c",
"sha256": "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
"required": true
}
]

Binary file not shown.

View File

@@ -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,10 +21,33 @@ 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";
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
const code = "code" in error && typeof error.code === "string" ? `${error.code}: ` : "";
return `${code}${error.message}`;
}
return String(error);
}
async function sha256Hex(data: ArrayBuffer): Promise<string> {
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;
@@ -52,34 +76,143 @@ interface MeshEditSelection {
indices: Set<number>;
nonMeshKind?: NonMeshElementKind;
nonMeshSelections?: Map<NonMeshElementKind, Set<number>>;
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<string, WebEngineLODLevelResult[]> | null;
selectedObjectIds: ReadonlySet<string>;
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<HTMLCanvasElement>(null);
const rendererRef = useRef<ViewportBackend | null>(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<string | null>(null);
const [activeTool, setActiveTool] = useState<"translate" | "rotate" | "scale">("translate");
const [curveGizmoScreenFrame, setCurveGizmoScreenFrame] = useState<CurveGizmoScreenFrameIR | null>(null);
const [volumeAssets, setVolumeAssets] = useState<NanoVDBViewportAssetIR[]>([]);
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<number>): ArrayLike<number> | 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;
@@ -111,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<CurveGizmoScreenFrameIR | null>).detail);
canvas.addEventListener("curve-gizmo-frame", update);
return () => canvas.removeEventListener("curve-gizmo-frame", update);
}, []);
return (
<div className="viewport-placeholder" role="img" aria-label="Three.js 视口占位区域">
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
@@ -133,22 +311,38 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
<button type="button" className={`tool-button${activeTool === "rotate" ? " active" : ""}`} aria-label="旋转工具" title="旋转" onClick={() => setActiveTool("rotate")}></button>
<button type="button" className={`tool-button${activeTool === "scale" ? " active" : ""}`} aria-label="缩放工具" title="缩放" onClick={() => setActiveTool("scale")}></button>
</div>
{snapshot?.activeObjectId ? <div className="transform-gizmo" aria-label="变换 Gizmo">{([0, 1, 2] as const).map((axis) => <button key={axis} type="button" className={`gizmo-axis axis-${"xyz"[axis]}`} aria-label={`${"XYZ"[axis]} 轴变换手柄`} onPointerDown={(event) => {
{snapshot?.activeObjectId ? <div className={`transform-gizmo${curveGizmoScreenFrame ? " handle-local" : ""}`} aria-label="变换 Gizmo" data-gizmo-space={curveGizmoScreenFrame ? "HANDLE_LOCAL" : "OBJECT"} style={curveGizmoScreenFrame ? { left: `${curveGizmoScreenFrame.origin[0] * 100}%`, top: `${curveGizmoScreenFrame.origin[1] * 100}%` } : undefined}>{([0, 1, 2] as const).map((axis) => <button key={axis} type="button" className={`gizmo-axis axis-${"xyz"[axis]}`} aria-label={`${"XYZ"[axis]} 轴变换手柄`} data-local-axis={curveGizmoScreenFrame ? curveGizmo?.frame.axes[axis].map((value) => value.toFixed(6)).join(",") : undefined} data-screen-axis={curveGizmoScreenFrame ? curveGizmoScreenFrame.axes[axis].map((value) => value.toFixed(6)).join(",") : undefined} style={curveGizmoScreenFrame ? { left: `${46 + curveGizmoScreenFrame.axes[axis][0] * 32 - 14}px`, top: `${46 + curveGizmoScreenFrame.axes[axis][1] * 32 - 14}px` } : undefined} onPointerDown={(event) => {
const start = { x: event.clientX, y: event.clientY };
const screenAxis = curveGizmoScreenFrame?.axes[axis];
const axisVector = curveGizmo?.frame.axes[axis];
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const target = event.currentTarget;
let previewDataId: string | null = null;
let greasePencilPreview = false;
const pointerDistance = (pointer: PointerEvent): number => screenAxis ? (pointer.clientX - start.x) * screenAxis[0] + (pointer.clientY - start.y) * screenAxis[1] : (pointer.clientX - start.x) - (pointer.clientY - start.y);
const move = (pointer: PointerEvent): void => {
const distance = pointerDistance(pointer);
if (Math.abs(distance) < 2) return;
if (curveGizmo) previewDataId = previewCurveHandles(distance / 100, axis) ?? previewDataId;
else greasePencilPreview = previewGreasePencilPoints(distance / 100, axis) || greasePencilPreview;
};
const finish = (up: PointerEvent): void => {
target.removeEventListener("pointermove", move);
target.removeEventListener("pointerup", finish);
target.removeEventListener("pointercancel", finish);
const distance = (up.clientX - start.x) - (up.clientY - start.y);
if (Math.abs(distance) >= 2) onTransform(activeTool, distance / 100, axis);
if (previewDataId) rendererRef.current?.setCurveHandlePreview(previewDataId, null);
if (greasePencilPreview && greasePencilGizmo) rendererRef.current?.setGreasePencilPointPreview(greasePencilGizmo.data.id, greasePencilGizmo.layer.id, greasePencilGizmo.frame.frame, null);
if (up.type === "pointercancel") return;
const distance = pointerDistance(up);
if (Math.abs(distance) >= 2) onTransform(activeTool, distance / 100, axis, axisVector);
};
target.addEventListener("pointermove", move);
target.addEventListener("pointerup", finish);
target.addEventListener("pointercancel", finish);
}}>{"XYZ"[axis]}</button>)}</div> : null}
<aside className="viewport-sidebar" aria-label="视口侧栏">
<span>{editMode ? `${meshSelection.mode} ${meshSelection.indices.size}` : "Transform"}</span>
<span>{editMode ? `VERT ${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size}` : "Transform"}</span>
<span>View</span>
<span>Item</span>
</aside>
@@ -180,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;
@@ -215,12 +410,17 @@ 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");
const [greasePencilStrokeIndex, setGreasePencilStrokeIndex] = useState(0);
const [greasePencilPointIndex, setGreasePencilPointIndex] = useState(0);
const [greasePencilPointDeltaX, setGreasePencilPointDeltaX] = useState(0.1);
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;
@@ -239,6 +439,69 @@ 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]);
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,
dataId: activeGreasePencil.id,
layerId: greasePencilLayerId,
frame: activeGreasePencilFrame.frame,
onionSkinning: activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.onionSkinning ?? false,
selectedStrokeIndices: [...new Set(selectedPoints.map((point) => point.strokeIndex))],
selectedPoints,
}, activeGreasePencilFrame.drawing.strokes, {
type: "TRANSLATE_POINTS",
revision: snapshot.revision,
translation: [greasePencilPointDeltaX, 0, 0],
});
onCommand({
type: "setGreasePencilStrokes",
dataId: activeGreasePencil.id,
layerId: greasePencilLayerId,
frame: activeGreasePencilFrame.frame,
baseRevision: snapshot.revision,
strokes: result.strokes,
});
};
const toggleDelimit = (value: SimplifyDelimit): void => {
setDelimit((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value]);
};
@@ -280,8 +543,8 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label> <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}></button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}></button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}></button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
<div className="property-section"><h3>Viewport Display</h3><label> <span className="swatch" /></label><label> <input type="checkbox" defaultChecked /></label></div>
{activeMesh ? <div className="property-section"><h3>UV Maps</h3><label> UV <select aria-label="活动 UV Map" value={activeMesh.activeUVMap ?? ""} onChange={(event) => event.target.value && onCommand({ type: "setActiveUVMap", meshId: activeMesh.id, name: event.target.value })}><option value="">None</option>{activeMesh.uvLayers?.map((layer) => <option key={layer.name} value={layer.name}>{layer.name}</option>)}</select></label><div className="property-actions"><button type="button" onClick={() => onCommand({ type: "createUVMap", meshId: activeMesh.id, name: `UVMap.${(activeMesh.uvLayers?.length ?? 0) + 1}` })}> UV</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "PLANAR" })}>Planar</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "CUBE" })}>Cube</button></div></div> : null}
{activeGreasePencil ? <div className="property-section" data-testid="grease-pencil-editor"><h3>Grease Pencil</h3><label>Layer <select aria-label="Grease Pencil layer" value={greasePencilLayerId} onChange={(event) => setGreasePencilLayerId(event.target.value)}>{activeGreasePencil.layers.map((layer) => <option key={layer.id} value={layer.id}>{layer.name}</option>)}</select></label><label>New layer <input aria-label="Grease Pencil new layer name" value={greasePencilLayerName} onChange={(event) => setGreasePencilLayerName(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand({ type: "createGreasePencilLayer", dataId: activeGreasePencil.id, name: greasePencilLayerName })}>Add Layer</button><button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand({ type: "removeGreasePencilLayer", dataId: activeGreasePencil.id, layerId: greasePencilLayerId })}>Remove Layer</button><button type="button" disabled={!greasePencilLayerId} onClick={() => onCommand({ type: "insertGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Add Frame</button><button type="button" disabled={!activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.some((entry) => entry.frame === (snapshot?.frame.current ?? 1))} onClick={() => onCommand({ type: "removeGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Remove Frame</button><button type="button" disabled={!activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.some((entry) => entry.frame === (snapshot?.frame.current ?? 1))} onClick={() => onCommand({ type: "setGreasePencilStrokes", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1, strokes: [] })}>Clear Drawing</button></div><output>{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes</output></div> : null}
{activeMesh && activeNode ? <div className="property-section" data-testid="paint-editor"><h3>Paint</h3><label>Vertex color <input aria-label="Paint vertex color" type="color" value={paintColor} onChange={(event) => setPaintColor(event.target.value)} /></label><label>Vertex group <input aria-label="Paint vertex group" value={paintGroup} onChange={(event) => setPaintGroup(event.target.value)} /></label><label>Weight <input aria-label="Paint vertex weight" type="range" min="0" max="1" step="0.01" value={paintWeight} onChange={(event) => setPaintWeight(Number(event.target.value))} /><output>{paintWeight.toFixed(2)}</output></label><div className="property-actions"><button type="button" disabled={selectedVertexIndices.length === 0} onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: selectedVertexIndices, colors: selectedVertexIndices.flatMap(() => [...rgb, 1]) }); }}>Apply Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup} onClick={() => onCommand({ type: "setVertexWeights", objectId: activeNode.id, vertexGroup: paintGroup, indices: selectedVertexIndices, values: selectedVertexIndices.map(() => paintWeight), normalize: true })}>Apply Weight</button></div><output>{selectedVertexIndices.length} selected vertices</output></div> : null}
{activeGreasePencil ? <div className="property-section" data-testid="grease-pencil-editor"><h3>Grease Pencil</h3><label>Layer <select aria-label="Grease Pencil layer" value={greasePencilLayerId} onChange={(event) => { setGreasePencilLayerId(event.target.value); setGreasePencilStrokeIndex(0); setGreasePencilPointIndex(0); }}>{activeGreasePencil.layers.map((layer) => <option key={layer.id} value={layer.id}>{layer.name}</option>)}</select></label><label>New layer <input aria-label="Grease Pencil new layer name" value={greasePencilLayerName} onChange={(event) => setGreasePencilLayerName(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand({ type: "createGreasePencilLayer", dataId: activeGreasePencil.id, name: greasePencilLayerName })}>Add Layer</button><button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand({ type: "removeGreasePencilLayer", dataId: activeGreasePencil.id, layerId: greasePencilLayerId })}>Remove Layer</button><button type="button" disabled={!greasePencilLayerId} onClick={() => onCommand({ type: "insertGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Add Frame</button><button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "removeGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Remove Frame</button><button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "setGreasePencilStrokes", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1, baseRevision: snapshot?.revision, strokes: [] })}>Clear Drawing</button></div>{activeGreasePencilFrame ? <><label>Stroke <select aria-label="Grease Pencil stroke" value={Math.min(greasePencilStrokeIndex, Math.max(0, activeGreasePencilFrame.drawing.strokes.length - 1))} onChange={(event) => { setGreasePencilStrokeIndex(Number(event.target.value)); setGreasePencilPointIndex(0); }}>{activeGreasePencilFrame.drawing.strokes.map((stroke, index) => <option key={stroke.id ?? index} value={index}>{index + 1}</option>)}</select></label><label>Point <select aria-label="Grease Pencil point" value={Math.min(greasePencilPointIndex, Math.max(0, (activeGreasePencilStroke?.points?.length ?? 1) - 1))} onChange={(event) => setGreasePencilPointIndex(Number(event.target.value))}>{activeGreasePencilStroke?.points?.map((_, index) => <option key={index} value={index}>{index + 1}</option>)}</select></label><label>X delta <input aria-label="Grease Pencil point X delta" type="number" min="-1000000" max="1000000" step="0.1" value={greasePencilPointDeltaX} onChange={(event) => setGreasePencilPointDeltaX(Number(event.target.value))} /></label><div className="property-actions"><button type="button" disabled={!activeGreasePencilPoint} onClick={translateGreasePencilPoint}>Move Point</button></div><output data-testid="grease-pencil-selection-count">{greasePencilPointSelection.length} viewport points selected</output>{activeGreasePencilPoint ? <output data-testid="grease-pencil-point-position">{activeGreasePencilPoint.position.join(", ")}</output> : null}</> : null}<output>{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes</output></div> : null}
{activeMesh && activeNode ? <div className="property-section" data-testid="paint-editor"><h3>Paint</h3><label>Vertex color <input aria-label="Paint vertex color" type="color" value={paintColor} onChange={(event) => setPaintColor(event.target.value)} /></label><label>Vertex group <input aria-label="Paint vertex group" value={paintGroup} onChange={(event) => setPaintGroup(event.target.value)} /></label><label>Weight <input aria-label="Paint vertex weight" type="range" min="0" max="1" step="0.01" value={paintWeight} onChange={(event) => setPaintWeight(Number(event.target.value))} /><output>{paintWeight.toFixed(2)}</output></label><label>Selection mask <input aria-label="Paint selection mask" type="range" min="0" max="1" step="0.01" value={paintSelectionMask} onChange={(event) => setPaintSelectionMask(Number(event.target.value))} /><output>{paintSelectionMask.toFixed(2)}</output></label><div className="property-actions"><button type="button" disabled={selectedVertexIndices.length === 0} onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: selectedVertexIndices, colors: selectedVertexIndices.flatMap(() => [...rgb, 1]) }); }}>Apply Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup} onClick={() => onCommand({ type: "setVertexWeights", objectId: activeNode.id, vertexGroup: paintGroup, indices: selectedVertexIndices, values: selectedVertexIndices.map(() => paintWeight), normalize: true })}>Apply Weight</button><button type="button" disabled={selectedVertexIndices.length === 0 || paintSelectionMask <= 0} onClick={blendPaintColor}>Blend Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup || paintSelectionMask <= 0} onClick={blendPaintWeight}>Blend Weight</button></div><output>{selectedVertexIndices.length} selected vertices</output><output data-testid="paint-color-attribute">{activeMesh.attributes?.some((attribute) => attribute.name === "WebPaintColor" && attribute.domain === "POINT") ? "WebPaintColor POINT" : "No WebPaintColor"}</output><output data-testid="paint-vertex-group">{activeMesh.vertexGroups?.some((group) => group.name === paintGroup) ? paintGroup : "No paint group"}</output></div> : null}
{activeMesh ? <div className="property-section">
<h3>Material Slots</h3>
<div className="material-slots">{activeMesh.materialSlotIds?.map((id, index) => <span key={`${id}:${index}`}>{index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"}</span>)}</div>
@@ -324,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 (
<div className="operator-search" role="dialog" aria-label="Operator Search">
<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") onClose(); }} placeholder="Search operators" aria-label="搜索操作" />
<div className="operator-results">{matches.map((operator) => <button key={operator} type="button" onClick={onClose}>{operator}</button>)}</div>
<input autoFocus value={query} onChange={(event) => 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="搜索操作" />
<div className="operator-results">{matches.map((command) => <button key={command.id} type="button" onClick={() => run(command)}>{command.label}</button>)}</div>
</div>
);
}
@@ -354,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 (
<div className="timeline-content">
<div className="timeline-controls"><button type="button" aria-label="跳到第一帧" onClick={() => { setPlaying(false); onFrameChange(start); }}>|</button><button type="button" aria-label="上一帧" onClick={() => onFrameChange(Math.max(start, frame - 1))}></button><button type="button" aria-label="播放" onClick={() => setPlaying((current) => !current)}>{playing ? "Ⅱ" : "▶"}</button><button type="button" aria-label="下一帧" onClick={() => onFrameChange(Math.min(end, frame + 1))}>|</button><button type="button" aria-label="跳到最后一帧" onClick={() => { setPlaying(false); onFrameChange(end); }}>|</button><output className="frame-number">{frame}</output>{snapshot?.activeObjectId ? <><button type="button" aria-label="插入位置关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "LOCATION", interpolation: "BEZIER" })}>Loc</button><button type="button" aria-label="插入旋转关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "ROTATION_EULER", interpolation: "BEZIER" })}>Rot</button><button type="button" aria-label="插入缩放关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "SCALE", interpolation: "BEZIER" })}>Scale</button><button type="button" aria-label="删除当前关键帧" onClick={() => onCommand({ type: "deleteObjectKeyframe", objectId: snapshot.activeObjectId!, frame })}>Del Key</button></> : null}</div>
<input className="frame-slider" type="range" min={start} max={end} value={Math.min(end, Math.max(start, frame))} onChange={(event) => onFrameChange(Number(event.target.value))} aria-label="当前帧" />
<div className="timeline-scale"><span>{start}</span><span>{mid}</span><span>{second}</span><span>{third}</span><span>{fourth}</span><span>{end}</span></div>
<div className="dope-sheet" aria-label="Dope Sheet"><span className="channel-name">{animation?.name ?? "No Action"}</span><div className="key-track">{keyframes.map((keyframe) => <button key={keyframe} type="button" className={keyframe === frame ? "key-dot active" : "key-dot"} style={{ left: `${((keyframe - start) / Math.max(1, end - start)) * 100}%` }} aria-label={`关键帧 ${keyframe}`} onClick={() => onFrameChange(keyframe)} />)}</div>{animation?.channels[0] ? <select aria-label="FCurve 插值" value={animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: animation.channels[0].path, interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" })}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select> : null}</div>
<div className="dope-sheet" aria-label="Dope Sheet"><span className="channel-name">{greasePencil?.name ?? animation?.name ?? "No Action"}</span><div className="key-track">{keyframes.map((keyframe) => <button key={keyframe} type="button" className={keyframe === frame ? "key-dot active" : "key-dot"} style={{ left: `${((keyframe - start) / Math.max(1, end - start)) * 100}%` }} aria-label={`${greasePencil ? "Grease Pencil 帧" : "关键帧"} ${keyframe}`} onClick={() => onFrameChange(keyframe)} />)}</div>{!greasePencil && animation?.channels[0] ? <select aria-label="FCurve 插值" value={animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: animation.channels[0].path, interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" })}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select> : <span />}</div>
</div>
);
}
@@ -379,6 +656,7 @@ export function App() {
const [geometryBuffers, setGeometryBuffers] = useState<MeshGeometryBuffer[]>([]);
const [nonMeshGeometryBuffers, setNonMeshGeometryBuffers] = useState<NonMeshGeometryChunk[]>([]);
const [gpuTextureAssets, setGPUTextureAssets] = useState<GPUTextureAsset[]>([]);
const [volumeProject, setVolumeProject] = useState<NanoVDBViewportProjectContextIR | null>(null);
const [preview, setPreview] = useState<{ snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] } | null>(null);
const [lodLevels, setLodLevels] = useState<Record<string, WebEngineLODLevelResult[]> | null>(null);
const [openProgress, setOpenProgress] = useState<ProgressEvent | null>(null);
@@ -400,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);
@@ -413,7 +691,7 @@ export function App() {
const next = preserve ? new Set(current.indices) : new Set<number>();
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<NonMeshElementKind, Set<number>>(preserve ? [...(current.nonMeshSelections ?? [])].map(([kind, values]) => [kind, new Set(values)]) : []);
const kindIndices = selections.get(nonMeshKind) ?? new Set<number>();
if (kindIndices.has(index)) kindIndices.delete(index);
@@ -422,7 +700,23 @@ export function App() {
else selections.set(nonMeshKind, kindIndices);
const combined = new Set<number>();
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<void> => {
@@ -523,7 +817,7 @@ export function App() {
}
}
} catch (error) {
setEngineStatus(`Engine: command failed${error instanceof Error ? ` (${error.message})` : ""}`);
setEngineStatus(`Engine: command failed (${errorMessage(error)})`);
}
};
@@ -538,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;
@@ -568,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();
@@ -589,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})` : ""}`);
@@ -627,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") {
@@ -660,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<void> => {
const client = webClientRef.current;
if (!client || !snapshot || triangleCount <= 0) return;
@@ -839,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([]);
@@ -870,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;
};
@@ -896,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;
@@ -986,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 (
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
@@ -998,14 +1352,14 @@ export function App() {
<div className="topbar-actions"><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}></button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}></button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}></button><button type="button" aria-label="导出 GLB" onClick={reportGLBExport}>GLB</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}></button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}></button><button type="button" aria-label="操作搜索" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button></div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept=".blend,application/octet-stream" data-testid="blend-file-input" onChange={(event) => { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} />
</header>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><button type="button" onClick={() => setSaved(false)}>{saved ? "已保存" : "未保存"}</button></div>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><button type="button" onClick={() => setSaved(false)}>{saved ? "已保存" : "未保存"}</button></div>
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onTransform={transformActive} /></Area>
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onTransform={transformActive} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilPointSelection={meshSelection.greasePencilPoints ?? []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
{uiState.operatorSearchOpen ? <OperatorSearch commands={operatorCommands} onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
<footer className="status-bar"><span>Blender Web 0.1.0</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
</main>
);

View File

@@ -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; }

View File

@@ -0,0 +1,15 @@
export {
CompositorFrameCache,
CompositorValidationError,
compositorFrameCacheKey,
executeCompositorGraph,
executeCompositorGraphCached,
gateCompositorGraph,
parseCompositorGraph,
} from "../../../protocol/compositor";
export type {
CompositorCachedExecutionResult,
CompositorExecutionResult,
CompositorGraphIR,
CompositorImageBuffer,
} from "../../../protocol/compositor";

View File

@@ -0,0 +1 @@
export * from "../../../protocol/render-assets";

View File

@@ -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<f32>(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<i32>) -> vec2<u32> {
let x = bitcast<u32>(coord.x) >> 12u;
let y = bitcast<u32>(coord.y) >> 12u;
let z = bitcast<u32>(coord.z) >> 12u;
return vec2<u32>(z | ((y & 0x7ffu) << 21u), (y >> 11u) | (x << 10u));
}
fn key_less(a: vec2<u32>, b: vec2<u32>) -> 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<i32>) -> vec2<f32> {
if (!valid_grid()) { return vec2<f32>(0.0, -1.0); }
let tree = 672u;
let root = child_address(tree, tree + 24u, 64u);
if (root == 0xffffffffu) { return vec2<f32>(0.0, -1.0); }
let count = word(root + 24u);
if (count > (params.data_bytes - root - 64u) / 32u) { return vec2<f32>(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<u32>(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<f32>(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<f32>(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<f32>(0.0, -1.0); }
let ux = (bitcast<u32>(coord.x) & 4095u) >> 7u;
let uy = (bitcast<u32>(coord.y) & 4095u) >> 7u;
let uz = (bitcast<u32>(coord.z) & 4095u) >> 7u;
let upper_index = (ux << 10u) | (uy << 5u) | uz;
if (!mask_on(upper + 4128u, upper_index)) { return vec2<f32>(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<f32>(0.0, -1.0); }
let lx = (bitcast<u32>(coord.x) & 127u) >> 3u;
let ly = (bitcast<u32>(coord.y) & 127u) >> 3u;
let lz = (bitcast<u32>(coord.z) & 127u) >> 3u;
let lower_index = (lx << 8u) | (ly << 4u) | lz;
if (!mask_on(lower + 544u, lower_index)) { return vec2<f32>(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<f32>(0.0, -1.0); }
let voxel = ((bitcast<u32>(coord.x) & 7u) << 6u) | ((bitcast<u32>(coord.y) & 7u) << 3u) | (bitcast<u32>(coord.z) & 7u);
return vec2<f32>(scalar(leaf + 96u + voxel * 4u), select(0.0, 1.0, mask_on(leaf + 16u, voxel)));
}
fn sample_density_linear(position: vec3<f32>) -> vec2<f32> {
let base = vec3<i32>(floor(position));
let fraction = position - vec3<f32>(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<i32>(x, y, z));
if (sample.y < 0.0) { return vec2<f32>(0.0, -1.0); }
let offset = vec3<f32>(f32(x), f32(y), f32(z));
let weight3 = select(vec3<f32>(1.0) - fraction, fraction, offset == vec3<f32>(1.0));
value += sample.x * weight3.x * weight3.y * weight3.z;
activity = max(activity, sample.y);
}
}
}
return vec2<f32>(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<f32>(color_word(byte_offset)); }
fn color_vec3(byte_offset: u32) -> vec3<f32> { return vec3<f32>(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<i32>) -> vec2<u32> {
let x = bitcast<u32>(coord.x) >> 12u; let y = bitcast<u32>(coord.y) >> 12u; let z = bitcast<u32>(coord.z) >> 12u;
return vec2<u32>(z | ((y & 0x7ffu) << 21u), (y >> 11u) | (x << 10u));
}
fn color_key_less(a: vec2<u32>, b: vec2<u32>) -> 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<i32>) -> vec4<f32> {
if (!color_valid_grid()) { return vec4<f32>(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<f32>(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<f32>(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<u32>(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<f32>(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<f32>(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<f32>(0.0, 0.0, 0.0, -1.0); }
let upper_index = (((bitcast<u32>(coord.x) & 4095u) >> 7u) << 10u) | (((bitcast<u32>(coord.y) & 4095u) >> 7u) << 5u) | ((bitcast<u32>(coord.z) & 4095u) >> 7u);
if (!color_mask_on(upper + 4128u, upper_index)) { return vec4<f32>(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<f32>(0.0, 0.0, 0.0, -1.0); }
let lower_index = (((bitcast<u32>(coord.x) & 127u) >> 3u) << 8u) | (((bitcast<u32>(coord.y) & 127u) >> 3u) << 4u) | ((bitcast<u32>(coord.z) & 127u) >> 3u);
if (!color_mask_on(lower + 544u, lower_index)) { return vec4<f32>(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<f32>(0.0, 0.0, 0.0, -1.0); }
let voxel = ((bitcast<u32>(coord.x) & 7u) << 6u) | ((bitcast<u32>(coord.y) & 7u) << 3u) | (bitcast<u32>(coord.z) & 7u);
return vec4<f32>(color_vec3(leaf + 128u + voxel * 12u), select(0.0, 1.0, color_mask_on(leaf + 16u, voxel)));
}
fn sample_color_linear(position: vec3<f32>) -> vec4<f32> {
let base = vec3<i32>(floor(position)); let fraction = position - vec3<f32>(base); var value = vec3<f32>(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<i32>(x, y, z)); if (sample.w < 0.0) { return vec4<f32>(0.0, 0.0, 0.0, -1.0); }
let offset = vec3<f32>(f32(x), f32(y), f32(z)); let weight3 = select(vec3<f32>(1.0) - fraction, fraction, offset == vec3<f32>(1.0));
value += sample.xyz * weight3.x * weight3.y * weight3.z; activity = max(activity, sample.w);
}}}
return vec4<f32>(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<number, number>();
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<string, { buffer: GPUBuffer; bytes: number; used: number }>();
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<NanoVDBDeviceLossIR>;
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<GPUDevice> {
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<NanoVDBDeviceLossIR> {
if (!this.loss) throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU session has not opened");
return this.loss;
}
async recover(requiredBytes: number): Promise<GPUDevice> {
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<readonly [number, number, number]>): Promise<Array<{ value: number; active: boolean; valid: boolean }>> {
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<storage, read> grid: array<u32>;
@group(0) @binding(1) var<storage, read> coords: array<vec4<i32>>;
@group(0) @binding(2) var<storage, read_write> results: array<vec4<f32>>;
@group(0) @binding(3) var<uniform> params: Params;
@group(0) @binding(4) var<storage, read> page_table: array<u32>;
${traversalWGSL}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x >= params.count) { return; }
let sample = sample_density(coords[id.x].xyz);
results[id.x] = vec4<f32>(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<Uint8Array> {
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<i32>) -> vec2<f32> { return vec2<f32>(0.0); }
fn temperature_sample_density_linear(position: vec3<f32>) -> vec2<f32> { return vec2<f32>(0.0); }
`;
const colorSource = materialGrids.color ? vec3TraversalWGSL : /* wgsl */`
fn sample_color(coord: vec3<i32>) -> vec4<f32> { return vec4<f32>(0.0); }
fn sample_color_linear(position: vec3<f32>) -> vec4<f32> { return vec4<f32>(0.0); }
`;
const emissionSource = materialGrids.emission ? emissionTraversalWGSL : /* wgsl */`
fn emission_sample_density(coord: vec3<i32>) -> vec2<f32> { return vec2<f32>(0.0); }
fn emission_sample_density_linear(position: vec3<f32>) -> vec2<f32> { return vec2<f32>(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<i32>, index_max: vec4<i32>, material: vec4<f32>, color: vec4<f32>, emission_color: vec4<f32>, material_grids: vec4<f32>
}
@group(0) @binding(0) var<storage, read> grid: array<u32>;
@group(0) @binding(1) var<storage, read_write> pixels: array<u32>;
@group(0) @binding(2) var<uniform> params: Params;
@group(0) @binding(3) var<storage, read> page_table: array<u32>;
@group(0) @binding(4) var<storage, read> temperature_grid: array<u32>;
@group(0) @binding(5) var<storage, read> color_grid: array<u32>;
@group(0) @binding(6) var<storage, read> emission_grid: array<u32>;
${traversalWGSL}
${temperatureSource}
${colorSource}
${emissionSource}
fn blackbody_color(kelvin: f32) -> vec3<f32> {
let t = smoothstep(800.0, 12000.0, clamp(kelvin, 800.0, 12000.0));
return mix(vec3<f32>(1.0, 0.11, 0.015), vec3<f32>(0.62, 0.8, 1.0), t);
}
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x >= params.width || id.y >= params.height) { return; }
let extent = vec2<f32>(params.index_max.xy - params.index_min.xy + vec2<i32>(1));
let uv = (vec2<f32>(id.xy) + vec2<f32>(0.5)) / vec2<f32>(f32(params.width), f32(params.height));
let xy_position = vec2<f32>(params.index_min.xy) + uv * extent - vec2<f32>(0.5);
let xy = vec2<i32>(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<f32>(0.0);
for (var z = params.index_min.z; z <= params.index_max.z; z += stride) {
var sample = sample_density(vec3<i32>(xy, z));
if (params.interpolation == 1u) {
sample = sample_density_linear(vec3<f32>(xy_position, f32(z) + 0.5));
}
if (sample.y < 0.0) { radiance = vec3<f32>(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<i32>(xy, z));
if (params.interpolation == 1u) { color_sample = sample_color_linear(vec3<f32>(xy_position, f32(z) + 0.5)); }
if (color_sample.w >= 0.0 && color_sample.w > 0.5) { scattering_color = max(vec3<f32>(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<i32>(xy, z));
if (params.interpolation == 1u) { temperature_sample = temperature_sample_density_linear(vec3<f32>(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<i32>(xy, z));
if (params.interpolation == 1u) { emission_sample = emission_sample_density_linear(vec3<f32>(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<f32>(clamp(radiance, vec3<f32>(0.0), vec3<f32>(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;
}

View File

@@ -0,0 +1,14 @@
export {
applySequencerEdit,
gateSequencerCodec,
parseSequencerTimeline,
resolveSequencerFrame,
resolveSequencerTransitionFrame,
sequencerRuntimeCapabilities,
sequencerSourceFrame,
} from "../../../protocol/sequencer";
export type {
SequencerFrameStripIR,
SequencerTimelineIR,
SequencerTransitionFrameIR,
} from "../../../protocol/sequencer";

View File

@@ -0,0 +1,8 @@
export {
applyBrowserTransformCachePreview,
BrowserTransformCachePlaybackSession,
} from "../../../protocol/physics-cache-playback";
export type {
BrowserTransformCacheFrameSource,
BrowserTransformCachePlaybackResult,
} from "../../../protocol/physics-cache-playback";

View File

@@ -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;

View File

@@ -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<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>;
@@ -90,6 +91,9 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = 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<number> = 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();
});
}

View File

@@ -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 };

View File

@@ -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<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void;
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void;
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, 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<OffscreenViewportResponse>) => 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<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void {
const cloned = cloneNanoVDBViewportAssets(assets);
this.worker.postMessage({ type: "volumeAssets", assets: cloned } satisfies OffscreenViewportRequest, nanoVDBViewportAssetTransferables(cloned));
}
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, 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;
}

View File

@@ -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) :

View File

@@ -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<string, string[]> {
const groups = new Map<string, string[]>();
@@ -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<string, Object3D>();
private readonly instanceIndexByBlenderId = new Map<string, number>();
@@ -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<string, NanoVDBViewportRenderResultIR>();
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<void> {
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<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
setSelection(
objectIds: ReadonlySet<string>,
elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>,
greasePencilPoints: readonly GreasePencilPointRef[] = [],
): void {
const visitedInstances = new Set<InstancedMesh>();
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<CurveGizmoScreenFrameIR | null>("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();
}

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
export {
raycastMaskProject,
selectMaskPointsInBounds,
} from "../../../protocol/tracking-mask";
export type {
MaskPointSelectionIR,
MaskRaycastHitIR,
} from "../../../protocol/tracking-mask";

46
web/app/src/types/webgpu.d.ts vendored Normal file
View File

@@ -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<void>;
}
interface GPUAdapter {
limits: { maxStorageBufferBindingSize: number; maxBufferSize: number };
requestDevice(options?: { requiredLimits?: Record<string, number> }): Promise<GPUDevice>;
}
interface GPUQueue { writeBuffer(buffer: GPUBuffer, offset: number, data: ArrayBuffer | ArrayBufferView): void; submit(commands: Array<GPUCommandBuffer>): 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<GPUAdapter | null> } }

Binary file not shown.

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -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<FileSystemDirectoryHandle> };
type MovableFile = FileSystemFileHandle & { move?: (name: string) => Promise<void> };
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<VDBProjectBindingIR[]> {
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<VDBProjectBindingIR>(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<FileSystemDirectoryHandle> {
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<FileSystemDirectoryHandle> {
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<void> {
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<void> {
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<T>(parent: FileSystemDirectoryHandle, name: string): Promise<T> {
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<void> {
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<string> {
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<VDBProjectBindingIR> {
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<NanoVDBOPFSCommitResult> {
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<NanoVDBBundleManifestIR>(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<NanoVDBOPFSOpenResult> {
validateSha256(bundleSha256);
if (conversionRequestSha256) validateSha256(conversionRequestSha256);
const cache = await rootFor(projectId, storage);
const bundle = await directory(cache, bundleSha256, false);
const manifest = validateNanoVDBBundleManifest(await readJson<NanoVDBBundleManifestIR>(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<VDBProjectBindingIR>(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<NanoVDBBundleManifestIR>(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 };
}

View File

@@ -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<ArrayBuffer>;
export type NanoVDBChunkConsumer = (range: NanoVDBRangeIR, data: ArrayBuffer, signal: AbortSignal) => Promise<void> | 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<NanoVDBStreamResultIR> {
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<void> {
if (delayMs === 0) return;
await new Promise<void>((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<string, string> = { 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}`);
};
}

View File

@@ -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<NanoVDBViewportRenderResultIR> {
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<ArrayBuffer> {
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<NanoVDBViewportAssetIR> {
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<NanoVDBViewportAssetIR> {
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<NanoVDBViewportAssetIR> {
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<NanoVDBViewportRenderResultIR> {
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();
}
}

View File

@@ -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"],
};
}

View File

@@ -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<string, unknown> = {};
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);
};

View File

@@ -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<string, unknown> = {}) => ({ id, type, name: id, properties });
const valid = {
@@ -26,7 +26,7 @@ const valid = {
],
};
self.onmessage = () => {
self.onmessage = async () => {
const result: Record<string, unknown> = {};
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);
};

View File

@@ -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<string, unknown> = {};
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);
};

View File

@@ -1,4 +1,4 @@
import { applyGreasePencilEditorEdit, parseGreasePencilEditor } from "../../../protocol/grease-pencil-editor";
import { applyGreasePencilEditorEdit, applyGreasePencilPointTranslation, parseGreasePencilEditor } from "../../../protocol/grease-pencil-editor";
self.onmessage = () => {
const result: Record<string, unknown> = {};
@@ -11,5 +11,16 @@ self.onmessage = () => {
try { applyGreasePencilEditorEdit(base, { type: "SET_LAYER", revision: 7, layerId: "layer:Other" }); } catch (error) { result.stale = error instanceof Error ? error.message : String(error); }
try { parseGreasePencilEditor({ ...base, selectedPoints: [{ strokeIndex: 1, pointIndex: 1 }, { strokeIndex: 1, pointIndex: 1 }] }); } catch (error) { result.duplicate = error instanceof Error ? error.message : String(error); }
try { parseGreasePencilEditor({ ...base, selectedStrokeIndices: [-1] }); } catch (error) { result.negative = error instanceof Error ? error.message : String(error); }
try {
const editor = { ...base, selectedPoints: [{ strokeIndex: 0, pointIndex: 1 }] };
const translated = applyGreasePencilPointTranslation(editor, [{ cyclic: false, materialIndex: 0, points: [
{ position: [0, 0, 0], radius: 0.1, opacity: 1 },
{ position: [1, 2, 3], radius: 0.2, opacity: 0.8, vertexColor: [1, 0, 0, 1] },
] }], { type: "TRANSLATE_POINTS", revision: 0, translation: [0.5, -1, 2] });
result.translation = [translated.editor.revision, translated.strokes[0].points[0].position, translated.strokes[0].points[1]];
} catch (error) { result.translation = error instanceof Error ? error.message : String(error); }
try {
applyGreasePencilPointTranslation({ ...base, selectedPoints: [{ strokeIndex: 9, pointIndex: 1 }] }, [{ points: [{ position: [0, 0, 0] }] }], { type: "TRANSLATE_POINTS", revision: 0, translation: [1, 0, 0] });
} catch (error) { result.missingPoint = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -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<string, unknown> = {};
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);
};

View File

@@ -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<string, unknown> = {};
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);
};

View File

@@ -1,4 +1,4 @@
import { PAINT_BUDGET, parsePaintStroke, parseWeightPatch } from "../../../protocol/paint";
import { PAINT_BUDGET, applyUdimTilePatch, buildPaintBrushSpatialIndex, composePaintColorPatch, composePaintWeightPatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch, queryPaintBrushSpatialIndex } from "../../../protocol/paint";
const base = {
schemaVersion: 1,
@@ -11,12 +11,50 @@ const base = {
color: [1, 0.25, 0, 1],
};
self.onmessage = () => {
const result: Record<string, string> = {};
self.onmessage = async () => {
const result: Record<string, unknown> = {};
try { result.valid = parsePaintStroke(base).mode; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
try { parsePaintStroke({ ...base, samples: [{ position: [0, 0, 0], barycentric: [0.1, 0.1, 0.1] }] }); } catch (error) { result.hit = error instanceof Error ? error.message : String(error); }
try { parsePaintStroke({ ...base, mode: "WEIGHT", vertexGroup: "Group", samples: new Array(PAINT_BUDGET.maxSamples + 1).fill(base.samples[0]) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { result.weight = parseWeightPatch({ schemaVersion: 1, objectId: "object:Paint", revision: 3, vertexGroup: "Group", indices: [0, 1], values: [0.25, 0.75], normalize: true }).vertexGroup; } catch (error) { result.weight = error instanceof Error ? error.message : String(error); }
try {
result.brush = computePaintBrushWeights([
{ index: 2, position: [0, 0, 0], normal: [0, 0, 1] },
{ index: 1, position: [1, 0, 0], normal: [0, 0, 1] },
{ index: 3, position: [0.5, 0, 0], normal: [0, 0, 1], occluded: true },
{ 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<string, readonly number[]>).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("");
const expected = baseTile.slice(); expected.set([10, 20, 30, 255], 4);
const patch = { schemaVersion: 1, textureAssetId: "image:Paint", tile: 1001, revision: 2, width: 2, height: 2, format: "RGBA8", colorSpace: "SRGB", baseSha256: await hash(baseTile), resultSha256: await hash(expected), byteOffset: 4, bytes: new Uint8Array([10, 20, 30, 255]) };
parseUdimTilePatch(patch);
result.udim = Array.from(await applyUdimTilePatch(baseTile, patch, 2));
try { await applyUdimTilePatch(baseTile, patch, 3); } catch (error) { result.udimRevision = error instanceof Error ? error.message : String(error); }
try { await applyUdimTilePatch(new Uint8Array(16).fill(1), patch, 2); } catch (error) { result.udimStale = error instanceof Error ? error.message : String(error); }
} catch (error) { result.udim = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -1,4 +1,5 @@
import {
decodeBrowserTransformCacheFrame,
PHYSICS_FAMILIES,
PHYSICS_SIMULATION_BUDGET,
gatePhysicsExecution,
@@ -6,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 = {
@@ -29,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<string, unknown> = {};
try {
const parsed = parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [base] });
@@ -61,5 +75,41 @@ self.onmessage = () => {
result.solver = gatePhysicsExecution("FLUID", "LOCAL_SOLVER").issues[0]?.code;
result.manifest = gatePhysicsExecution("RIGID_BODY", "CACHE_MANIFEST").status;
result.familyCount = PHYSICS_FAMILIES.length;
try {
const bytes = browserFrame(7, [1, 2, 3]);
const view = new DataView(bytes);
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<ArrayBuffer>((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<ArrayBuffer>((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);
};

View File

@@ -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<string, unknown> = {};
@@ -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);
};

View File

@@ -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<string, unknown> = {
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);
};

View File

@@ -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<string, unknown> = {};
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); }

View File

@@ -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);
};

View File

@@ -13,6 +13,10 @@ const scope = self as unknown as {
const projectTransactions = new Map<string, Promise<void>>();
let opfsUsable: boolean | undefined;
interface WorkerLockManager {
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
}
async function useOpfsForProject(projectId: string): Promise<boolean> {
if (opfsUsable !== undefined) return opfsUsable;
const workerNavigator = (self as unknown as { navigator?: Navigator }).navigator;
@@ -33,7 +37,10 @@ async function useOpfsForProject(projectId: string): Promise<boolean> {
async function withProjectTransaction<T>(projectId: string, operation: () => Promise<T>): Promise<T> {
projectLayout(projectId);
const previous = projectTransactions.get(projectId) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(operation);
const result = previous.catch(() => undefined).then(() => {
const locks = (self as unknown as { navigator?: { locks?: WorkerLockManager } }).navigator?.locks;
return locks ? locks.request(`blender-web-project:${projectId}`, operation) : operation();
});
const tail = result.then(() => undefined, () => undefined);
projectTransactions.set(projectId, tail);
try {
@@ -197,6 +204,33 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf
const useOpfs = await useOpfsForProject(projectId);
const layout = useOpfs ? await ensureProjectLayout(projectId) : projectLayout(projectId);
const sha256 = await sha256Hex(buffer);
const existing = faultAt ? undefined : await readProjectRow(projectId);
if (existing && existing.revision > revision) {
throw new Error(`PROJECT_REVISION_CONFLICT: committed revision ${existing.revision} is newer than ${revision}`);
}
if (existing?.revision === revision) {
if (existing.bytes !== buffer.byteLength || existing.sha256 !== sha256) {
throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} already has different content`);
}
if (existing.backend === "opfs" && useOpfs) {
const recovered = await recoverProjectBlend(projectId);
if (!recovered.manifest || recovered.manifest.revision !== revision || recovered.manifest.bytes !== existing.bytes || recovered.manifest.sha256 !== sha256) {
throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} metadata does not match the OPFS commit`);
}
}
else if (existing.backend !== "indexeddb" || useOpfs || !existing.buffer || await sha256Hex(existing.buffer) !== sha256) {
throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} backend does not match the committed project`);
}
return {
projectId,
bytes: existing.bytes,
revision,
persisted: true,
backend: existing.backend,
scenePath: existing.scenePath,
sha256,
};
}
if (useOpfs) {
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, faultAt);
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");

View File

@@ -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); }

View File

@@ -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<Uint8Array>({
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) }));
};

View File

@@ -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<string> {
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<FileSystemDirectoryHandle> {
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<void> {
const writer = await (await directory.getFileHandle(name)).createWritable();
await writer.write(value);
await writer.close();
}
async function manifestFor(data: Uint8Array, requestSeed: string): Promise<NanoVDBBundleManifestIR> {
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<unknown> {
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<ArrayBuffer> => {
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<unknown> {
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<never> {
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<never>(() => 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<unknown> {
const recovered = await recoverNanoVDBOPFS(projectId);
await pruneNanoVDBOPFS(projectId, 0);
return recovered;
}
async function prepareQuota(): Promise<unknown> {
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<unknown> {
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<unknown> {
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) }));
};

View File

@@ -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<string> {
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) }));
};

View File

@@ -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) }));
};

View File

@@ -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<OffscreenViewportRequest>) => 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<string, Object3D>();
let curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null;
let volumeAssets: NanoVDBViewportAssetIR[] = [];
const volumeRenderCache = new Map<string, NanoVDBViewportRenderResultIR>();
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<void> {
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<Object3D>();
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) {

View File

@@ -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<WebEngineStatus> | null = null;
let currentSnapshot: ReturnType<typeof parseSceneSnapshotIR> | null = null;
let currentGeometryBuffers: MeshGeometryBuffer[] = [];
let sourceBlendBuffer: ArrayBuffer | null = null;
@@ -300,6 +301,17 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
if (!payload.links || [payload.links.regular, payload.links.bold, payload.links.italic, payload.links.boldItalic].some((id) => 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}`);
@@ -329,6 +341,7 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
}
case "setGreasePencilStrokes": {
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !Number.isSafeInteger(payload.frame) || payload.frame < -1_000_000 || payload.frame > 1_000_000 || !Array.isArray(payload.strokes) || payload.strokes.length > 1_000_000) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil stroke transaction is invalid");
if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Grease Pencil stroke base revision does not match the current SceneIR");
let points = 0;
for (const [strokeIndex, stroke] of payload.strokes.entries()) {
if (!stroke || typeof stroke !== "object" || !Array.isArray(stroke.points) || stroke.points.length === 0 || stroke.points.length > 1_000_000 || (stroke.cyclic !== undefined && typeof stroke.cyclic !== "boolean") || (stroke.materialIndex !== undefined && (!Number.isSafeInteger(stroke.materialIndex) || stroke.materialIndex < 0))) throw report("GREASE_PENCIL_SCHEMA_INVALID", `Grease Pencil stroke ${strokeIndex} is invalid`);
@@ -458,20 +471,32 @@ function nativeError(fallbackCode: ErrorReport["code"]): ErrorReport {
}
async function initialize(): Promise<WebEngineStatus> {
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 } {

View File

@@ -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<string, string | undefined> } }).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,

View File

@@ -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",
@@ -45,6 +56,7 @@
"test:editor-main-reader": "node ../tools/web/check-editor-main-reader.mjs",
"test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"",
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
"test:scripting-isolation": "node ../tools/web/check-scripting-isolation.mjs",
"test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"",
"test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"",
"test:cross-browser-smoke": "npm run test:browser-smoke",

View File

@@ -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",

View File

@@ -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<string> {
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<void> {
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<string>();
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);

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