Compare commits
2 Commits
d54d5dd913
...
68d50f810f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d50f810f | ||
|
|
3da1dfc804 |
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Blender 5.2 Web 功能对标与全量迁移台账
|
||||
|
||||
更新时间:2026-08-12
|
||||
更新时间:2026-08-13
|
||||
|
||||
本文件以仓库内 `blender-5.2.0/source/blender/` 为基线,记录 Blender 功能域在 Web
|
||||
项目中的迁移方式和唯一领取顺序。`completed_current_scope` 只表示声明子集通过验收,
|
||||
@@ -37,14 +37,14 @@
|
||||
| NLA | `in_progress` Action Clip/reverse/repeat 子集 | 多轨混合、Transition/Meta、Animated Time、UI 和导出 | N-014 |
|
||||
| Curve/Curves/Surface/Text/Metaball | `in_progress` reader/预览子集 | 数据块 IR、Main operator、tessellation 和保存闭环 | N-015 |
|
||||
| Point Cloud/Volume/Hair | `in_progress` 摘要/资源门 | 有界数据读取、分块 buffer、视口和资源预算 | N-015 |
|
||||
| Grease Pencil/Drawing | `planned` | layer/frame/drawing/stroke、onion skin 和基础 modifier | N-016 |
|
||||
| Vertex/Weight/Texture Paint | `planned` | Main/PBVH image transaction、颜色管理和恢复 | N-017 |
|
||||
| Rigid/Soft Body、Cloth、Fluid、Particles | `planned` | 优先消费 desktop bake;WASM 求值逐 family feature probe | N-018 |
|
||||
| Camera/Light/World/Color Management | `metadata/viewport subset` | SceneIR/Three/WebGPU 映射、阴影、曝光和差异报告 | N-019 |
|
||||
| Grease Pencil/Drawing | `in_progress` 有界 Main/3D editor | layer/frame/drawing/stroke、onion skin 和基础 modifier | N-016 |
|
||||
| Vertex/Weight/Texture Paint | `in_progress` vertex color/weight Main 子集 | Main/PBVH image transaction、颜色管理和恢复 | N-017 |
|
||||
| Rigid/Soft Body、Cloth、Fluid、Particles | `in_progress` reader/cache/playback 安全层 | 优先消费 desktop bake;WASM 求值逐 family feature probe | N-018 |
|
||||
| Camera/Light/World/Color Management | `in_progress` Main/viewport subset | SceneIR/Three/WebGPU 映射、阴影、曝光和差异报告 | N-019 |
|
||||
| Eevee/Cycles/Freestyle/Render Result | `planned` | WebGPU 实时子集;Cycles/硬件后端采用服务端 Blender | N-019 |
|
||||
| Compositor | `planned` | 受限 node graph + WebGPU/CPU executor;完整图可服务端执行 | N-020 |
|
||||
| Video Sequencer/Audio | `planned` | strip/time/edit proxy;codec 能力探测;最终编码可服务端 | N-021 |
|
||||
| Movie Tracking/Mask | `planned` | 数据/编辑协议和基础浏览器工具;重计算按能力门 | N-022 |
|
||||
| Compositor | `in_progress` 有限 Main reader/CPU executor | 受限 node graph + WebGPU/CPU executor;完整图可服务端执行 | N-020 |
|
||||
| Video Sequencer/Audio | `in_progress` reader/transition 子集 | strip/time/edit proxy;codec 能力探测;最终编码可服务端 | N-021 |
|
||||
| Movie Tracking/Mask | `in_progress` Mask reader/raycast/marquee | 数据/编辑协议和基础浏览器工具;重计算按能力门 | N-022 |
|
||||
| Asset Browser/Library/Override | `partial` | catalog、preview、append/link/override、路径与来源沙箱 | N-023 |
|
||||
| Import/Export formats | `GLB subset` | glTF/OBJ/PLY/STL/USD/Alembic 能力矩阵和 desktop round-trip | N-023 |
|
||||
| Blender editors/workspaces/keymap | `partial shell` | View3D/Outliner/Properties/UV/Node/Graph/Dope/NLA/Spreadsheet | N-024 |
|
||||
@@ -84,21 +84,27 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
65,536 点 chunks、SHA-256、256 MB 场景预算和 1M 点性能门。
|
||||
2. N-015-B(`done_current_scope`):native reader 已读取真实 PointCloud/Curves position、
|
||||
radius、curve offset 和属性;depsgraph 对 Curve/Surface/Font/Metaball 输出真实 mesh、UV、
|
||||
材质槽和 source face mapping;Volume 保持项目路径与 VDB grid/decoder 安全门。
|
||||
材质槽和 source face mapping;Volume 保留 metadata,并采用 desktop/server OpenVDB ->
|
||||
NanoVDB、浏览器 range stream + WebGPU 的重新纳入方案。
|
||||
3. N-015-C(`in_progress`):Curve/Surface control point/resolution、有界 cyclic/handle metadata、
|
||||
bounded Poly Curve create/delete、Poly/Bezier conversion、Curve/Surface/Font/Metaball 数据块 rename、
|
||||
Font body/geometry properties、逐字符样式/textbox、已有/packed VFont 四样式链接和
|
||||
Metaball element Main command 已支持并通过 undo/save/reopen;一维多 spline 创建/删除/重排、
|
||||
批量 handle/cyclic 和二维 Surface U/V topology/order/rational weight transaction 已通过
|
||||
undo/save/reopen 与 depsgraph 求值;多 handle 选择拖拽/gizmo UI 和任意外部路径字体导入仍推进。
|
||||
undo/save/reopen 与 depsgraph 求值;多 handle 连续 preview、handle-local 原点/正交方向及
|
||||
单次 Main commit 已完成,任意外部路径字体导入仍推进。
|
||||
4. N-015-D(`in_progress`):Three.js 主线程和 OffscreenCanvas Worker 共享 WNM chunks,真实
|
||||
PointCloud/Curves 可预览,Point/Curve bounded raycast、Metaball proxy 与 evaluated mesh 分层;
|
||||
左右 handle identity raycast/单 handle 轴向 gizmo 已完成;WebGPU、跨对象 selection history、
|
||||
多 handle 拖拽和 SceneDelta range patch 仍待完成。
|
||||
左右 handle identity raycast、跨对象 selection history、range patch、多 handle 拖拽和
|
||||
handle-local gizmo 已完成;WebGPU 等价显示仍待完成。
|
||||
5. N-015-E(`in_progress`):Blender 5.2 fixture、Chromium 双 renderer、depsgraph 真求值、GLB
|
||||
拒绝/映射和 USD loss report、Main roundtrip、desktop golden、7 对象 GLB/USD round-trip、
|
||||
1M binary gate、Worker restart 与 Chromium 真实 OPFS quota 已接入;VDB renderer 仍是
|
||||
当前阻断。后续浏览器验收仅覆盖 Chromium,不配置 Firefox/WebKit。
|
||||
1M binary gate、Worker restart 与 Chromium 真实 OPFS quota 已接入。VDB 转换请求、NanoVDB
|
||||
grid/material/chunk manifest、逐块 hash/range streamer、分阶段能力门、14 条真实资源 catalog
|
||||
和 OpenVDB 13 -> NanoVDB 32 desktop converter 已完成;server job、HTTP body 断点续传、OPFS、
|
||||
有界 Float32 WebGPU 双视口和 Main Volume 属性保存重开已有专项证据。GPU resident paging、
|
||||
联合重开、完整材质、大 bundle/OOM 和 golden 仍 `BLOCKED`。后续浏览器验收仅覆盖
|
||||
Chromium,不配置 Firefox/WebKit。
|
||||
|
||||
专项字段、验收命令和停止条件见 `docs/status/N-015.md`。
|
||||
|
||||
@@ -107,13 +113,15 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
1. N-016-A:Layer/Frame/Drawing/Stroke/Point/Attribute schema 与最大 layer/frame/point 预算。
|
||||
2. N-016-B:Main create/remove/reorder layer,insert/remove frame,draw/erase stroke transaction。
|
||||
3. N-016-C:material、onion skin、cyclic stroke、radius/opacity/color 和基础 modifier reader。
|
||||
4. N-016-D:Chromium 主线程/Offscreen 的 3D current-frame stroke preview 已完成;2D editor、
|
||||
stroke/point selection、timeline/dope integration、gizmo 和 worker restart 继续推进。
|
||||
4. N-016-D:Chromium 主线程/Offscreen 的 3D current-frame stroke preview、点 raycast、多选
|
||||
高亮、连续 drag preview、单次 gizmo Main commit、真实 drawing frame 的有界 Dope 导航和
|
||||
Worker 保存重开已完成;完整 2D canvas/marquee 与完整 timeline/dope 编辑继续推进。
|
||||
5. N-016-E:desktop drawing hash、像素 golden、save/reopen 和 GLB/USD loss report。
|
||||
|
||||
## N-017 Paint 与权重
|
||||
|
||||
1. N-017-A:Vertex Color/Weight/Texture Paint stroke schema、PBVH/UV hit 和 brush 预算。
|
||||
1. N-017-A:Vertex Color/Weight/Texture Paint stroke schema、UV hit、uniform-grid 候选查询、
|
||||
显式深度可见性门和 brush 预算;真实 PBVH/深度采样仍阻断。
|
||||
2. N-017-B:vertex color 与 vertex group Main transaction、normalize/limit/mirror 权重。
|
||||
3. N-017-C:packed/UDIM image tile transaction、色彩空间、dirty tile 和原子保存。
|
||||
4. N-017-D:armature deform golden、seam bleed、mask/face selection 和 undo/redo。
|
||||
@@ -123,21 +131,25 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
|
||||
1. N-018-A:Rigid Body、Soft Body、Cloth、Fluid、Dynamic Paint、Particle/Hair 能力清单。
|
||||
2. N-018-B:每 family 的 settings/dependency/cache manifest;目标对象和 collection 防循环。
|
||||
3. N-018-C:先消费 Blender desktop deterministic bake,frame seek 不允许复用错误帧。
|
||||
3. N-018-C:frame seek 不允许复用错误帧;浏览器自有 BTF1 已连接不可变 SceneIR preview、
|
||||
可取消顺序播放和跨 Storage Worker 重启精确读取;Blender desktop deterministic bake family
|
||||
decoder、正式 viewport UI 控制和 100 帧 golden 仍阻断。
|
||||
4. N-018-D:逐 family 探测 WASM solver;初始化、线程或内存门失败时保持 bake-only。
|
||||
5. N-018-E:浏览器 bake start/cancel/commit、故障恢复、长任务进度和 100 帧 golden。
|
||||
|
||||
## N-019 灯光与渲染
|
||||
|
||||
1. N-019-A:Camera、Light、World、View Transform、Exposure、Mist、Shadow 的完整 SceneIR。
|
||||
2. N-019-B:Three/WebGPU 映射和 capability report;材质/灯光变化使用增量更新。
|
||||
2. N-019-B:Three/WebGPU 映射和 capability report;共享 PBR 路径已应用有界 Kelvin 线性灯光
|
||||
颜色,材质/灯光变化使用增量更新。
|
||||
3. N-019-C:Eevee 有限特性矩阵、shadow map、transparent sorting、probe 和后处理。
|
||||
4. N-019-D:Cycles/Freestyle/硬件 denoise 使用服务端 Blender job 协议与结果 hash。
|
||||
5. N-019-E:desktop/Web 像素误差、色彩管理、设备丢失和 1M 三角形性能门。
|
||||
|
||||
## N-020 Compositor
|
||||
|
||||
1. N-020-A:Compositor GraphIR、socket/link、image/render-layer 资源和 cycle 校验。
|
||||
1. N-020-A:Compositor GraphIR、socket/link、image/render-layer 资源和 cycle 校验;真实 Main
|
||||
Exposure 与默认 Invert 参数链已进入 CPU executor,其他参数节点仍按 Unsupported 保留。
|
||||
2. N-020-B:Transform/Color/Alpha/Blur/Mix 等有限 node 的 CPU/WebGPU executor。
|
||||
3. N-020-C:viewer/composite output、frame cache、tile budget 和 worker cancellation。
|
||||
4. N-020-D:unsupported node 保留 metadata 并可提交服务端 Blender,不删图继续执行。
|
||||
@@ -146,7 +158,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
## N-021 Sequencer 与音频
|
||||
|
||||
1. N-021-A:Scene/Movie/Image/Sound/Effect/Meta strip schema、channel、range 和 proxy metadata。
|
||||
2. N-021-B:Main add/remove/move/trim/split、transition 和 modifier 白名单。
|
||||
2. N-021-B:Main add/remove/move/trim/split、transition 和 modifier 白名单;CROSS/GAMMA_CROSS
|
||||
已有真实依赖顺序、进度和静帧 source-frame 解析,尚未接媒体解码。
|
||||
3. N-021-C:WebCodecs/HTMLMedia capability probe、waveform/proxy 和精确 timeline seek。
|
||||
4. N-021-D:浏览器不支持的 codec/混音/编码提交服务端;资源路径必须沙箱化。
|
||||
5. N-021-E:A/V sync、frame hash、save/reopen、丢帧和损坏媒体测试。
|
||||
@@ -156,7 +169,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
1. N-022-A:MovieClip、Track/Marker、Plane Track、Mask Layer/Spline/Point schema。
|
||||
2. N-022-B:Main marker/mask edit、selection、keyframe 和 camera solve metadata。
|
||||
3. N-022-C:浏览器 tracking feature probe;完整 solve 可使用服务端 Blender。
|
||||
4. N-022-D:Clip/Mask editors、overlay 和 compositor/scene resource binding。
|
||||
4. N-022-D:Clip/Mask editors、overlay 和 compositor/scene resource binding;真实 Main Mask 的
|
||||
锁定过滤、稳定点 raycast 与 replace/add/toggle marquee 已验证。
|
||||
5. N-022-E:desktop solve/error golden、媒体故障、保存和重开。
|
||||
|
||||
## N-023 Asset、Library 与 IO
|
||||
@@ -165,7 +179,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
2. N-023-B:Append/Link/Library Override Main transaction、reload/relocate 和循环依赖门。
|
||||
3. N-023-C:glTF/OBJ/PLY/STL 首批本地 IO;USD/Alembic 按编译能力或服务端执行。
|
||||
4. N-023-D:每格式 import -> save -> reopen -> export -> desktop reimport 语义比较。
|
||||
5. N-023-E:zip bomb、路径穿越、外部 URI、许可证和大文件流式预算。
|
||||
5. N-023-E:zip bomb、路径穿越、外部 URI、许可证和大文件流式预算;archive 重复/前缀冲突、
|
||||
累计压缩预算和确定性 range plan 已落地,真实 ZIP 解码仍阻断。
|
||||
|
||||
## N-024 Editors 与工作流
|
||||
|
||||
@@ -175,6 +190,9 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
4. N-024-D:Blender-compatible keymap、可配置 shortcut、gizmo、drag preview/commit。
|
||||
5. N-024-E:desktop/mobile layout、键盘/触控/笔、无重叠截图和 accessibility 门。
|
||||
|
||||
当前有界切片还包括 workspace/editor/mode 作用域 keymap,以及可执行、上下文过滤的 F3
|
||||
operator search;这不等价于 Blender 全量 operator registry、context menu 或 editor writer。
|
||||
|
||||
## N-025 Scripting 与平台适配
|
||||
|
||||
1. N-025-A:默认拒绝任意 Python、Text autorun、driver expression 和 add-on 安装。
|
||||
@@ -183,6 +201,9 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
4. N-025-D:CUDA/Metal/HIP/OptiX、native window、file watcher 等返回平台能力报告。
|
||||
5. N-025-E:恶意脚本、依赖混淆、逃逸、重放和审计日志发布门。
|
||||
|
||||
当前拒绝审计已具备有界 SHA-256 链、严格 UTC 顺序、requestId replay 与篡改拒绝;
|
||||
无网络 sandbox 和持久化发布审计仍阻断。
|
||||
|
||||
## N-026 全域发布门
|
||||
|
||||
1. 生成 machine-readable parity manifest:每个 Blender family 为
|
||||
@@ -192,6 +213,11 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
||||
4. `.blend`/image/media/script fuzz、OPFS quota、OOM、设备丢失、网络中断和恢复验证。
|
||||
5. 许可证/source offer/SBOM、确定性包、文档状态与测试报告一致后才能发布。
|
||||
|
||||
当前证据为 13 条有效记录、15 个缺失,整体仍为 `BLOCKED`;新增 VDB 边界记录只证明
|
||||
转换/manifest/range 协议与显式阻断;已通过 1M geometry、
|
||||
600 帧 OPFS simulation cache、4K/8K texture、运行中断网和主线程 WebGL device loss,
|
||||
仍缺 10M geometry、长媒体、OOM 及 N-016 至 N-025 上游 family 的完整验收。
|
||||
|
||||
## 默认验收入口
|
||||
|
||||
新增任务必须把专项命令接入 `web/package.json`,并至少执行:
|
||||
|
||||
@@ -133,9 +133,9 @@ transmission material 数、shadow caster 数、WASM/GPU 内存压力和帧时
|
||||
|
||||
- PBR-007-A:done_current_scope;`render-assets.ts` 定义 schema v1、SHA-256、MIME/尺寸/字节预算和稳定错误码,App 从 `requestAsset` 生成载荷并同时写入 OPFS。
|
||||
- PBR-007-B:done_current_scope;Base Color/Emission 使用 sRGB,Normal/Data 使用 non-color/linear,主线程和 Worker 使用相同 `GPUTextureStore`。
|
||||
- PBR-007-C:in_progress;ImageBitmap 解码、旧 Texture dispose、状态回传已完成;mipmap、anisotropy、GPU context-loss 恢复和全局显存预算仍未开放。
|
||||
- PBR-007-C:in_progress;ImageBitmap 解码、旧 Texture dispose、状态回传和主线程 WebGL context-loss 恢复已完成;mipmap、anisotropy、Offscreen context-loss 恢复和全局显存预算仍未开放。
|
||||
- PBR-007-D:Normal Map 切线空间、UV Map 选择、wrap/filter 和缺失纹理 fallback。
|
||||
- PBR-007-E:in_progress;双后端同一载荷正例已覆盖,context-loss/Worker crash 后重新上传仍属于 PBR-011 故障注入门。
|
||||
- PBR-007-E:in_progress;双后端同一载荷正例和主线程真实 `WEBGL_lose_context` 恢复已覆盖,Offscreen context-loss/Worker crash 后重新上传仍属于 PBR-011 故障注入门。
|
||||
|
||||
### PBR-008 UDIM 与外部资源:in_progress(manifest/阻断已完成,多 tile 采样未声明)
|
||||
|
||||
@@ -169,7 +169,10 @@ transmission material 数、shadow caster 数、WASM/GPU 内存压力和帧时
|
||||
|
||||
- PBR-012-A:done_current_scope;`queryRenderCapability` 明确返回 `WEBGPU_RENDERER_UNAVAILABLE`,没有 WebGPU renderer bundle 时不伪装为 WebGPU。
|
||||
- PBR-012-B:done_current_scope(安全门);BLOOM/SSAO/SSR/TAA/DOF/MOTION_BLUR 未有真实 pass 时返回 `POSTPROCESS_PASS_UNAVAILABLE`,未声明假实现。
|
||||
- PBR-012-C:done_current_scope(安全门);Volume/SSS 返回独立 `VOLUME_SHADER_UNAVAILABLE`/`SUBSURFACE_SHADER_UNAVAILABLE`,任意 Shader node 返回 `SHADER_NODE_UNSUPPORTED`。
|
||||
- PBR-012-C:in_progress;Volume 已重新纳入 desktop/server OpenVDB -> NanoVDB + WebGPU
|
||||
路径,当前完成 manifest/material/range 安全门,真实 WGSL/材质/golden 前仍返回
|
||||
`VOLUME_SHADER_UNAVAILABLE`;SSS 与任意 Shader node 仍分别结构化阻断。详细任务见
|
||||
`docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md`。
|
||||
- PBR-012-D:浏览器 path tracer 若引入,作为独立 Render backend,不声称 Cycles 等价。
|
||||
|
||||
## 8. 当前验收
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Web Blender 项目现状与后续连续任务
|
||||
|
||||
更新时间:2026-08-12
|
||||
更新时间:2026-08-14
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
@@ -37,9 +37,10 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM
|
||||
| SceneIR 扩展 | tangent/split normal/seam/sharp/vertex group/attribute/UV,native WBG1、资源 Delta 和 mesh byte-range patch | 已完成当前数据契约 |
|
||||
| Sculpt 属性 | Mask/Face Set 的 SceneIR、WBG1 header v2、Delta、Main、undo 和保存重开 | 已完成属性子集;有界四笔刷 Main stroke 已开放,PBVH/完整求值未开放 |
|
||||
| Simulation cache | Blender 5.2/source blend/hash/frame manifest、OPFS 内容寻址、schema 6、重启逐帧复验 | 已完成缓存安全层;Simulation evaluator 未开放 |
|
||||
| 发布性能/故障门 | Chromium 118 条 E2E、1M geometry、600 帧 OPFS cache、4K/8K texture、运行中断网、主线程 WebGL context loss | 已完成已声明门;10M、长媒体、OOM 和 Offscreen device loss 仍缺 |
|
||||
| Shader Main | RGB/Value/Principled/Image Texture/Normal Map/Output 整图事务、资源门和保存重开 | 已完成六节点写回子集;任意图/编译仍阻断 |
|
||||
| NLA Main | 单对象 Action Clip 整栈写回、SceneIR 重开和 native frame 求值 | 已完成有限 Action Clip 子集 |
|
||||
| 非 Mesh 数据块 | Curve/Surface/Font/Metaball Main 权威写回与保存重开、真实 evaluated mesh、PointCloud/Curves/Hair 属性、WNM chunks、双 viewport、desktop golden 与 7 对象 GLB/USD round-trip | 有界本地切片 in_progress;VDB renderer、完整 topology editor/跨对象 history/range patch、外部字体导入、跨浏览器 OPFS 性能仍阻断 |
|
||||
| 非 Mesh 数据块 | Curve/Surface/Font/Metaball Main 权威写回与保存重开、真实 evaluated mesh、PointCloud/Curves/Hair 属性、WNM chunks、双 viewport、handle-local 连续 preview/单次 Main commit、desktop golden 与 7 对象 GLB/USD round-trip | N-015 整体 BLOCKED;VDB 资源 catalog、desktop OpenVDB->NanoVDB 与分块协议已完成,server/WebGPU/保存重开/golden 未完成 |
|
||||
|
||||
## 3. 部分完成或仍有边界的能力
|
||||
|
||||
@@ -52,7 +53,7 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM
|
||||
| `.blend` 保存 | 场景状态、modifier、Mesh Edit、材质/UV、动画/约束和对象层级命令均写 Main;历史、原子保存、快照和日志重放可恢复 | skin/shape-key decimate 保持结构化阻断 |
|
||||
| Blender UI 对标 | 默认区域、当前 Mesh Edit、raycast、多选、拖拽 gizmo、层级/材质/动画面板和核心快捷键已闭环 | Sculpt、Geometry Nodes 编辑器等未声明能力继续结构化阻断 |
|
||||
| 未声明能力扩展 | 四项协议/安全门均已细分;Sculpt 属性/有界 Main stroke、Simulation cache、六节点 Shader Main 和 Action Clip NLA Main 已有正例 | PBVH、GN lazy-function/Simulation 求值、其余 Shader Node、Web compiler、完整 NLA/UI/GLB 图映射仍 planned/结构化阻断 |
|
||||
| 非 Mesh 数据块 | 1M 点 reader budget、ID_MB/PT/VO/VF 注册、Main control-point/topology/body/element/Font style link commands、一维多 spline 与批量 handle/cyclic、二维 Surface topology transaction、undo/save/reopen、真实 evaluated mesh、WNM SHA chunks、handle raycast/gizmo、7 对象 GLB/USD 和 Chromium quota | 多 handle 选择拖拽/gizmo UI、跨对象 history/range delta、新外部字体导入、VDB renderer/voxel upload 仍 planned/阻断 |
|
||||
| 非 Mesh 数据块 | 1M 点 reader budget、ID_MB/PT/VO/VF 注册、Main control-point/topology/body/element/Font style link commands、一维多 spline 与批量 handle/cyclic、二维 Surface topology transaction、undo/save/reopen、真实 evaluated mesh、WNM SHA chunks、handle raycast/handle-local 连续 preview/单次 commit、7 对象 GLB/USD、Chromium quota、VDB/NanoVDB 协议与 desktop converter | 新外部字体导入及 VDB server job、WebGPU renderer、材质、保存重开和 golden 仍 planned/阻断 |
|
||||
| 大场景 | Worker 二进制解码、per-mesh/range transferable、linked Mesh 实例化、LOD/OPFS cache、frustum culling、能力门 OffscreenCanvas Worker、100k/1M 内存门 | 超出当前 WBG1 的网络式渐进流送仍属于后续性能扩展 |
|
||||
| Decimate 全对标 | Collapse 的当前网格集合已覆盖,错误路径结构化 | Un-Subdivide、Dissolve、所有 delimiter/权重/对称组合及大模型性能矩阵未完成 |
|
||||
|
||||
@@ -181,9 +182,11 @@ Blender 5.2 全域功能矩阵、浏览器/服务端边界和 N-015 至 N-026
|
||||
`NonMeshDataIR`、native SDNA reader、Main 权威 Curve/Surface/Font/Metaball 写回、bounded Poly Curve create/delete、Poly/Bezier conversion、非 Mesh 数据块 rename、Curve 有界 topology/Font geometry、undo/redo、
|
||||
save/reopen、真实 legacy evaluated mesh、PointCloud/Curves 属性和 WNM 二进制分块已经接通。
|
||||
主线程与 OffscreenCanvas 消费同一 chunks;GLB 只接受真实 evaluated mesh,USD 提供
|
||||
`UsdGeomMesh`、`UsdGeomPoints`、`UsdGeomBasisCurves` 和 `OpenVDBAsset` 的机器可读映射/损失。
|
||||
VDB renderer、完整 Curve handle/cyclic topology editor、二维 Surface mutation、跨对象 history/range patch、新字体导入和跨浏览器 OPFS quota 仍按
|
||||
`docs/status/N-015.md` 的阻断项推进。
|
||||
`UsdGeomMesh`、`UsdGeomPoints` 和 `UsdGeomBasisCurves` 的机器可读映射/损失。
|
||||
VDB 已改为 desktop/server OpenVDB -> NanoVDB、浏览器分块读取 + WebGPU。真实资源 catalog、
|
||||
desktop converter 和协议/range 基础件已接通,但 server job、renderer、材质、保存重开和 golden 仍阻断;handle-local 专用 gizmo
|
||||
已接通,新字体导入等剩余项按 `docs/status/N-015.md` 和
|
||||
`docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md` 推进。
|
||||
|
||||
### PBR-001 至 PBR-012 模型物理渲染(核心切片 done_current_scope,资产/高级渲染安全门 in_progress)
|
||||
|
||||
@@ -192,10 +195,19 @@ VDB renderer、完整 Curve handle/cyclic topology editor、二维 Surface mutat
|
||||
现已接通 SHA-256 GPU 纹理载荷、颜色空间、尺寸预算、ImageBitmap 解码和双后端状态;PBR-008
|
||||
现已接通 UDIM manifest/缺失资源/多 tile 结构化阻断;PBR-009 已支持有限 packed raster
|
||||
equirectangular PMREM/IBL 生命周期;PBR-012 已提供 WebGPU、任意 Shader、Volume/SSS 和
|
||||
高级后处理的能力查询门。HDR/EXR、多 tile 采样、context-loss 恢复、真实 WebGPU renderer、
|
||||
高级后处理的能力查询门。主线程 WebGL context-loss 已有显式恢复与真实 Chromium 门;
|
||||
HDR/EXR、多 tile 采样、Offscreen context-loss、真实 WebGPU renderer、
|
||||
高级 pass 和未声明 Shader 家族仍不得声称完成。架构映射、细分状态、停止条件和验收命令统一维护在
|
||||
`docs/PBR_RENDERING_IMPLEMENTATION_PLAN.md`。
|
||||
|
||||
### N-026 当前发布实况
|
||||
|
||||
`docs/status/release-evidence.json` 已生成 13 条成功 command/output/artifact-hash 记录;其中
|
||||
VDB 边界记录没有设置发布 true 字段,只证明 Phase 0 协议门。
|
||||
`test:release-evidence` 计算为 15 个缺失并保持 `BLOCKED`。其中 3 个是实际证据字段
|
||||
`performance.geometry10M`、`performance.longMedia`、`faults.oom`,其余 12 个来自仍为
|
||||
`BLOCKED` 的 N-016 至 N-026 family。浏览器范围仅为 Chromium。
|
||||
|
||||
## 6. 当前验收命令
|
||||
|
||||
```bash
|
||||
@@ -223,9 +235,16 @@ npm --prefix web run test:topology-collapse
|
||||
npm --prefix web run test:capability-gates
|
||||
npm --prefix web run test:simulation-cache
|
||||
npm --prefix web run test:release-performance
|
||||
npm --prefix web run test:simulation-cache-performance
|
||||
npm --prefix web run test:network-interruption
|
||||
npm --prefix web run test:device-loss
|
||||
npm --prefix web run test:texture-4k-performance
|
||||
npm --prefix web run test:texture-8k-performance
|
||||
npm --prefix web run test:malicious-blends
|
||||
npm --prefix web run test:browser
|
||||
npm --prefix web run release:offline
|
||||
npm --prefix web run release:evidence
|
||||
npm --prefix web run test:release-evidence
|
||||
WEB_TEST_PORT=5193 npm --prefix web run test:e2e # 5173 被占用时指定可用端口
|
||||
WEB_TEST_PORT=5193 npm --prefix web run test:e2e -- -g "N-015|non-mesh"
|
||||
```
|
||||
|
||||
242
docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md
Normal file
242
docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md
Normal 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 preset,OpenVDB 只在该目标开启;固定 Blender 5.2、
|
||||
OpenVDB/NanoVDB 版本并生成可执行文件 hash。状态:`done_current_scope`;desktop target 实际
|
||||
链接 OpenVDB 13.0/TBB 并生成 NanoVDB 32.9,浏览器仍保持 `WITH_OPENVDB=OFF`。
|
||||
- `VDB-012`:实现 grid inventory 预扫、allowlist、active voxel/内存/输出预算和取消,调用官方
|
||||
OpenVDB/NanoVDB API 写出确定性 `.nvdb`。状态:`done_current_scope`;inventory、allowlist、源/输出/
|
||||
grid/active voxel 预算、LOSSLESS/FP16、report、确定性、cancel-file、timeout 和失败原子清理已验证。
|
||||
- `VDB-013`:实现服务端 job API:content hash 幂等、上传/转换/下载、进度、超时、取消、日志、
|
||||
不可信输入隔离和输出签名。状态:`done_current_scope`;当前以 bubblewrap、只读 root、资源限制、
|
||||
HMAC 构件签名和原子输出为受控本地服务基线。
|
||||
- `VDB-014`:桌面与服务端对同一 fixture 的 manifest/bundle hash 相同;版本变化必须导致缓存
|
||||
key 变化。状态:`done_current_scope`;语义相同的 VDB、官方 sphere 重转及桌面/隔离服务端
|
||||
转换均得到相同 NanoVDB hash。
|
||||
|
||||
### Phase 2:存储与流送
|
||||
|
||||
- `VDB-020`:实现项目 asset manifest 和 source/bundle binding,支持 source hash 变化失效。
|
||||
状态:`done_current_scope`。
|
||||
- `VDB-021`:实现 OPFS range source、staging/journal/atomic commit、重启发现和 LRU page cache。
|
||||
状态:`done_current_scope`;已验证真实 bundle、取消清理、Worker 重开、tamper gate 和 bundle LRU。
|
||||
- `VDB-022`:实现 HTTP Range 重试、ETag/If-Range、断点续传、重复/乱序/短响应拒绝。
|
||||
状态:`done_current_scope`;临时状态/网络异常重试、稳定 ETag、response-body 精确偏移续传、
|
||||
ETag 改变、错位 `Content-Range`、短响应和逐块 hash 均有 Chromium 故障门。
|
||||
- `VDB-023`:增加网络中断、Worker terminate、quota、tamper、manifest rollback 测试。
|
||||
状态:`done_current_scope`;网络/body 中断、Worker terminate、真实 OPFS quota、chunk tamper、
|
||||
manifest rollback、incomplete staging recovery 和旧 bundle 保持均已验证。
|
||||
- `VDB-024`:在 512 MiB CPU、1 GiB bundle、512 MiB GPU 上限下验证峰值;不得整包复制到
|
||||
JS heap。`planned`。
|
||||
|
||||
### Phase 3:WebGPU NanoVDB
|
||||
|
||||
- `VDB-030`:加入 Chromium WebGPU capability probe、adapter/device limits 和 device loss 状态机。
|
||||
状态:`done_current_scope`;真实 adapter/device limit probe、device loss、device 重建和重传后
|
||||
固定点 sample 一致已完成;生产视口资源重建仍归 `VDB-034`。
|
||||
- `VDB-031`:实现 NanoVDB Float/Vec leaf traversal WGSL 和 CPU reference sampler;用固定 index
|
||||
点比较值与 active/inactive 状态。状态:`in_progress`;Float32 CPU/WGSL 与 native sample 已逐点
|
||||
一致,Float16/Vec 仍保持阻断。
|
||||
- `VDB-032`:实现 GPU page allocator、storage buffer upload、indirection、resident LRU 和
|
||||
dispose;每页上传前已通过 chunk hash。`BLOCKED`。
|
||||
- `VDB-033`:实现对象 bounds raycast、front-to-back ray marching、步长/early exit 和深度合成。
|
||||
状态:`in_progress`;有界正交 bounds、front-to-back integration、early exit 和确定性 96x96
|
||||
Chromium 图像已完成,生产相机深度合成待接入。
|
||||
- `VDB-034`:接入主线程/Offscreen renderer 的相同 volume scene delta;device loss 后重建已
|
||||
验证页面。`BLOCKED`。
|
||||
|
||||
### Phase 4:材质、Main 与导出
|
||||
|
||||
- `VDB-040`:读取 Blender Volume/Principled Volume 节点并生成显式 material mapping/loss report。
|
||||
状态:`in_progress`;density、常量 color/emission、anisotropy、nearest/linear 已映射,
|
||||
color/temperature/emission grid 和 velocity 明确生成 loss。
|
||||
- `VDB-041`:density/temperature/color/emission/anisotropy 的 Main writer、undo/redo 和原子事务。
|
||||
状态:`in_progress`;Volume source/display/interpolation/step/velocity Main 写回、undo/redo、保存重开
|
||||
已完成,Principled Volume 完整节点事务仍阻断。
|
||||
- `VDB-042`:保存 `.blend` 与 Web asset binding,清空 Worker 后重开并重新流送。`BLOCKED`。
|
||||
状态:`in_progress`;Main Volume 保存重开与 OPFS asset binding/Worker 重开分别通过,尚待生产视口
|
||||
联合重开闭环。
|
||||
- `VDB-043`:GLB 明确报告 Volume 无核心映射;USD 仅在 desktop USD/OpenVDB 路径真实可用时
|
||||
写入 field asset,不把 bounds proxy 当体积导出。`BLOCKED`。
|
||||
|
||||
### Phase 5:真实验收与发布
|
||||
|
||||
- `VDB-050`:desktop OpenVDB -> NanoVDB grid count/name/type/transform/bounds/value sample golden。
|
||||
- `VDB-051`:Blender 5.2 与 Chromium WebGPU 的至少 3 个视角像素 golden,分别比较 alpha、
|
||||
transmittance、color,记录容差与色彩空间。
|
||||
- `VDB-052`:64 MiB/512 MiB/1 GiB sparse bundle 的首帧、渐进清晰、峰值 CPU/GPU 和取消门。
|
||||
- `VDB-053`:损坏 magic/version/tree offset/hash、zip bomb 等价超预算、NaN transform、设备丢失、
|
||||
网络中断和 OOM 门。
|
||||
- `VDB-054`:保存重开、资源缺失、源变更重转、旧 schema migration、离线 OPFS 重开。
|
||||
- `VDB-055`:N-026 证据记录包含真实转换器、资源、Chromium WebGPU、desktop golden 和构件 hash。
|
||||
|
||||
Phase 5 全部任务当前为 `BLOCKED`,不能由 Phase 0 的协议 fixture 代替。
|
||||
|
||||
## 7. 验收命令与停止条件
|
||||
|
||||
当前可运行门:
|
||||
|
||||
```bash
|
||||
npm --prefix web run typecheck
|
||||
npm --prefix web run test:vdb
|
||||
npm --prefix web run test:vdb-availability
|
||||
npm --prefix web run test:vdb-native
|
||||
npm --prefix web run test:vdb-server
|
||||
npm --prefix web run test:vdb-opfs
|
||||
npm --prefix web run test:vdb-webgpu
|
||||
npm --prefix web run test:vdb-viewport
|
||||
npm --prefix web run test:vdb-faults
|
||||
```
|
||||
|
||||
阶段停止条件:
|
||||
|
||||
- 新增资源若无许可证/来源/hash:不得进入 VDB catalog 或 golden。
|
||||
- desktop converter 已构建;server job API 未实现时 `SERVER_CONVERSION` 保持 `BLOCKED`。
|
||||
- 无 CPU reference sample 对照:WGSL traversal 不得进入 `READY`。
|
||||
- 仅能显示 box/point proxy:体渲染仍为 `BLOCKED`。
|
||||
- 未验证 material mapping、Main 保存重开或 desktop/Chromium golden:N-015 与 N-019 仍为
|
||||
`BLOCKED`。
|
||||
- Chromium 是当前唯一浏览器门;不配置 Firefox/WebKit,也不把未测浏览器写成兼容。
|
||||
@@ -1,15 +1,21 @@
|
||||
# N-015 非 Mesh 几何数据块
|
||||
|
||||
状态:`in_progress`(本地有界切片已落地,VDB renderer/完整交互仍阻断)
|
||||
状态:`BLOCKED / in_progress`(VDB core 已覆盖 server、OPFS、Float32 WebGPU 与 Main 属性重开;
|
||||
生产视口、高级材质、分页和发布 golden 仍阻断)
|
||||
|
||||
更新时间:2026-08-12
|
||||
更新时间:2026-08-14
|
||||
|
||||
## 当前声明
|
||||
|
||||
当前声明覆盖 Blender 5.2 `.blend` 中有限非 Mesh 数据的稳定识别、Main 权威写回、undo/redo、
|
||||
保存重开、真实 evaluated mesh 报告和 WNM 二进制分块。Curve cyclic/handle 已有有界
|
||||
属性读写、多目标选择和单事务平移提交,未声明完整 topology editor、连续 gizmo preview、
|
||||
VDB 体渲染或完整 Volume USD loss fixture。
|
||||
属性读写、多目标选择、连续临时预览、handle-local gizmo 和单事务平移提交;未声明完整
|
||||
topology editor。
|
||||
Volume 采用“桌面/服务端 OpenVDB 转 NanoVDB,浏览器分块读取并以 WebGPU 渲染”的固定架构。
|
||||
浏览器不直接解码 OpenVDB;当前已完成真实资源 catalog、独立 OpenVDB 13 -> NanoVDB 32 desktop
|
||||
converter、受控 server job、转换/清单/分块协议、OPFS 绑定/重开、Float32 CPU/WGSL 树遍历、
|
||||
有界体积积分和 Volume Main 属性保存重开。主线程/Offscreen 生产视口、高级 grid 材质、GPU
|
||||
分页和发布 golden 尚未完成,因此 N-015 整体保持 `BLOCKED`。
|
||||
|
||||
| 数据族 | Reader | Web 预览 | 当前门 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -19,7 +25,7 @@ VDB 体渲染或完整 Volume USD loss fixture。
|
||||
| Metaball | 元素类型、位置、半径、尺度 | bounded sphere proxy;depsgraph implicit mesh | `READY` |
|
||||
| PointCloud | position/radius/POINT 属性 | WNM 分块 Points | `READY`(空数据为 `summary-only`) |
|
||||
| Curves/Hair | position/radius/curve/POINT 属性 | WNM 分块 Points/curve line | `READY`(缺数据阻断) |
|
||||
| Volume | 项目内 OpenVDB path/grid metadata | 无 renderer;可取消 decoder API | `NON_MESH_RESOURCE_MISSING` 或预算阻断 |
|
||||
| Volume | path/grid metadata + VDB/NanoVDB 转换契约 | WebGPU core 可渲染真实 Float32 density;生产视口未接入 | `BLOCKED` |
|
||||
|
||||
## 已实现任务
|
||||
|
||||
@@ -32,7 +38,8 @@ VDB 体渲染或完整 Volume USD loss fixture。
|
||||
5. `NonMeshGeometryChunk` WNM schema 1 提供 65,536 点分块、SHA-256、属性 domain/storage、
|
||||
256 MB 场景预算和 1M 点性能门;Worker/主线程/Offscreen 均传输并消费分块。
|
||||
6. native reader 读取 PointCloud/Curves/Hair 的 position、radius、curve offset 和属性值,
|
||||
不以摘要计数伪造点;Volume 提供项目路径、OpenVDB grid metadata 和可取消 decoder。
|
||||
不以摘要计数伪造点;Volume 保留项目路径和 OpenVDB grid metadata,raw `.vdb` 必须提交桌面/
|
||||
服务端转换,浏览器不接 OpenVDB decoder。
|
||||
7. depsgraph 对 Curve/Surface/Font/Metaball 通过 `BKE_mesh_new_from_object` 输出真实位置、法线、
|
||||
边/三角索引、UV、材质槽和 source face/edge mapping,并在 1M/2M 拓扑预算处结构化阻断。
|
||||
Web ID registry 已注册 `ID_VF` 并在加载时恢复 Blender 未写入 `.blend` 的 `<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 场景中做绝对坐标 preview,pointer cancel 恢复且 pointer up 只提交一次 Main。
|
||||
handle-local gizmo 以所选 handle 质心为原点、控制点到 handle 的对齐平均切向为 X 轴,
|
||||
构造稳定正交 Y/Z 轴并在两个 renderer 中投影。单用户、undo/redo、save/reopen 由
|
||||
`check-nonmesh-roundtrip.mjs` 验证。
|
||||
9. 主线程与 OffscreenCanvas 对 PointCloud/Curve 控制点和 Bezier handle 代理提供 bounded raycast,并对 Mesh 的
|
||||
VERT/EDGE/FACE 使用一致的三角顶点、边索引解析,回传既有 selection callback。selection
|
||||
history schema 2 覆盖跨对象、多 data block、多 element kind、revision/stale-hit、undo/redo
|
||||
和有界 selected/unselected range patch,并兼容 schema 1。Bezier 左/右 handle 以全局控制点
|
||||
映射独立拾取;多 handle 选择、高亮和有界平移经精确 Main 命令写回并通过 undo/redo、
|
||||
save/reopen。完整连续 gizmo preview 仍未声明。
|
||||
save/reopen。连续 preview、局部轴投影和局部轴 Main delta 已接通。
|
||||
10. `queryNonMeshCapability(dataId)` 返回 N-015 `READY/BLOCKED` 与机器可读错误码;GLB 对未求值
|
||||
非 Mesh 拒绝导出。Font/Metaball/二维 NURBS Surface 的 depsgraph 三角网格与 Curve 的
|
||||
evaluated edge line primitive 已通过 GLB 导出和 Blender 5.2 桌面回导几何校验;零三角且零边的结果仍返回
|
||||
@@ -62,31 +71,52 @@ VDB 体渲染或完整 Volume USD loss fixture。
|
||||
已通过 USD-enabled Blender 5.2.0 LTS desktop reimport。PointCloud 使用 GLB `POINTS`/USD
|
||||
`UsdGeomPoints`,Curves/Hair 使用 GLB `LINES`/USD `UsdGeomBasisCurves`;radius 映射 USD
|
||||
widths,typed WNM 属性映射 primvars,GLB 属性损失结构化报告,7 对象 desktop reimport 已通过。
|
||||
Volume loss fixture 仍由 VDB 资源/renderer 阻断。
|
||||
Volume 不生成 proxy 导出;GLB/USD 映射与 loss fixture 重新列入阻断切片。
|
||||
11. Blender 5.2 fixture 已包含真实 PointCloud/Curves 属性;Chromium 主线程/Offscreen、真实
|
||||
depsgraph、WNM 1M 性能测试均接入。
|
||||
12. `CurveGizmoDragIR` 为 PREVIEW/COMMIT 请求绑定 data ID、base revision、轴向 delta 和最多
|
||||
256 个去重 handle identity;现有 UI gizmo 在单次 pointer commit 时先执行 stale revision、
|
||||
重复 handle、有限坐标和预算校验,再合并为一次 `setCurveTopology` Main 事务。连续 preview
|
||||
仍未开放。
|
||||
13. `test:vdb-availability` 确认当前 WASM build 为 `WITH_OPENVDB=OFF`、
|
||||
`WITH_NANOVDB=ON` metadata-only,项目和本机资源库没有可用 `.vdb`。因此真实 decoder、
|
||||
renderer 和 Volume loss fixture 继续 `BLOCKED`。
|
||||
12. `CurveGizmoDragIR` 为 PREVIEW/COMMIT 请求绑定 data ID、base revision、局部 axis vector、
|
||||
轴向 delta 和最多 256 个去重 handle identity;UI gizmo 在单次 pointer commit 时先执行 stale revision、
|
||||
重复 handle、有限坐标和预算校验;pointer move 仅更新 renderer 临时坐标,pointer cancel
|
||||
恢复,pointer up 再合并为一次 `setCurveTopology` Main 事务。分块控制点会先按 point offset
|
||||
重组为有界 `Float32Array`,因此内联和二进制 Curve 使用同一局部坐标契约。
|
||||
13. 当前 WASM build 保持 `WITH_OPENVDB=OFF`。`VDBConversionRequestIR` 记录源 hash、grid
|
||||
allowlist、量化、chunk size、desktop/server converter identity;`NanoVDBBundleManifestIR`
|
||||
校验 grid type/class/semantic、transform/bounds、材质映射、连续 32-byte 对齐 range、逐块与
|
||||
整包 SHA-256、CPU/GPU 预算。raw VDB 浏览器门固定返回 `VDB_CONVERSION_REQUIRED`。
|
||||
14. 浏览器 `streamNanoVDBChunks` 已按 range 串行读取、逐块复验和报告进度;HTTP adapter 严格要求
|
||||
`206 Content-Range`,对临时状态/网络异常有界重试,并以稳定 ETag/`If-Range` 从中断 body 的
|
||||
精确偏移续传;ETag 改变、错位 range 和短响应均阻断。协议 fixture 不冒充 native decoder。
|
||||
15. 独立 `tools/vdb` native target 已实际链接 OpenVDB 13.0/TBB,调用 NanoVDB 32.9 adapter
|
||||
转换真实多 grid fog、level set、大 bounds 稀疏体和 CC-BY-4.0 官方 sphere;转换 report 记录
|
||||
segment/payload range、type/class、transform、bounds 和 active voxel。资源库 14 条记录均绑定
|
||||
license/origin/byte length/SHA-256;截断 VDB 被真实 reader 拒绝,语义相同但 UUID 不同的源得到
|
||||
相同 NanoVDB bundle hash。
|
||||
16. 受控 server job 已覆盖 content-hash 幂等、bubblewrap/只读 root/资源限制、进度、超时、取消、
|
||||
原子输出和 HMAC 构件签名;相同输入的 desktop/server bundle hash 一致。
|
||||
17. OPFS 已覆盖逐块复验、staging 原子提交、内容去重、取消/残留恢复、source/converter/shader
|
||||
binding 失效、Worker 重开和 bundle LRU;真实 15 MiB NanoVDB bundle 已通过重开复验。读取时
|
||||
再验 chunk hash,chunk tamper、有效结构的 manifest rollback、真实 quota 和旧 bundle 保持均通过。
|
||||
18. Float32 NanoVDB 已有 CPU reference 与 WGSL root/internal/leaf 遍历,native/CPU/GPU 固定点值和
|
||||
active 状态一致;Chromium WebGPU 有界积分输出确定性 96x96 image hash。
|
||||
19. Volume source/display/interpolation/step/velocity 属性已写入 Blender Main,并通过 undo/redo、
|
||||
save/reopen;它不等于 Principled Volume 完整节点语义。
|
||||
|
||||
## 后续分解
|
||||
|
||||
1. N-015-B3:接入真实 OpenVDB decoder 与体素纹理/射线步进;在此之前保持 Volume renderer 阻断。
|
||||
2. N-015-C1:连续多 handle 拖拽 preview 与专用 gizmo UI;多 handle 选择、高亮、单事务平移、
|
||||
1. N-015-C1:按 handle 本地原点/方向放置的专用 gizmo UI、连续多 handle 拖拽 preview、
|
||||
多 handle 选择、高亮、局部轴单事务平移、
|
||||
多 spline 创建/删除/重排、批量 handle/cyclic editor transaction 和二维 Surface U/V
|
||||
topology/order/rational weight 网格替换已完成。
|
||||
3. N-015-C2:任意外部路径的新字体导入与资源沙箱;已有/packed VFont 的四 style link Main
|
||||
topology/order/rational weight 网格替换已完成;完整 topology editor 不在本切片声明内。
|
||||
2. N-015-C2:任意外部路径的新字体导入与资源沙箱;已有/packed VFont 的四 style link Main
|
||||
写回、字符级样式、kern/material 与 textbox 已完成并通过 PFB desktop/WASM golden。
|
||||
4. N-015-D1:跨对象 selection history、多目标 element identity、range patch、左右 handle
|
||||
raycast 和多 handle Main 写回已完成;连续拖拽 preview 归入 C1 阻断。
|
||||
5. N-015-D2:evaluated preview 与源控制笼分层显示、WebGPU 等价。
|
||||
6. N-015-E1:补齐 Volume 的完整 loss fixture;PointCloud/Curves/Hair、Curve line bake、真实
|
||||
4x4 NURBS Surface mesh、desktop geometry golden、7 对象 GLB 与 USDA desktop round-trip 已通过。
|
||||
7. N-015-E2:100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quota;Chromium
|
||||
3. N-015-D1:跨对象 selection history、多目标 element identity、range patch、左右 handle
|
||||
raycast、多 handle preview、handle-local gizmo 与单次 Main 写回已完成。
|
||||
4. N-015-D2:evaluated preview 与源控制笼分层显示、WebGPU 等价。
|
||||
5. N-015-B3/D2/E1(Volume):真实资源、desktop/server converter、可续传 HTTP range、OPFS、
|
||||
有界网络/Worker/quota/tamper/device-loss 故障门、Float32 WGSL core 和 Main Volume 属性重开已完成;
|
||||
继续执行生产 GPU paging、Principled Volume grid 语义、GLB/USD loss、大 bundle/OOM 和
|
||||
desktop/Chromium golden,未完成项保持阻断。
|
||||
6. N-015-E2:100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quota;Chromium
|
||||
64 KiB 真实 quota、失败后旧 revision 保持、Worker restart 恢复已通过。后续浏览器验收
|
||||
仅以 Chromium 为基线,不配置 Firefox/WebKit。
|
||||
|
||||
@@ -95,7 +125,9 @@ VDB 体渲染或完整 Volume USD loss fixture。
|
||||
- `summary-only` 不得进入 `READY`。
|
||||
- legacy 控制折线不得标记为 evaluated/tessellated curve。
|
||||
- Metaball sphere proxy 不得用于导出或保存为求值曲面。
|
||||
- Volume decoder/renderer、Hair 属性 buffer 缺失时不得生成占位几何。
|
||||
- Volume/VDB 不得生成占位几何;协议 fixture 不得作为真实 decoder/renderer 证据。
|
||||
- 浏览器构建不得通过开启 `WITH_OPENVDB` 绕过 desktop/server 转换边界。
|
||||
- 生产视口、GPU paging、完整材质、故障门和发布 golden 未齐前,VDB 与 N-015 保持 `BLOCKED`。
|
||||
- Metaball sphere proxy 不得进入 GLB;GLB 只能消费 depsgraph evaluated mesh。
|
||||
- 任意新增 Main authoring 命令在 save/reopen 与 undo/redo 完成前不得开放 UI。
|
||||
|
||||
@@ -117,8 +149,9 @@ BLENDER_BIN=/path/to/usd-enabled/blender \
|
||||
npm --prefix web run test:nonmesh-usd-blender-roundtrip
|
||||
npm --prefix web run test:nonmesh-binary
|
||||
npm --prefix web run test:nonmesh-interaction
|
||||
npm --prefix web run test:vdb-availability
|
||||
npm --prefix web run test:vdb
|
||||
npm --prefix web run test:vdb-availability
|
||||
npm --prefix web run test:vdb-native
|
||||
WEB_TEST_PORT=5349 npm --prefix web run test:e2e -- -g "real OPFS quota|reports quota exhaustion"
|
||||
WEB_TEST_PORT=5201 npm --prefix web run test:e2e -- -g "N-015|non-mesh"
|
||||
```
|
||||
|
||||
@@ -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 selection;Properties 面板已连接真实 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 五级身份 raycast;Edit Mode
|
||||
可高亮、Shift/Ctrl 多选同一 drawing 的点,并由轴向 gizmo 合并为一个 revision-bound
|
||||
`setGreasePencilStrokes` Main transaction。跨 drawing 追加会重置选择,避免错误混写。
|
||||
9. N-016-D(连续预览部分):轴向 gizmo 的 pointer move 从当前 SceneIR 绝对计算选中点坐标,
|
||||
同时更新真实 stroke 线和隐藏 point raycast proxy;pointer cancel/commit 均恢复预览基线,
|
||||
pointer up 才提交一次 Main。主线程与 OffscreenCanvas 重复回归一致。
|
||||
10. N-016-D(时间线部分):现有 Dope Sheet 在活动 Grease Pencil 对象上显示真实 layer drawing
|
||||
frame 并通过既有 `setFrame` Main 命令导航;不提供帧拖拽、重排或插值伪语义。
|
||||
11. N-016-D(重启部分):真实 Grease Pencil fixture 经首个 WebEngine Worker 点编辑和 `.blend`
|
||||
保存后,终止 Worker 并由全新 Worker 重开;data/layer/drawing identity、位置、radius、opacity
|
||||
均重新解析验证。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-016-C:material datablock 事务、onion fade/range 完整语义和完整 Grease Pencil modifier reader。
|
||||
- N-016-D:2D 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-E:desktop drawing hash、Chromium 像素 golden、GLB/USD loss report 和 OPFS。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -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-A:PBVH 加速结构、遮挡/背面选择和 brush falloff 对桌面语义的对应;基础 Mesh/UV
|
||||
raycast hit 已完成。
|
||||
- N-017-A:真实 PBVH、由 viewport 深度缓冲生成可见 vertex 集合,以及 brush falloff 桌面
|
||||
golden;基础 Mesh/UV hit、uniform-grid 候选查询和可见性硬门已完成。
|
||||
- N-017-B:limit/clean、已验证拓扑映射上的 mirror、桌面 brush/falloff 对照。
|
||||
- N-017-C:packed/UDIM tile transaction、色彩空间、dirty tile 和原子保存。
|
||||
- N-017-D/E:armature golden、seam bleed、mask/selection、GPU dispose、quota、坏图和 UI。
|
||||
- N-017-C:Blender packed/UDIM tile Main transaction、dirty tile、色彩转换和原子保存;当前
|
||||
只有内容哈希绑定的浏览器内存 patch 边界。
|
||||
- N-017-D/E:armature golden、seam bleed、face mask、GPU dispose、quota、坏图和桌面 UI 对照;
|
||||
vertex selection 与数值 mask 门已完成,不等同于完整 Paint 面/纹理遮罩系统。
|
||||
|
||||
## 验收
|
||||
|
||||
|
||||
@@ -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-C:desktop bake payload 的 family decoder、depsgraph frame playback 和 100 帧 golden。
|
||||
- N-018-C:desktop bake payload 的逐 family decoder、正式 viewport UI 播放控制和 100 帧
|
||||
golden;浏览器自有 transform frame decoder/SceneIR 播放会话已完成但不替代 Blender bake。
|
||||
- N-018-D:逐 family WASM solver 初始化、线程、内存和确定性验证。
|
||||
- N-018-E:bake start/cancel/commit、服务端 job、故障恢复、进度与 UI。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -13,20 +13,29 @@ Three exposure/shadow 映射已落地;Scene 颜色管理 writer 和渲染等
|
||||
3. N-019-B(部分):主线程和 OffscreenCanvas renderer 都优先使用 Scene color-management
|
||||
exposure,World exposure 仅作旧数据回退;Light exposure 转换为强度,`castsShadow:false`
|
||||
不再被 Three 强制打开。
|
||||
4. 白平衡读取有完整性门:Main Light/World 重写若导致 Blender 5.2 tint 序列化为异常近零值,
|
||||
4. N-019-B(部分):World/Scene Main delta 保留版本化集合,主线程与 Offscreen renderer
|
||||
对环境颜色、曝光和 scene render settings 变更触发确定性的 renderer rebuild;未知 delta
|
||||
版本和重复 ID 继续拒绝。
|
||||
5. 白平衡读取有完整性门:Main Light/World 重写若导致 Blender 5.2 tint 序列化为异常近零值,
|
||||
不暴露垃圾数值而返回 `whiteBalanceStatus: BLOCKED`。
|
||||
5. Camera 严格白名单写回覆盖 Perspective/Orthographic、lens/sensor/sensor fit、shift、clip、
|
||||
6. Camera 严格白名单写回覆盖 Perspective/Orthographic、lens/sensor/sensor fit、shift、clip、
|
||||
ortho scale 和 DOF enable/focus distance/f-stop/blades/rotation/ratio;near/far 与范围在修改前
|
||||
拒绝,已通过 undo/redo 和 save/reopen。
|
||||
7. N-019-B(色温部分):`useTemperature:true` 时,共享 PBR 适配器把 800–20000 K 的有界
|
||||
黑体近似归一到 6500 K 中性白并转换为线性 RGB,再乘入 Light color;关闭色温时原始颜色
|
||||
保持不变。主线程与 Offscreen Worker 共用该实现,数值门和原生 temperature 保存重开通过。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-019-A:Scene color-management writer;直接 DNA 写入会破坏 white balance,必须改接
|
||||
Blender/RNA 颜色管理 API 后才能开放。Camera writer 已完成。
|
||||
- N-019-B/C:AgX/Standard/Raw 的视觉等价、temperature 颜色、Area spread、Mist、DOF、
|
||||
- N-019-B/C:AgX/Standard/Raw 的视觉等价、Area spread、Mist、DOF、
|
||||
transparent sorting、probe 和高级 shadow 参数。
|
||||
- N-019-D:Cycles/Freestyle/denoise 服务端 job 协议与结果 hash。
|
||||
- N-019-E:desktop/Chromium 像素 golden、设备丢失和 1M triangles。
|
||||
- N-019-B/C/E(Volume):Float32 NanoVDB WGSL 树遍历和有界 density ray integration 已有真实
|
||||
Chromium 数值/图像专项门;GPU page allocator、生产视口深度合成、temperature/color/emission
|
||||
grid 语义和 desktop/Chromium 三视角 golden 仍阻断,不能据此声明发布级体渲染。
|
||||
|
||||
## 验收
|
||||
|
||||
|
||||
@@ -18,12 +18,23 @@
|
||||
identifier、socket identifier 和 link;活动 Group Output/旧 Composite 映射为 Composite,
|
||||
Viewer 和常量 RGBA 映射到已验证节点,其他节点原样保留 `blenderType` 为 Unsupported。
|
||||
4096 nodes/16384 links、重复 destination、缺 output 或不可读 node tree 返回 blocked status。
|
||||
6. N-020-C(部分):CPU executor 在长像素/blur 循环中周期检查 cancellation,不再只在节点
|
||||
边界取消;内容寻址 frame key 绑定规范化 GraphIR、资源 Float32 内容 SHA-256、frame 和输出
|
||||
尺寸。有界 LRU cache 以克隆后的真实 buffer 字节计费(最多 256 MiB),命中返回隔离副本,
|
||||
调用方修改结果不会污染缓存。它是内存帧缓存,不等同于 tile scheduler 或 OPFS 持久缓存。
|
||||
7. N-020-A/B(真实参数链):Blender 5.2 Main reader 从真实 socket default 读取 Exposure,并仅在
|
||||
factor=1、invert color=true、invert alpha=false 时把 Invert 映射到当前 CPU 精确子集;Group
|
||||
Output 的动态 `Socket_0` 规范化为 GraphIR `Image`。桌面生成 fixture 经 WASM/Worker 后直接由
|
||||
CPU executor 求值 Constant→Exposure→Invert→Composite,RGBA 结果通过;未连接的 Glare 仍保留
|
||||
Unsupported 并使完整图 capability gate 保持阻断。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-020-A:Main graph 写回,以及 Transform/Invert/Exposure/Alpha Over/Blur/Mix/Image/Render Layer
|
||||
的逐节点参数与 resource/pass reader;基础真实图结构、常量色、Viewer/Composite 已完成。
|
||||
- N-020-B/C:WebGPU executor、tile/frame cache、增量 invalidation 和 GPU dispose。
|
||||
- N-020-A:Main graph 写回,以及 Transform、非默认 Invert、Alpha Over、Blur、Mix、Image/Render
|
||||
Layer 的逐节点参数与 resource/pass reader;基础真实图结构、常量色、Exposure、默认 Invert、
|
||||
Viewer/Composite 已完成。
|
||||
- N-020-B/C:WebGPU executor、tile scheduler、OPFS 持久 frame cache、增量 invalidation 和 GPU
|
||||
dispose;CPU 周期取消和内容寻址内存 LRU 已完成。
|
||||
- N-020-D:服务端 Blender job、source hash 和结果提交。
|
||||
- N-020-E:desktop HDR/alpha/color-space golden、OOM/fault/device-loss。
|
||||
|
||||
|
||||
@@ -12,17 +12,24 @@ Main 写回、媒体解码/渲染、音频波形和服务端编码仍未实现
|
||||
timeline;编辑后重新解析依赖和预算,锁定 strip 与 stale revision 会拒绝。
|
||||
3. N-021-B/C:`sequencerSourceFrame` 对 speed、trim、split 使用确定性的 source-frame
|
||||
映射,避免左右片段复用越界源帧。
|
||||
4. N-021-C/D(门):运行时只报告 WebCodecs/HTMLMedia 需要精确 probe;codec 必须
|
||||
4. N-021-B/C(部分):`resolveSequencerFrame` 在切分边界解析当前顶层 strip、source frame
|
||||
与依赖;META 覆盖的子 strip 和 inactive dependency 不会泄漏到渲染帧。
|
||||
5. N-021-C/D(门):运行时只报告 WebCodecs/HTMLMedia 需要精确 probe;codec 必须
|
||||
出现在已验证 MIME 集合中才可放行,本地编码固定为 `BLOCKED`。
|
||||
5. N-021-A(部分):Blender 5.2 `Scene.ed/Editing.seqbase` 读取 Scene、Movie、Image、Sound、
|
||||
6. N-021-A(部分):Blender 5.2 `Scene.ed/Editing.seqbase` 读取 Scene、Movie、Image、Sound、
|
||||
Meta 和协议支持的 Effect;显示范围由持久 `start/startofs/endofs/len` 计算,保留 channel、
|
||||
mute/lock、相对媒体路径、24/1.001 FPS 和 effect input stable ID。绝对路径、未知类型、
|
||||
坏依赖或超预算将 Scene sequencer 标为 `BLOCKED`。
|
||||
7. N-021-B(transition 子集):CROSS/GAMMA_CROSS 可按半开显示区间确定性解析 `[0,1)` 进度,
|
||||
保持 Blender `input1,input2` 顺序,并为两个活动依赖返回各自 source frame;越界、依赖缺失、
|
||||
muted 输入和非 cross effect 拒绝。
|
||||
8. 桌面 Blender 5.2 fixture 经 WASM/Worker 后,在真实 CROSS 中间帧得到 factor 0.5;两个单张
|
||||
Image strip 的 source range 为 `0..1`,显示 20 帧仍固定到 source frame 1,不伪造成图像序列。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-021-A/B:Main strip 写回、transition/modifier 完整参数、undo/redo 和 save/reopen;
|
||||
基础真实 reader 已完成。
|
||||
- N-021-A/B:Main strip 写回、非 CROSS transition/modifier 完整参数、undo/redo 和 save/reopen;
|
||||
基础真实 reader 与 CROSS/GAMMA_CROSS 帧描述已完成。
|
||||
- N-021-C:WebCodecs 精确 seek/decode、音频 waveform、proxy 生成、A/V sync、丢帧和
|
||||
损坏媒体处理。
|
||||
- N-021-D:浏览器不支持的 codec、混音与最终编码的服务端 Blender job。
|
||||
|
||||
@@ -13,17 +13,26 @@
|
||||
修改使用 revision 事务,锁定项和 stale revision 会拒绝,提交后再次完整解析。
|
||||
4. N-022-C(门):marker/mask schema edit 可用;browser tracking 只有显式 probe 成功后
|
||||
放行,camera solve 保持 `BLOCKED` 并要求受验证的服务端 Blender。
|
||||
5. N-022-A(部分):Blender 5.2 Mask Main reader 输出 layer、spline 和 Bezier point,保留
|
||||
5. N-022-D(部分):按屏幕射线对可见、未锁定且非零透明度的 Mask Bezier 段做有界采样,
|
||||
返回稳定的 layer/spline/point/segment identity;精确点命中优先于段命中,未声明的
|
||||
selection/raycast 编辑器仍保持阻断。
|
||||
6. N-022-A(部分):Blender 5.2 Mask Main reader 输出 layer、spline 和 Bezier point,保留
|
||||
cyclic/fill、viewport visibility、lock/hide-select、opacity、handle type/坐标、feather 与
|
||||
selection;1024 layer、100k spline、1M point 预算在分配前检查。MovieClip 不能从外部路径
|
||||
推导内容 SHA-256,继续阻断而不使用路径散列冒充源摘要。
|
||||
7. N-022-D(选择子集):Mask point marquee 支持 replace/add/toggle,验证矩形、已有 stable
|
||||
identity、重复项和 1M point 预算;输出按 Main 中的 mask/layer/spline/point 顺序确定化,隐藏、
|
||||
锁定和零透明度 layer 不会成为新选中项。
|
||||
8. Blender 5.2 fixture 同时包含锁定层和可编辑层,经 WASM/Worker 后实证锁定点不可 raycast,
|
||||
可编辑点返回稳定 identity,跨两层的 marquee 仅选择可编辑点,toggle 可确定性清空。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-022-A/B:真实 MovieClip reader、Mask/MovieClip 写回、undo/redo 和 save/reopen;Mask reader
|
||||
已完成。
|
||||
- N-022-C:浏览器 tracking 实现、完整 camera/plane solve 与服务端 job/hash 提交。
|
||||
- N-022-D:Clip/Mask editor、overlay、selection/raycast 和 compositor/scene 实际绑定。
|
||||
- N-022-D:完整 Clip/Mask editor、overlay、handle/segment marquee 与 compositor/scene 实际绑定;
|
||||
point raycast 和 point marquee 的真实 Main 子集已完成。
|
||||
- N-022-E:desktop solve/error golden、媒体故障和 Chromium 测试。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -12,22 +12,38 @@ IO 安全门已落地;Append/Link/Override Main、非 GLB 本地导入和跨
|
||||
content-addressed index 报告 `LOCAL_BOUNDED`,OPFS 只在运行时 API 存在时报告 `PROBE_REQUIRED`。
|
||||
3. N-023-C(门):现有 GLB 导出与 USD semantic analysis 可放行;GLTF/OBJ/PLY/STL、
|
||||
USD/Alembic 实际导入保持 `IO_FORMAT_UNSUPPORTED`,库 mutation 需要真实 Main。
|
||||
4. N-023-E:项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。
|
||||
5. N-023-B(部分):Blender 5.2 Main reader 输出 linked Library stable ID、项目相对路径、
|
||||
4. N-023-A/B(部分):预览字节先验 SHA-256 和 byte length,再校验 PNG signature/尺寸;
|
||||
WebP 仅在 RIFF/WEBP 容器签名正确时进入后续解码门,不从元数据伪造预览内容。
|
||||
5. N-023-E:项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。
|
||||
6. N-023-B(部分):Blender 5.2 Main reader 输出 linked Library stable ID、项目相对路径、
|
||||
packed/external 状态、只读标志和 archive-parent dependency;1024 library/每库 1024 dependency、
|
||||
重复 ID、缺依赖、依赖环与项目外路径由 SceneIR 再校验。缺外部库字节时返回
|
||||
`LINKED_LIBRARY_RESOURCE_REQUIRED`,不从路径伪造 SHA-256 或声称已加载。
|
||||
7. N-023-E(archive 结构门):除 traversal 与展开比预算外,重复规范路径、文件/目录前缀冲突、
|
||||
累计压缩/解压字节和声明源长度不一致均拒绝;`planIOArchiveRanges` 按路径生成确定性、安全整数
|
||||
compressed offset 计划。它为后续流式解包提供边界,不代表已有 ZIP decoder。
|
||||
8. N-023-E(NanoVDB range 基础件):`.nvdb` manifest 要求连续、32-byte 对齐、逐块 hash;
|
||||
HTTP source 只接受与 manifest 精确一致的 `206 Content-Range`,并按块校验后消费;临时错误重试、
|
||||
稳定 ETag/If-Range、response-body 偏移续传、错位/短响应拒绝均已完成。OPFS 原子
|
||||
staging/commit、source/bundle binding、Worker 重开、tamper/rollback/quota recovery 和 bundle LRU
|
||||
已完成;大 bundle 性能仍未实现。
|
||||
9. VDB 独立资源库包含 generated/official/source/derived/report/manifest/license,14 条记录逐项绑定
|
||||
byte length 与 SHA-256;官方 sphere 使用 CC-BY-4.0 和 Git LFS 权威 hash,未放入仓库工作树。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-023-B:Append/Link/Library Override、reload/relocate 和真实 Main transaction;只读 library
|
||||
inventory 已完成。
|
||||
- N-023-C/D:GLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。
|
||||
- N-023-E:zip fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件流式性能。
|
||||
- N-023-E:真实 zip decoder/fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件
|
||||
流式性能;archive 路径冲突、双向字节预算与确定性 range plan 已完成。
|
||||
- N-023-E(VDB):GPU page resident LRU 和 64 MiB–1 GiB bundle 中断/内存性能门。
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
WEB_TEST_PORT=5323 npm --prefix web run test:e2e -- --grep "N-023 asset"
|
||||
npm --prefix web run test:library-main-reader
|
||||
npm --prefix web run test:vdb
|
||||
npm --prefix web run test:vdb-native
|
||||
```
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# N-024 Editors 与工作流
|
||||
|
||||
状态:`BLOCKED`(统一 context、真实 Main 只读布局清单、selection sync、keymap 和布局预算
|
||||
已落地;完整 Blender editor writer、运行时焦点、gizmo/触控和跨设备 golden 未实现)
|
||||
状态:`BLOCKED`(统一 context、真实 Main 只读布局清单、selection sync、上下文 keymap、
|
||||
可执行 operator search 和布局预算已落地;完整 Blender editor writer、运行时焦点、
|
||||
gizmo/触控和跨设备 golden 未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -11,16 +12,24 @@
|
||||
workflow state,rect 边界与不可重叠布局经过校验。
|
||||
3. N-024-C(部分):selection sync 以 revision 事务更新 active object 与 selected IDs,
|
||||
active object 必须属于 selection,过期 revision 会拒绝。
|
||||
4. N-024-D(门):keymap 绑定和预算可验证;writer、gizmo 与 touch drag 保持能力阻断。
|
||||
5. N-024-B(Main reader):从 Blender `WorkSpace`、`WorkSpaceLayout`、`bScreen`、`ScrArea`
|
||||
4. N-024-D(部分):key chord 统一规范化 modifier、key 大小写和排序,启用绑定的冲突会在
|
||||
resolver 层拒绝,禁用绑定不参与冲突;writer、gizmo 与 touch drag 保持能力阻断。
|
||||
5. N-024-D(门):keymap 绑定和预算可验证;writer、gizmo 与 touch drag 保持能力阻断。
|
||||
6. N-024-B(Main reader):从 Blender `WorkSpace`、`WorkSpaceLayout`、`bScreen`、`ScrArea`
|
||||
和 `ARegion` 读取有界 workspace/area/region 清单、编辑器类型、可见性与归一化布局;
|
||||
不完整或包含未知 editor 的 workspace 被跳过,首个完整 workspace/area 只作为确定性只读 context,
|
||||
不声称恢复 Blender 运行时焦点。
|
||||
7. N-024-D(上下文 keymap):同一 key chord 可按 workspace、editor、mode 隔离;仅作用域
|
||||
相交的启用绑定视为冲突,resolver 使用当前 workflow context 确定命令。
|
||||
8. N-024-C(operator search):F3 搜索结果来自当前 workspace/mode/Main 状态;命令可执行
|
||||
workspace/mode 切换、Add Cube、Apply Transform、Undo/Redo/Save。Chromium 用真实 `.blend`
|
||||
验证了 Edit Mode 隐藏 Object-only Add Cube,Object Mode 执行后 Main revision 只增加一次。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-024-B/C:Properties、UV/Image、Node、Graph、Dope Sheet、NLA、Spreadsheet 等 editor
|
||||
专属数据读取/写回、真实运行时焦点、selection history、operator search 和 context menu。
|
||||
专属数据读取/写回、真实运行时焦点、跨 editor selection history 和 context menu;当前
|
||||
operator search 仅覆盖已注册的安全命令子集,不代表 Blender 全量 operator registry。
|
||||
- N-024-D/E:Blender-compatible keymap 执行、gizmo/drag preview/commit、桌面/mobile/笔
|
||||
触控布局、无重叠截图和 accessibility golden。
|
||||
|
||||
@@ -29,4 +38,5 @@
|
||||
```bash
|
||||
npm --prefix web run test:editor-main-reader
|
||||
WEB_TEST_PORT=5324 npm --prefix web run test:e2e -- --grep "N-024 editor"
|
||||
WEB_TEST_PORT=5407 npm --prefix web run test:e2e -- --grep "operator search executes"
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-025 Scripting 与平台
|
||||
|
||||
状态:`BLOCKED`(默认拒绝策略、真实 Main Text 来源清单、签名 manifest、权限/资源预算、
|
||||
平台报告和服务端 hash 门已落地;本地隔离执行、真实 server job 与发布审计未实现)
|
||||
平台报告、服务端 hash 门和请求决策审计已落地;本地隔离执行、真实 server job 与发布审计未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -16,17 +16,27 @@
|
||||
5. N-025-A(Main 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"
|
||||
```
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# N-026 全域发布门
|
||||
|
||||
状态:`BLOCKED`(machine-readable parity manifest、依赖/状态检查、命令/hash 证据绑定、
|
||||
Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据已落地;10M/大纹理/
|
||||
长媒体、simulation cache 性能、OOM/device loss/network interruption 与上游能力仍未齐)
|
||||
Chromium 完整套件、离线包/OPFS、SPDX SBOM、1M geometry、simulation cache 性能和
|
||||
4K/8K texture、运行中断网和主线程 WebGL device loss 证据已落地;10M/长媒体、OOM 与上游能力仍未齐)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
1. N-026-A:版本化 schema 3 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT`、
|
||||
`LOCAL_BOUNDED`、`SERVER` 或 `BLOCKED`、roadmap 状态、完成/阻断切片、验收命令和
|
||||
依赖;缺失 family、非法非阻断状态或依赖环会拒绝。
|
||||
`LOCAL_BOUNDED`、`SERVER` 或 `BLOCKED`、roadmap 状态、完成/阻断/明确排除切片、验收命令和
|
||||
依赖;同一切片跨状态重复、缺失 family、非法非阻断状态或依赖环会拒绝。
|
||||
2. N-026-B/C:Chromium 主线程/OffscreenCanvas、offline/Worker restart/OPFS recovery、
|
||||
1M/10M geometry、4K/8K texture、长媒体、simulation cache、OOM/device loss/网络
|
||||
中断/损坏 blend/zip bomb 等证据字段必须逐项为 true 才能放行。
|
||||
@@ -20,19 +20,41 @@ Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据
|
||||
完整 suite 或其余 family 的桌面 golden。发布门 schema 3 仅接受 Chromium 浏览器证据,
|
||||
Firefox/WebKit 不在本项目当前测试配置内。
|
||||
5. N-015 Curve/Surface/Font/Metaball 已通过 Blender 5.2 desktop geometry golden;这四类及
|
||||
PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 仍无 renderer
|
||||
与 loss fixture,这些证据也不替代其余 family 的 desktop golden。
|
||||
PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 已重新纳入,
|
||||
当前已有真实资源/license/hash、OpenVDB 13->NanoVDB 32 desktop 转换、manifest/range 和确定性
|
||||
证据;server job、OPFS 重开、Float32 WebGPU core 和 Main Volume 属性保存重开已有专项证据,
|
||||
生产视口、GPU paging、GLB/USD loss 和 desktop/Chromium 发布 golden 仍计为阻断。
|
||||
6. 当前发布包已通过本地 third-party notices、Blender/Three license 文件、无远程运行时依赖、
|
||||
非空 SHA-256 manifest、SPDX 2.3 lockfile/vendored SBOM、对应源码提供和离线二进制/源码包
|
||||
确定性复建;100k/1M decimate、5 类损坏 blend 和 archive 高压缩比拒绝有实际命令记录。
|
||||
7. `docs/status/release-evidence.json` 记录 Chromium 完整 E2E + release suite 以及上述发布命令;
|
||||
`docs/web/sbom.spdx.json` 确定性覆盖 npm lockfile 与 notices 中显式 vendored/native 组件。
|
||||
8. simulation cache 性能门在 Chromium 中写入并重开真实 OPFS 内容寻址缓存,逐帧范围读取、
|
||||
SHA-256 复验、解码并发布 600 帧 BTF1/SceneIR;25 秒动画量级的写入加播放总耗时须小于
|
||||
30 秒。该门衡量浏览器缓存管线,不声称 native physics 求解或 bake 性能已完成。
|
||||
9. network interruption 门在 Chromium 页面和 Worker 启动后切换真实 offline context,离线完成
|
||||
Main Add Cube、`.blend` 下载保存和保存文件重开,检查 revision 与 4 个对象均保持;该门不把
|
||||
未实现的远端 job 断点续传计为完成。
|
||||
10. 主线程视口显式跟踪 `lost -> restoring -> ready` WebGL 状态;Chromium 使用
|
||||
`WEBGL_lose_context` 真正丢失并恢复 context,恢复后重新应用 PBR 配置、绘制非空像素并
|
||||
完成一次 Main 编辑。OffscreenCanvas Worker device loss 尚未计入该证据。
|
||||
11. 4K texture 门在 Chromium 生成 4096x4096 PNG,经 SHA-256/维度/字节预算校验、
|
||||
`createImageBitmap` 解码、GPUTextureStore 和 Three.js WebGL 实际采样,要求非空彩色像素且
|
||||
解码上传渲染小于 30 秒。
|
||||
12. evidence record 只能绑定 schema 中已知且值为 true 的字段;任何绑定字段的记录必须包含
|
||||
至少一个唯一构件 SHA-256,manifest `generatedAt` 必须是可往返的 canonical UTC 时间戳。
|
||||
13. 8K texture 门先要求 WebGL `MAX_TEXTURE_SIZE >= 8192`,再对 8192x8192 PNG 执行同样的
|
||||
SHA-256、解码、GPU 上传和实际采样,独立 Chromium 进程内耗时须小于 45 秒。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- 跨 family 桌面 golden、10M、4K/8K texture、长媒体、simulation cache 性能和 OOM/device loss/
|
||||
network interruption fault 仍无真实证据;本项目当前只配置 Chromium,上游 N-015 至 N-025
|
||||
- 跨 family 桌面 golden、10M、长媒体和 OOM fault 仍无真实证据;
|
||||
本项目当前只配置 Chromium,上游 N-015 至 N-025
|
||||
的阻断能力会传递到发布门。
|
||||
- VDB 发布证据已覆盖 server job、HTTP 续传/OPFS 分块恢复、quota/tamper/rollback、Float32 WebGPU
|
||||
数值/专项像素、双生产视口、有限材质映射与 Volume Main 属性重开;仍缺 resident 分页、生产
|
||||
Offscreen device-loss 重建、OOM/大 bundle、完整材质和三视角
|
||||
desktop/Chromium golden。
|
||||
- 完整 native dependency/license 审计仍需发行审核;当前 SBOM 覆盖 lockfile 和显式 notices,
|
||||
不声称替代 Blender 全部传递依赖的发布级法律审计。
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
224
docs/status/vdb-native-evidence.json
Normal file
224
docs/status/vdb-native-evidence.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Binary file not shown.
@@ -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
27
tools/vdb/CMakeLists.txt
Normal 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
17
tools/vdb/README.md
Normal 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.
|
||||
149
tools/vdb/build-nanovdb-manifest.mjs
Normal file
149
tools/vdb/build-nanovdb-manifest.mjs
Normal 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
16
tools/vdb/build-native-tools.sh
Executable 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"
|
||||
78
tools/vdb/catalog-resources.mjs
Normal file
78
tools/vdb/catalog-resources.mjs
Normal 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`);
|
||||
66
tools/vdb/provision-resources.sh
Executable file
66
tools/vdb/provision-resources.sh
Executable 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"
|
||||
18
tools/vdb/server/vdb-job-server.mjs
Executable file
18
tools/vdb/server/vdb-job-server.mjs
Executable 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)));
|
||||
}
|
||||
293
tools/vdb/server/vdb-job-service.mjs
Normal file
293
tools/vdb/server/vdb-job-service.mjs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
65
tools/vdb/snapshot-evidence.mjs
Normal file
65
tools/vdb/snapshot-evidence.mjs
Normal 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`);
|
||||
96
tools/vdb/vdb_fixture_generator.cc
Normal file
96
tools/vdb/vdb_fixture_generator.cc
Normal 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
484
tools/vdb/vdb_to_nanovdb.cc
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
69
tools/web/check-scripting-isolation.mjs
Normal file
69
tools/web/check-scripting-isolation.mjs
Normal 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 });
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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`);
|
||||
|
||||
108
tools/web/check-vdb-native-pipeline.mjs
Normal file
108
tools/web/check-vdb-native-pipeline.mjs
Normal 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`);
|
||||
126
tools/web/check-vdb-server-job.mjs
Normal file
126
tools/web/check-vdb-server-job.mjs
Normal 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 });
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"id": "web-engine-bootstrap",
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/web_engine.wasm",
|
||||
"sha256": "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c",
|
||||
"sha256": "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
|
||||
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
15
web/app/src/compositor/CompositorExecutor.ts
Normal file
15
web/app/src/compositor/CompositorExecutor.ts
Normal 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";
|
||||
1
web/app/src/render/RenderAssets.ts
Normal file
1
web/app/src/render/RenderAssets.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../../protocol/render-assets";
|
||||
615
web/app/src/render/nanovdb-volume-renderer.ts
Normal file
615
web/app/src/render/nanovdb-volume-renderer.ts
Normal 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;
|
||||
}
|
||||
14
web/app/src/sequencer/SequencerTimeline.ts
Normal file
14
web/app/src/sequencer/SequencerTimeline.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
applySequencerEdit,
|
||||
gateSequencerCodec,
|
||||
parseSequencerTimeline,
|
||||
resolveSequencerFrame,
|
||||
resolveSequencerTransitionFrame,
|
||||
sequencerRuntimeCapabilities,
|
||||
sequencerSourceFrame,
|
||||
} from "../../../protocol/sequencer";
|
||||
export type {
|
||||
SequencerFrameStripIR,
|
||||
SequencerTimelineIR,
|
||||
SequencerTransitionFrameIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
8
web/app/src/simulation/BrowserTransformCachePlayback.ts
Normal file
8
web/app/src/simulation/BrowserTransformCachePlayback.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
applyBrowserTransformCachePreview,
|
||||
BrowserTransformCachePlaybackSession,
|
||||
} from "../../../protocol/physics-cache-playback";
|
||||
export type {
|
||||
BrowserTransformCacheFrameSource,
|
||||
BrowserTransformCachePlaybackResult,
|
||||
} from "../../../protocol/physics-cache-playback";
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) :
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
43
web/app/src/three-adapter/volume.ts
Normal file
43
web/app/src/three-adapter/volume.ts
Normal 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;
|
||||
}
|
||||
8
web/app/src/tracking/MaskSelection.ts
Normal file
8
web/app/src/tracking/MaskSelection.ts
Normal 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
46
web/app/src/types/webgpu.d.ts
vendored
Normal 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> } }
|
||||
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
88
web/app/src/volume/incremental-sha256.ts
Normal file
88
web/app/src/volume/incremental-sha256.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
99
web/app/src/volume/nanovdb-float32.ts
Normal file
99
web/app/src/volume/nanovdb-float32.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
268
web/app/src/volume/nanovdb-opfs.ts
Normal file
268
web/app/src/volume/nanovdb-opfs.ts
Normal 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 };
|
||||
}
|
||||
190
web/app/src/volume/nanovdb-stream.ts
Normal file
190
web/app/src/volume/nanovdb-stream.ts
Normal 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}`);
|
||||
};
|
||||
}
|
||||
256
web/app/src/volume/nanovdb-viewport.ts
Normal file
256
web/app/src/volume/nanovdb-viewport.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
94
web/app/src/volume/volume-material-mapping.ts
Normal file
94
web/app/src/volume/volume-material-mapping.ts
Normal 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"],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
71
web/app/src/workers/grease-pencil-viewport-test.worker.ts
Normal file
71
web/app/src/workers/grease-pencil-viewport-test.worker.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
34
web/app/src/workers/scene-delta-render-test.worker.ts
Normal file
34
web/app/src/workers/scene-delta-render-test.worker.ts
Normal 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);
|
||||
};
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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); }
|
||||
|
||||
173
web/app/src/workers/vdb-fault-test.worker.ts
Normal file
173
web/app/src/workers/vdb-fault-test.worker.ts
Normal 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) }));
|
||||
};
|
||||
213
web/app/src/workers/vdb-opfs-test.worker.ts
Normal file
213
web/app/src/workers/vdb-opfs-test.worker.ts
Normal 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) }));
|
||||
};
|
||||
@@ -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) }));
|
||||
};
|
||||
|
||||
64
web/app/src/workers/vdb-webgpu-test.worker.ts
Normal file
64
web/app/src/workers/vdb-webgpu-test.worker.ts
Normal 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) }));
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user