Advance WebGPU volume and bounded workflows
This commit is contained in:
@@ -1543,7 +1543,7 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
|
|||||||
type == "moveObjectToCollection" || type == "renameId" || type == "joinObjects" ||
|
type == "moveObjectToCollection" || type == "renameId" || type == "joinObjects" ||
|
||||||
type == "separateMeshFaces" || type == "applyObjectTransform" || type == "setObjectOrigin" ||
|
type == "separateMeshFaces" || type == "applyObjectTransform" || type == "setObjectOrigin" ||
|
||||||
type == "setCurveControlPoints" || type == "setCurveHandle" || type == "setCurveTopology" || type == "setCurveSplines" || type == "setSurfaceTopology" || type == "setFontBody" ||
|
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 == "setMetaballElements" || type == "createGreasePencilLayer" ||
|
||||||
type == "removeGreasePencilLayer" || type == "moveGreasePencilLayer" ||
|
type == "removeGreasePencilLayer" || type == "moveGreasePencilLayer" ||
|
||||||
type == "insertGreasePencilFrame" || type == "removeGreasePencilFrame" ||
|
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(
|
applied = main_error.empty() && web_engine_blend_main_set_font_links(
|
||||||
engine->authoritative_main, command.value("dataId", "").c_str(), font_ids, main_error);
|
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") {
|
else if (type == "setMetaballElements") {
|
||||||
std::vector<WebMetaballElementEdit> elements;
|
std::vector<WebMetaballElementEdit> elements;
|
||||||
const json source_elements = command.value("elements", json::array());
|
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);
|
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,
|
std::optional<ElementRef> raw_array_element(const ParsedBlend &blend,
|
||||||
uint64_t pointer,
|
uint64_t pointer,
|
||||||
const std::string &type_name,
|
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]}}};
|
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));
|
nodes.push_back(std::move(node_ir));
|
||||||
}
|
}
|
||||||
if (output_node_id.empty()) output_node_id = fallback_output_node_id;
|
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");
|
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 (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");
|
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 ||
|
if (from_socket_name.empty() || to_socket_name.empty() || from_socket_name.size() > 256 ||
|
||||||
to_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") {
|
else if (record.type_name == "Volume") {
|
||||||
const std::string path = read_string(*blend.sdna, element, "filepath");
|
const std::string path = read_string(*blend.sdna, element, "filepath");
|
||||||
data["resourceKind"] = "OPENVDB";
|
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::optional<uint64_t> packed_file = read_pointer(*blend.sdna, element, "packedfile");
|
||||||
const std::vector<uint8_t> packed_bytes = packed_file ?
|
const std::vector<uint8_t> packed_bytes = packed_file ?
|
||||||
packed_file_bytes(blend, *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;
|
mesh_bind_matrices[data_id->second] = matrix;
|
||||||
json groups = json::array();
|
json groups = json::array();
|
||||||
int index = 0;
|
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++}});
|
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);
|
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.hh"
|
||||||
#include "BKE_object_deform.h"
|
#include "BKE_object_deform.h"
|
||||||
#include "BKE_scene.hh"
|
#include "BKE_scene.hh"
|
||||||
|
#include "BKE_volume.hh"
|
||||||
#include "BKE_fcurve.hh"
|
#include "BKE_fcurve.hh"
|
||||||
#include "BKE_image.hh"
|
#include "BKE_image.hh"
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
#include "DNA_world_types.h"
|
#include "DNA_world_types.h"
|
||||||
#include "DNA_userdef_enums.h"
|
#include "DNA_userdef_enums.h"
|
||||||
#include "DNA_vfont_types.h"
|
#include "DNA_vfont_types.h"
|
||||||
|
#include "DNA_volume_types.h"
|
||||||
|
|
||||||
#include "BLI_listbase_iterator.hh"
|
#include "BLI_listbase_iterator.hh"
|
||||||
#include "BLI_listbase.h"
|
#include "BLI_listbase.h"
|
||||||
@@ -187,6 +189,18 @@ MetaBall *find_metaball(Main *main, const char *data_id)
|
|||||||
return nullptr;
|
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)
|
Light *find_light(Main *main, const char *data_id)
|
||||||
{
|
{
|
||||||
if (main == nullptr || data_id == nullptr || strncmp(data_id, "light:", 6) != 0) return nullptr;
|
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;
|
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,
|
bool web_engine_blend_main_set_metaball_elements(WebBlendMainState *state,
|
||||||
const char *data_id,
|
const char *data_id,
|
||||||
const std::vector<WebMetaballElementEdit> &elements,
|
const std::vector<WebMetaballElementEdit> &elements,
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ struct WebFontTextBoxEdit {
|
|||||||
float height = 0.0f;
|
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 {
|
struct WebGreasePencilPointEdit {
|
||||||
std::array<float, 3> position = {0.0f, 0.0f, 0.0f};
|
std::array<float, 3> position = {0.0f, 0.0f, 0.0f};
|
||||||
float radius = 0.01f;
|
float radius = 0.01f;
|
||||||
@@ -341,6 +350,10 @@ bool web_engine_blend_main_set_font_links(WebBlendMainState *state,
|
|||||||
const char *data_id,
|
const char *data_id,
|
||||||
const std::array<std::string, 4> &font_ids,
|
const std::array<std::string, 4> &font_ids,
|
||||||
std::string &error);
|
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,
|
bool web_engine_blend_main_set_metaball_elements(WebBlendMainState *state,
|
||||||
const char *data_id,
|
const char *data_id,
|
||||||
const std::vector<WebMetaballElementEdit> &elements,
|
const std::vector<WebMetaballElementEdit> &elements,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Blender 5.2 Web 功能对标与全量迁移台账
|
# Blender 5.2 Web 功能对标与全量迁移台账
|
||||||
|
|
||||||
更新时间:2026-08-12
|
更新时间:2026-08-13
|
||||||
|
|
||||||
本文件以仓库内 `blender-5.2.0/source/blender/` 为基线,记录 Blender 功能域在 Web
|
本文件以仓库内 `blender-5.2.0/source/blender/` 为基线,记录 Blender 功能域在 Web
|
||||||
项目中的迁移方式和唯一领取顺序。`completed_current_scope` 只表示声明子集通过验收,
|
项目中的迁移方式和唯一领取顺序。`completed_current_scope` 只表示声明子集通过验收,
|
||||||
@@ -37,14 +37,14 @@
|
|||||||
| NLA | `in_progress` Action Clip/reverse/repeat 子集 | 多轨混合、Transition/Meta、Animated Time、UI 和导出 | N-014 |
|
| 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 |
|
| Curve/Curves/Surface/Text/Metaball | `in_progress` reader/预览子集 | 数据块 IR、Main operator、tessellation 和保存闭环 | N-015 |
|
||||||
| Point Cloud/Volume/Hair | `in_progress` 摘要/资源门 | 有界数据读取、分块 buffer、视口和资源预算 | N-015 |
|
| Point Cloud/Volume/Hair | `in_progress` 摘要/资源门 | 有界数据读取、分块 buffer、视口和资源预算 | N-015 |
|
||||||
| Grease Pencil/Drawing | `planned` | layer/frame/drawing/stroke、onion skin 和基础 modifier | N-016 |
|
| Grease Pencil/Drawing | `in_progress` 有界 Main/3D editor | layer/frame/drawing/stroke、onion skin 和基础 modifier | N-016 |
|
||||||
| Vertex/Weight/Texture Paint | `planned` | Main/PBVH image transaction、颜色管理和恢复 | N-017 |
|
| Vertex/Weight/Texture Paint | `in_progress` vertex color/weight Main 子集 | Main/PBVH image transaction、颜色管理和恢复 | N-017 |
|
||||||
| Rigid/Soft Body、Cloth、Fluid、Particles | `planned` | 优先消费 desktop bake;WASM 求值逐 family feature probe | N-018 |
|
| Rigid/Soft Body、Cloth、Fluid、Particles | `in_progress` reader/cache/playback 安全层 | 优先消费 desktop bake;WASM 求值逐 family feature probe | N-018 |
|
||||||
| Camera/Light/World/Color Management | `metadata/viewport subset` | SceneIR/Three/WebGPU 映射、阴影、曝光和差异报告 | N-019 |
|
| 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 |
|
| Eevee/Cycles/Freestyle/Render Result | `planned` | WebGPU 实时子集;Cycles/硬件后端采用服务端 Blender | N-019 |
|
||||||
| Compositor | `planned` | 受限 node graph + WebGPU/CPU executor;完整图可服务端执行 | N-020 |
|
| Compositor | `in_progress` 有限 Main reader/CPU executor | 受限 node graph + WebGPU/CPU executor;完整图可服务端执行 | N-020 |
|
||||||
| Video Sequencer/Audio | `planned` | strip/time/edit proxy;codec 能力探测;最终编码可服务端 | N-021 |
|
| Video Sequencer/Audio | `in_progress` reader/transition 子集 | strip/time/edit proxy;codec 能力探测;最终编码可服务端 | N-021 |
|
||||||
| Movie Tracking/Mask | `planned` | 数据/编辑协议和基础浏览器工具;重计算按能力门 | N-022 |
|
| Movie Tracking/Mask | `in_progress` Mask reader/raycast/marquee | 数据/编辑协议和基础浏览器工具;重计算按能力门 | N-022 |
|
||||||
| Asset Browser/Library/Override | `partial` | catalog、preview、append/link/override、路径与来源沙箱 | N-023 |
|
| 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 |
|
| 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 |
|
| 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 点性能门。
|
65,536 点 chunks、SHA-256、256 MB 场景预算和 1M 点性能门。
|
||||||
2. N-015-B(`done_current_scope`):native reader 已读取真实 PointCloud/Curves position、
|
2. N-015-B(`done_current_scope`):native reader 已读取真实 PointCloud/Curves position、
|
||||||
radius、curve offset 和属性;depsgraph 对 Curve/Surface/Font/Metaball 输出真实 mesh、UV、
|
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、
|
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、
|
bounded Poly Curve create/delete、Poly/Bezier conversion、Curve/Surface/Font/Metaball 数据块 rename、
|
||||||
Font body/geometry properties、逐字符样式/textbox、已有/packed VFont 四样式链接和
|
Font body/geometry properties、逐字符样式/textbox、已有/packed VFont 四样式链接和
|
||||||
Metaball element Main command 已支持并通过 undo/save/reopen;一维多 spline 创建/删除/重排、
|
Metaball element Main command 已支持并通过 undo/save/reopen;一维多 spline 创建/删除/重排、
|
||||||
批量 handle/cyclic 和二维 Surface U/V topology/order/rational weight transaction 已通过
|
批量 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,真实
|
4. N-015-D(`in_progress`):Three.js 主线程和 OffscreenCanvas Worker 共享 WNM chunks,真实
|
||||||
PointCloud/Curves 可预览,Point/Curve bounded raycast、Metaball proxy 与 evaluated mesh 分层;
|
PointCloud/Curves 可预览,Point/Curve bounded raycast、Metaball proxy 与 evaluated mesh 分层;
|
||||||
左右 handle identity raycast/单 handle 轴向 gizmo 已完成;WebGPU、跨对象 selection history、
|
左右 handle identity raycast、跨对象 selection history、range patch、多 handle 拖拽和
|
||||||
多 handle 拖拽和 SceneDelta range patch 仍待完成。
|
handle-local gizmo 已完成;WebGPU 等价显示仍待完成。
|
||||||
5. N-015-E(`in_progress`):Blender 5.2 fixture、Chromium 双 renderer、depsgraph 真求值、GLB
|
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、
|
拒绝/映射和 USD loss report、Main roundtrip、desktop golden、7 对象 GLB/USD round-trip、
|
||||||
1M binary gate、Worker restart 与 Chromium 真实 OPFS quota 已接入;VDB renderer 仍是
|
1M binary gate、Worker restart 与 Chromium 真实 OPFS quota 已接入。VDB 转换请求、NanoVDB
|
||||||
当前阻断。后续浏览器验收仅覆盖 Chromium,不配置 Firefox/WebKit。
|
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`。
|
专项字段、验收命令和停止条件见 `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 预算。
|
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。
|
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。
|
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、
|
4. N-016-D:Chromium 主线程/Offscreen 的 3D current-frame stroke preview、点 raycast、多选
|
||||||
stroke/point selection、timeline/dope integration、gizmo 和 worker restart 继续推进。
|
高亮、连续 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。
|
5. N-016-E:desktop drawing hash、像素 golden、save/reopen 和 GLB/USD loss report。
|
||||||
|
|
||||||
## N-017 Paint 与权重
|
## 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 权重。
|
2. N-017-B:vertex color 与 vertex group Main transaction、normalize/limit/mirror 权重。
|
||||||
3. N-017-C:packed/UDIM image tile transaction、色彩空间、dirty tile 和原子保存。
|
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。
|
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 能力清单。
|
1. N-018-A:Rigid Body、Soft Body、Cloth、Fluid、Dynamic Paint、Particle/Hair 能力清单。
|
||||||
2. N-018-B:每 family 的 settings/dependency/cache manifest;目标对象和 collection 防循环。
|
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。
|
4. N-018-D:逐 family 探测 WASM solver;初始化、线程或内存门失败时保持 bake-only。
|
||||||
5. N-018-E:浏览器 bake start/cancel/commit、故障恢复、长任务进度和 100 帧 golden。
|
5. N-018-E:浏览器 bake start/cancel/commit、故障恢复、长任务进度和 100 帧 golden。
|
||||||
|
|
||||||
## N-019 灯光与渲染
|
## N-019 灯光与渲染
|
||||||
|
|
||||||
1. N-019-A:Camera、Light、World、View Transform、Exposure、Mist、Shadow 的完整 SceneIR。
|
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 和后处理。
|
3. N-019-C:Eevee 有限特性矩阵、shadow map、transparent sorting、probe 和后处理。
|
||||||
4. N-019-D:Cycles/Freestyle/硬件 denoise 使用服务端 Blender job 协议与结果 hash。
|
4. N-019-D:Cycles/Freestyle/硬件 denoise 使用服务端 Blender job 协议与结果 hash。
|
||||||
5. N-019-E:desktop/Web 像素误差、色彩管理、设备丢失和 1M 三角形性能门。
|
5. N-019-E:desktop/Web 像素误差、色彩管理、设备丢失和 1M 三角形性能门。
|
||||||
|
|
||||||
## N-020 Compositor
|
## 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。
|
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。
|
3. N-020-C:viewer/composite output、frame cache、tile budget 和 worker cancellation。
|
||||||
4. N-020-D:unsupported node 保留 metadata 并可提交服务端 Blender,不删图继续执行。
|
4. N-020-D:unsupported node 保留 metadata 并可提交服务端 Blender,不删图继续执行。
|
||||||
@@ -146,7 +158,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
|||||||
## N-021 Sequencer 与音频
|
## N-021 Sequencer 与音频
|
||||||
|
|
||||||
1. N-021-A:Scene/Movie/Image/Sound/Effect/Meta strip schema、channel、range 和 proxy metadata。
|
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。
|
3. N-021-C:WebCodecs/HTMLMedia capability probe、waveform/proxy 和精确 timeline seek。
|
||||||
4. N-021-D:浏览器不支持的 codec/混音/编码提交服务端;资源路径必须沙箱化。
|
4. N-021-D:浏览器不支持的 codec/混音/编码提交服务端;资源路径必须沙箱化。
|
||||||
5. N-021-E:A/V sync、frame hash、save/reopen、丢帧和损坏媒体测试。
|
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。
|
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。
|
2. N-022-B:Main marker/mask edit、selection、keyframe 和 camera solve metadata。
|
||||||
3. N-022-C:浏览器 tracking feature probe;完整 solve 可使用服务端 Blender。
|
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、媒体故障、保存和重开。
|
5. N-022-E:desktop solve/error golden、媒体故障、保存和重开。
|
||||||
|
|
||||||
## N-023 Asset、Library 与 IO
|
## N-023 Asset、Library 与 IO
|
||||||
@@ -165,7 +179,8 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
|||||||
2. N-023-B:Append/Link/Library Override Main transaction、reload/relocate 和循环依赖门。
|
2. N-023-B:Append/Link/Library Override Main transaction、reload/relocate 和循环依赖门。
|
||||||
3. N-023-C:glTF/OBJ/PLY/STL 首批本地 IO;USD/Alembic 按编译能力或服务端执行。
|
3. N-023-C:glTF/OBJ/PLY/STL 首批本地 IO;USD/Alembic 按编译能力或服务端执行。
|
||||||
4. N-023-D:每格式 import -> save -> reopen -> export -> desktop reimport 语义比较。
|
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 与工作流
|
## N-024 Editors 与工作流
|
||||||
|
|
||||||
@@ -175,6 +190,9 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
|||||||
4. N-024-D:Blender-compatible keymap、可配置 shortcut、gizmo、drag preview/commit。
|
4. N-024-D:Blender-compatible keymap、可配置 shortcut、gizmo、drag preview/commit。
|
||||||
5. N-024-E:desktop/mobile layout、键盘/触控/笔、无重叠截图和 accessibility 门。
|
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 与平台适配
|
## N-025 Scripting 与平台适配
|
||||||
|
|
||||||
1. N-025-A:默认拒绝任意 Python、Text autorun、driver expression 和 add-on 安装。
|
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 等返回平台能力报告。
|
4. N-025-D:CUDA/Metal/HIP/OptiX、native window、file watcher 等返回平台能力报告。
|
||||||
5. N-025-E:恶意脚本、依赖混淆、逃逸、重放和审计日志发布门。
|
5. N-025-E:恶意脚本、依赖混淆、逃逸、重放和审计日志发布门。
|
||||||
|
|
||||||
|
当前拒绝审计已具备有界 SHA-256 链、严格 UTC 顺序、requestId replay 与篡改拒绝;
|
||||||
|
无网络 sandbox 和持久化发布审计仍阻断。
|
||||||
|
|
||||||
## N-026 全域发布门
|
## N-026 全域发布门
|
||||||
|
|
||||||
1. 生成 machine-readable parity manifest:每个 Blender family 为
|
1. 生成 machine-readable parity manifest:每个 Blender family 为
|
||||||
@@ -192,6 +213,11 @@ UI 任务可以提前制作只读视图,但不得在对应 Main writer 和保
|
|||||||
4. `.blend`/image/media/script fuzz、OPFS quota、OOM、设备丢失、网络中断和恢复验证。
|
4. `.blend`/image/media/script fuzz、OPFS quota、OOM、设备丢失、网络中断和恢复验证。
|
||||||
5. 许可证/source offer/SBOM、确定性包、文档状态与测试报告一致后才能发布。
|
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`,并至少执行:
|
新增任务必须把专项命令接入 `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-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-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-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 采样未声明)
|
### 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-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-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 等价。
|
- PBR-012-D:浏览器 path tracer 若引入,作为独立 Render backend,不声称 Cycles 等价。
|
||||||
|
|
||||||
## 8. 当前验收
|
## 8. 当前验收
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Web Blender 项目现状与后续连续任务
|
# Web Blender 项目现状与后续连续任务
|
||||||
|
|
||||||
更新时间:2026-08-12
|
更新时间:2026-08-14
|
||||||
|
|
||||||
## 1. 当前结论
|
## 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 | 已完成当前数据契约 |
|
| 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/完整求值未开放 |
|
| Sculpt 属性 | Mask/Face Set 的 SceneIR、WBG1 header v2、Delta、Main、undo 和保存重开 | 已完成属性子集;有界四笔刷 Main stroke 已开放,PBVH/完整求值未开放 |
|
||||||
| Simulation cache | Blender 5.2/source blend/hash/frame manifest、OPFS 内容寻址、schema 6、重启逐帧复验 | 已完成缓存安全层;Simulation evaluator 未开放 |
|
| Simulation cache | Blender 5.2/source blend/hash/frame manifest、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 整图事务、资源门和保存重开 | 已完成六节点写回子集;任意图/编译仍阻断 |
|
| Shader Main | RGB/Value/Principled/Image Texture/Normal Map/Output 整图事务、资源门和保存重开 | 已完成六节点写回子集;任意图/编译仍阻断 |
|
||||||
| NLA Main | 单对象 Action Clip 整栈写回、SceneIR 重开和 native frame 求值 | 已完成有限 Action Clip 子集 |
|
| 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. 部分完成或仍有边界的能力
|
## 3. 部分完成或仍有边界的能力
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM
|
|||||||
| `.blend` 保存 | 场景状态、modifier、Mesh Edit、材质/UV、动画/约束和对象层级命令均写 Main;历史、原子保存、快照和日志重放可恢复 | skin/shape-key decimate 保持结构化阻断 |
|
| `.blend` 保存 | 场景状态、modifier、Mesh Edit、材质/UV、动画/约束和对象层级命令均写 Main;历史、原子保存、快照和日志重放可恢复 | skin/shape-key decimate 保持结构化阻断 |
|
||||||
| Blender UI 对标 | 默认区域、当前 Mesh Edit、raycast、多选、拖拽 gizmo、层级/材质/动画面板和核心快捷键已闭环 | Sculpt、Geometry Nodes 编辑器等未声明能力继续结构化阻断 |
|
| 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/结构化阻断 |
|
| 未声明能力扩展 | 四项协议/安全门均已细分;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 的网络式渐进流送仍属于后续性能扩展 |
|
| 大场景 | Worker 二进制解码、per-mesh/range transferable、linked Mesh 实例化、LOD/OPFS cache、frustum culling、能力门 OffscreenCanvas Worker、100k/1M 内存门 | 超出当前 WBG1 的网络式渐进流送仍属于后续性能扩展 |
|
||||||
| Decimate 全对标 | Collapse 的当前网格集合已覆盖,错误路径结构化 | Un-Subdivide、Dissolve、所有 delimiter/权重/对称组合及大模型性能矩阵未完成 |
|
| 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、
|
`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 二进制分块已经接通。
|
save/reopen、真实 legacy evaluated mesh、PointCloud/Curves 属性和 WNM 二进制分块已经接通。
|
||||||
主线程与 OffscreenCanvas 消费同一 chunks;GLB 只接受真实 evaluated mesh,USD 提供
|
主线程与 OffscreenCanvas 消费同一 chunks;GLB 只接受真实 evaluated mesh,USD 提供
|
||||||
`UsdGeomMesh`、`UsdGeomPoints`、`UsdGeomBasisCurves` 和 `OpenVDBAsset` 的机器可读映射/损失。
|
`UsdGeomMesh`、`UsdGeomPoints` 和 `UsdGeomBasisCurves` 的机器可读映射/损失。
|
||||||
VDB renderer、完整 Curve handle/cyclic topology editor、二维 Surface mutation、跨对象 history/range patch、新字体导入和跨浏览器 OPFS quota 仍按
|
VDB 已改为 desktop/server OpenVDB -> NanoVDB、浏览器分块读取 + WebGPU。真实资源 catalog、
|
||||||
`docs/status/N-015.md` 的阻断项推进。
|
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)
|
### 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
|
现已接通 SHA-256 GPU 纹理载荷、颜色空间、尺寸预算、ImageBitmap 解码和双后端状态;PBR-008
|
||||||
现已接通 UDIM manifest/缺失资源/多 tile 结构化阻断;PBR-009 已支持有限 packed raster
|
现已接通 UDIM manifest/缺失资源/多 tile 结构化阻断;PBR-009 已支持有限 packed raster
|
||||||
equirectangular PMREM/IBL 生命周期;PBR-012 已提供 WebGPU、任意 Shader、Volume/SSS 和
|
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 家族仍不得声称完成。架构映射、细分状态、停止条件和验收命令统一维护在
|
高级 pass 和未声明 Shader 家族仍不得声称完成。架构映射、细分状态、停止条件和验收命令统一维护在
|
||||||
`docs/PBR_RENDERING_IMPLEMENTATION_PLAN.md`。
|
`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. 当前验收命令
|
## 6. 当前验收命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -223,9 +235,16 @@ npm --prefix web run test:topology-collapse
|
|||||||
npm --prefix web run test:capability-gates
|
npm --prefix web run test:capability-gates
|
||||||
npm --prefix web run test:simulation-cache
|
npm --prefix web run test:simulation-cache
|
||||||
npm --prefix web run test:release-performance
|
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:malicious-blends
|
||||||
npm --prefix web run test:browser
|
npm --prefix web run test:browser
|
||||||
npm --prefix web run release:offline
|
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 # 5173 被占用时指定可用端口
|
||||||
WEB_TEST_PORT=5193 npm --prefix web run test:e2e -- -g "N-015|non-mesh"
|
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 几何数据块
|
# 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、
|
当前声明覆盖 Blender 5.2 `.blend` 中有限非 Mesh 数据的稳定识别、Main 权威写回、undo/redo、
|
||||||
保存重开、真实 evaluated mesh 报告和 WNM 二进制分块。Curve cyclic/handle 已有有界
|
保存重开、真实 evaluated mesh 报告和 WNM 二进制分块。Curve cyclic/handle 已有有界
|
||||||
属性读写、多目标选择和单事务平移提交,未声明完整 topology editor、连续 gizmo preview、
|
属性读写、多目标选择、连续临时预览、handle-local gizmo 和单事务平移提交;未声明完整
|
||||||
VDB 体渲染或完整 Volume USD loss fixture。
|
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 预览 | 当前门 |
|
| 数据族 | Reader | Web 预览 | 当前门 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -19,7 +25,7 @@ VDB 体渲染或完整 Volume USD loss fixture。
|
|||||||
| Metaball | 元素类型、位置、半径、尺度 | bounded sphere proxy;depsgraph implicit mesh | `READY` |
|
| Metaball | 元素类型、位置、半径、尺度 | bounded sphere proxy;depsgraph implicit mesh | `READY` |
|
||||||
| PointCloud | position/radius/POINT 属性 | WNM 分块 Points | `READY`(空数据为 `summary-only`) |
|
| PointCloud | position/radius/POINT 属性 | WNM 分块 Points | `READY`(空数据为 `summary-only`) |
|
||||||
| Curves/Hair | position/radius/curve/POINT 属性 | WNM 分块 Points/curve line | `READY`(缺数据阻断) |
|
| 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、
|
5. `NonMeshGeometryChunk` WNM schema 1 提供 65,536 点分块、SHA-256、属性 domain/storage、
|
||||||
256 MB 场景预算和 1M 点性能门;Worker/主线程/Offscreen 均传输并消费分块。
|
256 MB 场景预算和 1M 点性能门;Worker/主线程/Offscreen 均传输并消费分块。
|
||||||
6. native reader 读取 PointCloud/Curves/Hair 的 position、radius、curve offset 和属性值,
|
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` 输出真实位置、法线、
|
7. depsgraph 对 Curve/Surface/Font/Metaball 通过 `BKE_mesh_new_from_object` 输出真实位置、法线、
|
||||||
边/三角索引、UV、材质槽和 source face/edge mapping,并在 1M/2M 拓扑预算处结构化阻断。
|
边/三角索引、UV、材质槽和 source face/edge mapping,并在 1M/2M 拓扑预算处结构化阻断。
|
||||||
Web ID registry 已注册 `ID_VF` 并在加载时恢复 Blender 未写入 `.blend` 的 `<builtin>` 字体
|
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、
|
Metaball element 写回,以及 bounded Poly Curve create、delete、1D Poly/Bezier/NURBS conversion、
|
||||||
二维 Surface U/V dimensions/order/rational weight 网格替换和 Curve/Surface/Font/Metaball 数据块
|
二维 Surface U/V dimensions/order/rational weight 网格替换和 Curve/Surface/Font/Metaball 数据块
|
||||||
重命名;一维多 spline 创建/删除/重排和批量 handle/cyclic 已由单个原子 Main 事务覆盖。
|
重命名;一维多 spline 创建/删除/重排和批量 handle/cyclic 已由单个原子 Main 事务覆盖。
|
||||||
多 handle 选择、高亮和有界平移已由单个 Main transaction 覆盖,连续拖拽 preview 与
|
多 handle 选择、高亮和有界平移已由单个 Main transaction 覆盖;连续拖拽在主线程与
|
||||||
handle 专用 gizmo UI 仍阻断。单用户、undo/redo、save/reopen 由
|
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` 验证。
|
`check-nonmesh-roundtrip.mjs` 验证。
|
||||||
9. 主线程与 OffscreenCanvas 对 PointCloud/Curve 控制点和 Bezier handle 代理提供 bounded raycast,并对 Mesh 的
|
9. 主线程与 OffscreenCanvas 对 PointCloud/Curve 控制点和 Bezier handle 代理提供 bounded raycast,并对 Mesh 的
|
||||||
VERT/EDGE/FACE 使用一致的三角顶点、边索引解析,回传既有 selection callback。selection
|
VERT/EDGE/FACE 使用一致的三角顶点、边索引解析,回传既有 selection callback。selection
|
||||||
history schema 2 覆盖跨对象、多 data block、多 element kind、revision/stale-hit、undo/redo
|
history schema 2 覆盖跨对象、多 data block、多 element kind、revision/stale-hit、undo/redo
|
||||||
和有界 selected/unselected range patch,并兼容 schema 1。Bezier 左/右 handle 以全局控制点
|
和有界 selected/unselected range patch,并兼容 schema 1。Bezier 左/右 handle 以全局控制点
|
||||||
映射独立拾取;多 handle 选择、高亮和有界平移经精确 Main 命令写回并通过 undo/redo、
|
映射独立拾取;多 handle 选择、高亮和有界平移经精确 Main 命令写回并通过 undo/redo、
|
||||||
save/reopen。完整连续 gizmo preview 仍未声明。
|
save/reopen。连续 preview、局部轴投影和局部轴 Main delta 已接通。
|
||||||
10. `queryNonMeshCapability(dataId)` 返回 N-015 `READY/BLOCKED` 与机器可读错误码;GLB 对未求值
|
10. `queryNonMeshCapability(dataId)` 返回 N-015 `READY/BLOCKED` 与机器可读错误码;GLB 对未求值
|
||||||
非 Mesh 拒绝导出。Font/Metaball/二维 NURBS Surface 的 depsgraph 三角网格与 Curve 的
|
非 Mesh 拒绝导出。Font/Metaball/二维 NURBS Surface 的 depsgraph 三角网格与 Curve 的
|
||||||
evaluated edge line primitive 已通过 GLB 导出和 Blender 5.2 桌面回导几何校验;零三角且零边的结果仍返回
|
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
|
已通过 USD-enabled Blender 5.2.0 LTS desktop reimport。PointCloud 使用 GLB `POINTS`/USD
|
||||||
`UsdGeomPoints`,Curves/Hair 使用 GLB `LINES`/USD `UsdGeomBasisCurves`;radius 映射 USD
|
`UsdGeomPoints`,Curves/Hair 使用 GLB `LINES`/USD `UsdGeomBasisCurves`;radius 映射 USD
|
||||||
widths,typed WNM 属性映射 primvars,GLB 属性损失结构化报告,7 对象 desktop reimport 已通过。
|
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、真实
|
11. Blender 5.2 fixture 已包含真实 PointCloud/Curves 属性;Chromium 主线程/Offscreen、真实
|
||||||
depsgraph、WNM 1M 性能测试均接入。
|
depsgraph、WNM 1M 性能测试均接入。
|
||||||
12. `CurveGizmoDragIR` 为 PREVIEW/COMMIT 请求绑定 data ID、base revision、轴向 delta 和最多
|
12. `CurveGizmoDragIR` 为 PREVIEW/COMMIT 请求绑定 data ID、base revision、局部 axis vector、
|
||||||
256 个去重 handle identity;现有 UI gizmo 在单次 pointer commit 时先执行 stale revision、
|
轴向 delta 和最多 256 个去重 handle identity;UI gizmo 在单次 pointer commit 时先执行 stale revision、
|
||||||
重复 handle、有限坐标和预算校验,再合并为一次 `setCurveTopology` Main 事务。连续 preview
|
重复 handle、有限坐标和预算校验;pointer move 仅更新 renderer 临时坐标,pointer cancel
|
||||||
仍未开放。
|
恢复,pointer up 再合并为一次 `setCurveTopology` Main 事务。分块控制点会先按 point offset
|
||||||
13. `test:vdb-availability` 确认当前 WASM build 为 `WITH_OPENVDB=OFF`、
|
重组为有界 `Float32Array`,因此内联和二进制 Curve 使用同一局部坐标契约。
|
||||||
`WITH_NANOVDB=ON` metadata-only,项目和本机资源库没有可用 `.vdb`。因此真实 decoder、
|
13. 当前 WASM build 保持 `WITH_OPENVDB=OFF`。`VDBConversionRequestIR` 记录源 hash、grid
|
||||||
renderer 和 Volume loss fixture 继续 `BLOCKED`。
|
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 阻断。
|
1. N-015-C1:按 handle 本地原点/方向放置的专用 gizmo UI、连续多 handle 拖拽 preview、
|
||||||
2. N-015-C1:连续多 handle 拖拽 preview 与专用 gizmo UI;多 handle 选择、高亮、单事务平移、
|
多 handle 选择、高亮、局部轴单事务平移、
|
||||||
多 spline 创建/删除/重排、批量 handle/cyclic editor transaction 和二维 Surface U/V
|
多 spline 创建/删除/重排、批量 handle/cyclic editor transaction 和二维 Surface U/V
|
||||||
topology/order/rational weight 网格替换已完成。
|
topology/order/rational weight 网格替换已完成;完整 topology editor 不在本切片声明内。
|
||||||
3. N-015-C2:任意外部路径的新字体导入与资源沙箱;已有/packed VFont 的四 style link Main
|
2. N-015-C2:任意外部路径的新字体导入与资源沙箱;已有/packed VFont 的四 style link Main
|
||||||
写回、字符级样式、kern/material 与 textbox 已完成并通过 PFB desktop/WASM golden。
|
写回、字符级样式、kern/material 与 textbox 已完成并通过 PFB desktop/WASM golden。
|
||||||
4. N-015-D1:跨对象 selection history、多目标 element identity、range patch、左右 handle
|
3. N-015-D1:跨对象 selection history、多目标 element identity、range patch、左右 handle
|
||||||
raycast 和多 handle Main 写回已完成;连续拖拽 preview 归入 C1 阻断。
|
raycast、多 handle preview、handle-local gizmo 与单次 Main 写回已完成。
|
||||||
5. N-015-D2:evaluated preview 与源控制笼分层显示、WebGPU 等价。
|
4. N-015-D2:evaluated preview 与源控制笼分层显示、WebGPU 等价。
|
||||||
6. N-015-E1:补齐 Volume 的完整 loss fixture;PointCloud/Curves/Hair、Curve line bake、真实
|
5. N-015-B3/D2/E1(Volume):真实资源、desktop/server converter、可续传 HTTP range、OPFS、
|
||||||
4x4 NURBS Surface mesh、desktop geometry golden、7 对象 GLB 与 USDA desktop round-trip 已通过。
|
有界网络/Worker/quota/tamper/device-loss 故障门、Float32 WGSL core 和 Main Volume 属性重开已完成;
|
||||||
7. N-015-E2:100k/1M WASM/GPU 内存、Worker restart 和 Chromium OPFS quota;Chromium
|
继续执行生产 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 恢复已通过。后续浏览器验收
|
64 KiB 真实 quota、失败后旧 revision 保持、Worker restart 恢复已通过。后续浏览器验收
|
||||||
仅以 Chromium 为基线,不配置 Firefox/WebKit。
|
仅以 Chromium 为基线,不配置 Firefox/WebKit。
|
||||||
|
|
||||||
@@ -95,7 +125,9 @@ VDB 体渲染或完整 Volume USD loss fixture。
|
|||||||
- `summary-only` 不得进入 `READY`。
|
- `summary-only` 不得进入 `READY`。
|
||||||
- legacy 控制折线不得标记为 evaluated/tessellated curve。
|
- legacy 控制折线不得标记为 evaluated/tessellated curve。
|
||||||
- Metaball sphere proxy 不得用于导出或保存为求值曲面。
|
- 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。
|
- Metaball sphere proxy 不得进入 GLB;GLB 只能消费 depsgraph evaluated mesh。
|
||||||
- 任意新增 Main authoring 命令在 save/reopen 与 undo/redo 完成前不得开放 UI。
|
- 任意新增 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-usd-blender-roundtrip
|
||||||
npm --prefix web run test:nonmesh-binary
|
npm --prefix web run test:nonmesh-binary
|
||||||
npm --prefix web run test:nonmesh-interaction
|
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
|
||||||
|
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=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"
|
WEB_TEST_PORT=5201 npm --prefix web run test:e2e -- -g "N-015|non-mesh"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# N-016 Grease Pencil
|
# N-016 Grease Pencil
|
||||||
|
|
||||||
状态:`BLOCKED`(协议、有限 reader/Main transaction、当前帧及相邻帧 onion preview 已落地;
|
状态:`BLOCKED`(协议、有限 reader/Main transaction、当前帧及相邻帧 onion preview 已落地;
|
||||||
完整 2D/3D 编辑器、modifier 语义和桌面 golden 仍阻断)
|
完整 2D canvas/Dope 编辑、modifier 语义和桌面 golden 仍阻断)
|
||||||
|
|
||||||
## 已验证切片
|
## 已验证切片
|
||||||
|
|
||||||
@@ -28,12 +28,24 @@
|
|||||||
有界 X 轴平移;协议先校验 point identity、revision、有限坐标和 1M 点预算,再保留 radius、
|
有界 X 轴平移;协议先校验 point identity、revision、有限坐标和 1M 点预算,再保留 radius、
|
||||||
opacity、vertex color、cyclic、material index 并合并为一个 `setGreasePencilStrokes` Main
|
opacity、vertex color、cyclic、material index 并合并为一个 `setGreasePencilStrokes` Main
|
||||||
transaction。它是点级整帧原子写回,不等同于完整 2D 画布选择或连续 gizmo。
|
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-C:material datablock 事务、onion fade/range 完整语义和完整 Grease Pencil modifier reader。
|
||||||
- N-016-D:2D 画布 raycast/marquee、多点可视选择、timeline/dope integration、连续 gizmo 和
|
- N-016-D:完整 2D canvas/marquee 和完整 timeline/dope 编辑;3D current-frame 点 raycast、
|
||||||
worker restart;3D current-frame stroke viewport 与 Properties 单点原子平移已完成。
|
多点高亮、连续 preview、单次 gizmo commit、有界 drawing frame 导航和 worker restart 已完成。
|
||||||
- N-016-E:desktop drawing hash、Chromium 像素 golden、GLB/USD loss report 和 OPFS。
|
- N-016-E:desktop drawing hash、Chromium 像素 golden、GLB/USD loss report 和 OPFS。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# N-017 Paint 与权重
|
# N-017 Paint 与权重
|
||||||
|
|
||||||
状态:`BLOCKED`(stroke/patch schema、真实 Three raycast 命中、Main 顶点色/权重 transaction
|
状态:`BLOCKED`(stroke/patch schema、真实 Three raycast 命中、选择/遮罩门和 Main 顶点色/权重
|
||||||
已落地;PBVH brush、texture paint 和 GPU/image 生命周期未实现)
|
transaction 已落地;PBVH brush、texture paint 和 GPU/image 生命周期未实现)
|
||||||
|
|
||||||
## 已验证切片
|
## 已验证切片
|
||||||
|
|
||||||
@@ -25,15 +25,25 @@
|
|||||||
front-face 法线门;它不宣称 PBVH 加速或桌面 brush 等价。`UdimTilePatchIR` 约束 1001-1999
|
front-face 法线门;它不宣称 PBVH 加速或桌面 brush 等价。`UdimTilePatchIR` 约束 1001-1999
|
||||||
tile、RGBA8、色彩空间、尺寸/256 MiB 预算、revision、base/result SHA-256,并在内存中验证
|
tile、RGBA8、色彩空间、尺寸/256 MiB 预算、revision、base/result SHA-256,并在内存中验证
|
||||||
原子 range patch;尚未绑定 Blender packed image/UDIM Main 写回与保存。
|
原子 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 与桌面语义的
|
- N-017-A:真实 PBVH、由 viewport 深度缓冲生成可见 vertex 集合,以及 brush falloff 桌面
|
||||||
golden 对照;基础 Mesh/UV raycast hit 和有界 CPU 候选 falloff 已完成。
|
golden;基础 Mesh/UV hit、uniform-grid 候选查询和可见性硬门已完成。
|
||||||
- N-017-B:limit/clean、已验证拓扑映射上的 mirror、桌面 brush/falloff 对照。
|
- N-017-B:limit/clean、已验证拓扑映射上的 mirror、桌面 brush/falloff 对照。
|
||||||
- N-017-C:Blender packed/UDIM tile Main transaction、dirty tile、色彩转换和原子保存;当前
|
- N-017-C:Blender packed/UDIM tile Main transaction、dirty tile、色彩转换和原子保存;当前
|
||||||
只有内容哈希绑定的浏览器内存 patch 边界。
|
只有内容哈希绑定的浏览器内存 patch 边界。
|
||||||
- N-017-D/E:armature golden、seam bleed、mask/selection、GPU dispose、quota、坏图和 UI。
|
- N-017-D/E:armature golden、seam bleed、face mask、GPU dispose、quota、坏图和桌面 UI 对照;
|
||||||
|
vertex selection 与数值 mask 门已完成,不等同于完整 Paint 面/纹理遮罩系统。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# N-018 Physics 与 Simulation
|
# N-018 Physics 与 Simulation
|
||||||
|
|
||||||
状态:`BLOCKED`(family capability、settings/dependency/cache manifest 与错误帧门已落地;
|
状态:`BLOCKED`(family capability、settings/dependency/cache manifest、错误帧门及浏览器自有
|
||||||
cache playback、WASM solver 和 bake job 未实现)
|
BTF1 播放会话已落地;desktop bake playback、WASM solver 和 bake job 未实现)
|
||||||
|
|
||||||
## 已验证切片
|
## 已验证切片
|
||||||
|
|
||||||
@@ -24,12 +24,22 @@ cache playback、WASM solver 和 bake job 未实现)
|
|||||||
frame、对象 stable ID、精确字节长度、有限 translation、单位 quaternion、非零 scale 和重复
|
frame、对象 stable ID、精确字节长度、有限 translation、单位 quaternion、非零 scale 和重复
|
||||||
ID。该格式仅是浏览器自有的 cache playback primitive,不是 Blender desktop bake family
|
ID。该格式仅是浏览器自有的 cache playback primitive,不是 Blender desktop bake family
|
||||||
payload,因此 capability inventory 的 `cachePlayback` 仍为 `BLOCKED`。
|
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-A/B:从真实 Main 提取完整 family settings、collection/effector/collision 依赖。
|
||||||
- N-018-C:desktop bake payload 的逐 family decoder、与 depsgraph/viewport 的 frame playback
|
- N-018-C:desktop bake payload 的逐 family decoder、正式 viewport UI 播放控制和 100 帧
|
||||||
连接和 100 帧 golden;浏览器自有 transform frame decoder 已完成但不替代 Blender bake。
|
golden;浏览器自有 transform frame decoder/SceneIR 播放会话已完成但不替代 Blender bake。
|
||||||
- N-018-D:逐 family WASM solver 初始化、线程、内存和确定性验证。
|
- N-018-D:逐 family WASM solver 初始化、线程、内存和确定性验证。
|
||||||
- N-018-E:bake start/cancel/commit、服务端 job、故障恢复、进度与 UI。
|
- N-018-E:bake start/cancel/commit、服务端 job、故障恢复、进度与 UI。
|
||||||
|
|
||||||
@@ -41,6 +51,6 @@ npm --prefix web run test:simulation-cache
|
|||||||
npm --prefix web run test:physics-main-reader
|
npm --prefix web run test:physics-main-reader
|
||||||
```
|
```
|
||||||
|
|
||||||
本轮在 Chromium 隔离端口复验 content-addressed cache 的 Worker restart、目标帧 range read 与
|
本轮在 Chromium 隔离端口复验 content-addressed cache 的 Worker restart、目标帧 range read、
|
||||||
帧 SHA-256 门通过;没有 desktop bake family decoder 或 solver,因此 playback/solver/bake 状态
|
帧 SHA-256、BTF1 seek/play/cancel 和迟到结果抑制通过;没有 desktop bake family decoder 或
|
||||||
不变,继续为 `BLOCKED`。
|
solver,因此 desktop playback/solver/bake 状态不变,继续为 `BLOCKED`。
|
||||||
|
|||||||
@@ -13,20 +13,29 @@ Three exposure/shadow 映射已落地;Scene 颜色管理 writer 和渲染等
|
|||||||
3. N-019-B(部分):主线程和 OffscreenCanvas renderer 都优先使用 Scene color-management
|
3. N-019-B(部分):主线程和 OffscreenCanvas renderer 都优先使用 Scene color-management
|
||||||
exposure,World exposure 仅作旧数据回退;Light exposure 转换为强度,`castsShadow:false`
|
exposure,World exposure 仅作旧数据回退;Light exposure 转换为强度,`castsShadow:false`
|
||||||
不再被 Three 强制打开。
|
不再被 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`。
|
不暴露垃圾数值而返回 `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 与范围在修改前
|
ortho scale 和 DOF enable/focus distance/f-stop/blades/rotation/ratio;near/far 与范围在修改前
|
||||||
拒绝,已通过 undo/redo 和 save/reopen。
|
拒绝,已通过 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,必须改接
|
- N-019-A:Scene color-management writer;直接 DNA 写入会破坏 white balance,必须改接
|
||||||
Blender/RNA 颜色管理 API 后才能开放。Camera writer 已完成。
|
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 参数。
|
transparent sorting、probe 和高级 shadow 参数。
|
||||||
- N-019-D:Cycles/Freestyle/denoise 服务端 job 协议与结果 hash。
|
- N-019-D:Cycles/Freestyle/denoise 服务端 job 协议与结果 hash。
|
||||||
- N-019-E:desktop/Chromium 像素 golden、设备丢失和 1M triangles。
|
- 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,
|
identifier、socket identifier 和 link;活动 Group Output/旧 Composite 映射为 Composite,
|
||||||
Viewer 和常量 RGBA 映射到已验证节点,其他节点原样保留 `blenderType` 为 Unsupported。
|
Viewer 和常量 RGBA 映射到已验证节点,其他节点原样保留 `blenderType` 为 Unsupported。
|
||||||
4096 nodes/16384 links、重复 destination、缺 output 或不可读 node tree 返回 blocked status。
|
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
|
- N-020-A:Main graph 写回,以及 Transform、非默认 Invert、Alpha Over、Blur、Mix、Image/Render
|
||||||
的逐节点参数与 resource/pass reader;基础真实图结构、常量色、Viewer/Composite 已完成。
|
Layer 的逐节点参数与 resource/pass reader;基础真实图结构、常量色、Exposure、默认 Invert、
|
||||||
- N-020-B/C:WebGPU executor、tile/frame cache、增量 invalidation 和 GPU dispose。
|
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-D:服务端 Blender job、source hash 和结果提交。
|
||||||
- N-020-E:desktop HDR/alpha/color-space golden、OOM/fault/device-loss。
|
- N-020-E:desktop HDR/alpha/color-space golden、OOM/fault/device-loss。
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,24 @@ Main 写回、媒体解码/渲染、音频波形和服务端编码仍未实现
|
|||||||
timeline;编辑后重新解析依赖和预算,锁定 strip 与 stale revision 会拒绝。
|
timeline;编辑后重新解析依赖和预算,锁定 strip 与 stale revision 会拒绝。
|
||||||
3. N-021-B/C:`sequencerSourceFrame` 对 speed、trim、split 使用确定性的 source-frame
|
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`。
|
出现在已验证 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、
|
Meta 和协议支持的 Effect;显示范围由持久 `start/startofs/endofs/len` 计算,保留 channel、
|
||||||
mute/lock、相对媒体路径、24/1.001 FPS 和 effect input stable ID。绝对路径、未知类型、
|
mute/lock、相对媒体路径、24/1.001 FPS 和 effect input stable ID。绝对路径、未知类型、
|
||||||
坏依赖或超预算将 Scene sequencer 标为 `BLOCKED`。
|
坏依赖或超预算将 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;
|
- N-021-A/B:Main strip 写回、非 CROSS transition/modifier 完整参数、undo/redo 和 save/reopen;
|
||||||
基础真实 reader 已完成。
|
基础真实 reader 与 CROSS/GAMMA_CROSS 帧描述已完成。
|
||||||
- N-021-C:WebCodecs 精确 seek/decode、音频 waveform、proxy 生成、A/V sync、丢帧和
|
- N-021-C:WebCodecs 精确 seek/decode、音频 waveform、proxy 生成、A/V sync、丢帧和
|
||||||
损坏媒体处理。
|
损坏媒体处理。
|
||||||
- N-021-D:浏览器不支持的 codec、混音与最终编码的服务端 Blender job。
|
- N-021-D:浏览器不支持的 codec、混音与最终编码的服务端 Blender job。
|
||||||
|
|||||||
@@ -13,17 +13,26 @@
|
|||||||
修改使用 revision 事务,锁定项和 stale revision 会拒绝,提交后再次完整解析。
|
修改使用 revision 事务,锁定项和 stale revision 会拒绝,提交后再次完整解析。
|
||||||
4. N-022-C(门):marker/mask schema edit 可用;browser tracking 只有显式 probe 成功后
|
4. N-022-C(门):marker/mask schema edit 可用;browser tracking 只有显式 probe 成功后
|
||||||
放行,camera solve 保持 `BLOCKED` 并要求受验证的服务端 Blender。
|
放行,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 与
|
cyclic/fill、viewport visibility、lock/hide-select、opacity、handle type/坐标、feather 与
|
||||||
selection;1024 layer、100k spline、1M point 预算在分配前检查。MovieClip 不能从外部路径
|
selection;1024 layer、100k spline、1M point 预算在分配前检查。MovieClip 不能从外部路径
|
||||||
推导内容 SHA-256,继续阻断而不使用路径散列冒充源摘要。
|
推导内容 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-A/B:真实 MovieClip reader、Mask/MovieClip 写回、undo/redo 和 save/reopen;Mask reader
|
||||||
已完成。
|
已完成。
|
||||||
- N-022-C:浏览器 tracking 实现、完整 camera/plane solve 与服务端 job/hash 提交。
|
- 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 测试。
|
- 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`。
|
content-addressed index 报告 `LOCAL_BOUNDED`,OPFS 只在运行时 API 存在时报告 `PROBE_REQUIRED`。
|
||||||
3. N-023-C(门):现有 GLB 导出与 USD semantic analysis 可放行;GLTF/OBJ/PLY/STL、
|
3. N-023-C(门):现有 GLB 导出与 USD semantic analysis 可放行;GLTF/OBJ/PLY/STL、
|
||||||
USD/Alembic 实际导入保持 `IO_FORMAT_UNSUPPORTED`,库 mutation 需要真实 Main。
|
USD/Alembic 实际导入保持 `IO_FORMAT_UNSUPPORTED`,库 mutation 需要真实 Main。
|
||||||
4. N-023-E:项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。
|
4. N-023-A/B(部分):预览字节先验 SHA-256 和 byte length,再校验 PNG signature/尺寸;
|
||||||
5. N-023-B(部分):Blender 5.2 Main reader 输出 linked Library stable ID、项目相对路径、
|
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、
|
packed/external 状态、只读标志和 archive-parent dependency;1024 library/每库 1024 dependency、
|
||||||
重复 ID、缺依赖、依赖环与项目外路径由 SceneIR 再校验。缺外部库字节时返回
|
重复 ID、缺依赖、依赖环与项目外路径由 SceneIR 再校验。缺外部库字节时返回
|
||||||
`LINKED_LIBRARY_RESOURCE_REQUIRED`,不从路径伪造 SHA-256 或声称已加载。
|
`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
|
- N-023-B:Append/Link/Library Override、reload/relocate 和真实 Main transaction;只读 library
|
||||||
inventory 已完成。
|
inventory 已完成。
|
||||||
- N-023-C/D:GLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。
|
- 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
|
```bash
|
||||||
WEB_TEST_PORT=5323 npm --prefix web run test:e2e -- --grep "N-023 asset"
|
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:library-main-reader
|
||||||
|
npm --prefix web run test:vdb
|
||||||
|
npm --prefix web run test:vdb-native
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# N-024 Editors 与工作流
|
# N-024 Editors 与工作流
|
||||||
|
|
||||||
状态:`BLOCKED`(统一 context、真实 Main 只读布局清单、selection sync、keymap 和布局预算
|
状态:`BLOCKED`(统一 context、真实 Main 只读布局清单、selection sync、上下文 keymap、
|
||||||
已落地;完整 Blender editor writer、运行时焦点、gizmo/触控和跨设备 golden 未实现)
|
可执行 operator search 和布局预算已落地;完整 Blender editor writer、运行时焦点、
|
||||||
|
gizmo/触控和跨设备 golden 未实现)
|
||||||
|
|
||||||
## 已验证切片
|
## 已验证切片
|
||||||
|
|
||||||
@@ -11,16 +12,24 @@
|
|||||||
workflow state,rect 边界与不可重叠布局经过校验。
|
workflow state,rect 边界与不可重叠布局经过校验。
|
||||||
3. N-024-C(部分):selection sync 以 revision 事务更新 active object 与 selected IDs,
|
3. N-024-C(部分):selection sync 以 revision 事务更新 active object 与 selected IDs,
|
||||||
active object 必须属于 selection,过期 revision 会拒绝。
|
active object 必须属于 selection,过期 revision 会拒绝。
|
||||||
4. N-024-D(门):keymap 绑定和预算可验证;writer、gizmo 与 touch drag 保持能力阻断。
|
4. N-024-D(部分):key chord 统一规范化 modifier、key 大小写和排序,启用绑定的冲突会在
|
||||||
5. N-024-B(Main reader):从 Blender `WorkSpace`、`WorkSpaceLayout`、`bScreen`、`ScrArea`
|
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 清单、编辑器类型、可见性与归一化布局;
|
和 `ARegion` 读取有界 workspace/area/region 清单、编辑器类型、可见性与归一化布局;
|
||||||
不完整或包含未知 editor 的 workspace 被跳过,首个完整 workspace/area 只作为确定性只读 context,
|
不完整或包含未知 editor 的 workspace 被跳过,首个完整 workspace/area 只作为确定性只读 context,
|
||||||
不声称恢复 Blender 运行时焦点。
|
不声称恢复 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
|
- 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/笔
|
- N-024-D/E:Blender-compatible keymap 执行、gizmo/drag preview/commit、桌面/mobile/笔
|
||||||
触控布局、无重叠截图和 accessibility golden。
|
触控布局、无重叠截图和 accessibility golden。
|
||||||
|
|
||||||
@@ -29,4 +38,5 @@
|
|||||||
```bash
|
```bash
|
||||||
npm --prefix web run test:editor-main-reader
|
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=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 与平台
|
# N-025 Scripting 与平台
|
||||||
|
|
||||||
状态:`BLOCKED`(默认拒绝策略、真实 Main Text 来源清单、签名 manifest、权限/资源预算、
|
状态:`BLOCKED`(默认拒绝策略、真实 Main Text 来源清单、签名 manifest、权限/资源预算、
|
||||||
平台报告和服务端 hash 门已落地;本地隔离执行、真实 server job 与发布审计未实现)
|
平台报告、服务端 hash 门和请求决策审计已落地;本地隔离执行、真实 server job 与发布审计未实现)
|
||||||
|
|
||||||
## 已验证切片
|
## 已验证切片
|
||||||
|
|
||||||
@@ -19,13 +19,19 @@
|
|||||||
6. `test:scripting-isolation` 独立验证未批准 key 返回 `SCRIPT_SIGNATURE_INVALID`,批准 key
|
6. `test:scripting-isolation` 独立验证未批准 key 返回 `SCRIPT_SIGNATURE_INVALID`,批准 key
|
||||||
仍返回 `SCRIPT_SANDBOX_UNAVAILABLE`,server job 返回 `SERVER_JOB_UNAVAILABLE`;测试不创建
|
仍返回 `SCRIPT_SANDBOX_UNAVAILABLE`,server job 返回 `SERVER_JOB_UNAVAILABLE`;测试不创建
|
||||||
本地执行器,也不把签名校验误报为 sandbox。
|
本地执行器,也不把签名校验误报为 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 和
|
- N-025-B/C:签名验证密钥管理、无网络 CPython/native sandbox、server Blender job 和
|
||||||
output hash 提交。
|
output hash 提交。
|
||||||
- N-025-D/E:真实 GPU/native window/file watcher 适配、恶意脚本/依赖混淆/逃逸/重放、
|
- N-025-D/E:真实 GPU/native window/file watcher 适配、恶意脚本/依赖混淆/逃逸/重放、
|
||||||
审计日志和发布门。
|
审计日志持久化和发布门;本地请求级审计凭证已完成,不代表隔离执行完成。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# N-026 全域发布门
|
# N-026 全域发布门
|
||||||
|
|
||||||
状态:`BLOCKED`(machine-readable parity manifest、依赖/状态检查、命令/hash 证据绑定、
|
状态:`BLOCKED`(machine-readable parity manifest、依赖/状态检查、命令/hash 证据绑定、
|
||||||
Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据已落地;10M/大纹理/
|
Chromium 完整套件、离线包/OPFS、SPDX SBOM、1M geometry、simulation cache 性能和
|
||||||
长媒体、simulation cache 性能、OOM/device loss/network interruption 与上游能力仍未齐)
|
4K/8K texture、运行中断网和主线程 WebGL device loss 证据已落地;10M/长媒体、OOM 与上游能力仍未齐)
|
||||||
|
|
||||||
## 已验证切片
|
## 已验证切片
|
||||||
|
|
||||||
1. N-026-A:版本化 schema 3 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT`、
|
1. N-026-A:版本化 schema 3 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT`、
|
||||||
`LOCAL_BOUNDED`、`SERVER` 或 `BLOCKED`、roadmap 状态、完成/阻断切片、验收命令和
|
`LOCAL_BOUNDED`、`SERVER` 或 `BLOCKED`、roadmap 状态、完成/阻断/明确排除切片、验收命令和
|
||||||
依赖;缺失 family、非法非阻断状态或依赖环会拒绝。
|
依赖;同一切片跨状态重复、缺失 family、非法非阻断状态或依赖环会拒绝。
|
||||||
2. N-026-B/C:Chromium 主线程/OffscreenCanvas、offline/Worker restart/OPFS recovery、
|
2. N-026-B/C:Chromium 主线程/OffscreenCanvas、offline/Worker restart/OPFS recovery、
|
||||||
1M/10M geometry、4K/8K texture、长媒体、simulation cache、OOM/device loss/网络
|
1M/10M geometry、4K/8K texture、长媒体、simulation cache、OOM/device loss/网络
|
||||||
中断/损坏 blend/zip bomb 等证据字段必须逐项为 true 才能放行。
|
中断/损坏 blend/zip bomb 等证据字段必须逐项为 true 才能放行。
|
||||||
@@ -20,19 +20,41 @@ Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据
|
|||||||
完整 suite 或其余 family 的桌面 golden。发布门 schema 3 仅接受 Chromium 浏览器证据,
|
完整 suite 或其余 family 的桌面 golden。发布门 schema 3 仅接受 Chromium 浏览器证据,
|
||||||
Firefox/WebKit 不在本项目当前测试配置内。
|
Firefox/WebKit 不在本项目当前测试配置内。
|
||||||
5. N-015 Curve/Surface/Font/Metaball 已通过 Blender 5.2 desktop geometry golden;这四类及
|
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
|
PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 已重新纳入,
|
||||||
与 loss fixture,这些证据也不替代其余 family 的 desktop golden。
|
当前已有真实资源/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 文件、无远程运行时依赖、
|
6. 当前发布包已通过本地 third-party notices、Blender/Three license 文件、无远程运行时依赖、
|
||||||
非空 SHA-256 manifest、SPDX 2.3 lockfile/vendored SBOM、对应源码提供和离线二进制/源码包
|
非空 SHA-256 manifest、SPDX 2.3 lockfile/vendored SBOM、对应源码提供和离线二进制/源码包
|
||||||
确定性复建;100k/1M decimate、5 类损坏 blend 和 archive 高压缩比拒绝有实际命令记录。
|
确定性复建;100k/1M decimate、5 类损坏 blend 和 archive 高压缩比拒绝有实际命令记录。
|
||||||
7. `docs/status/release-evidence.json` 记录 Chromium 完整 E2E + release suite 以及上述发布命令;
|
7. `docs/status/release-evidence.json` 记录 Chromium 完整 E2E + release suite 以及上述发布命令;
|
||||||
`docs/web/sbom.spdx.json` 确定性覆盖 npm lockfile 与 notices 中显式 vendored/native 组件。
|
`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/
|
- 跨 family 桌面 golden、10M、长媒体和 OOM fault 仍无真实证据;
|
||||||
network interruption fault 仍无真实证据;本项目当前只配置 Chromium,上游 N-015 至 N-025
|
本项目当前只配置 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,
|
- 完整 native dependency/license 审计仍需发行审核;当前 SBOM 覆盖 lockfile 和显式 notices,
|
||||||
不声称替代 Blender 全部传递依赖的发布级法律审计。
|
不声称替代 Blender 全部传递依赖的发布级法律审计。
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"updatedAt": "2026-08-12",
|
"updatedAt": "2026-08-14",
|
||||||
"source": "docs/BLENDER_5_2_WEB_FEATURE_PARITY.md",
|
"source": "docs/BLENDER_5_2_WEB_FEATURE_PARITY.md",
|
||||||
"statusEnum": ["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "BLOCKED"],
|
"statusEnum": ["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "BLOCKED"],
|
||||||
"families": [
|
"families": [
|
||||||
{
|
{
|
||||||
"id": "N-015",
|
"id": "N-015",
|
||||||
"name": "Non-mesh geometry",
|
"name": "Non-mesh geometry",
|
||||||
"status": "LOCAL_BOUNDED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "in_progress",
|
"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"],
|
"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": ["B3-vdb-renderer", "C1-continuous-handle-gizmo-preview", "C2-new-external-font-import", "E1-volume-loss-fixture"],
|
"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"],
|
||||||
"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"],
|
"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": []
|
"dependencies": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -19,8 +20,8 @@
|
|||||||
"name": "Grease Pencil",
|
"name": "Grease Pencil",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"roadmapStatus": "planned",
|
||||||
"completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", "D-editor-main-layer-frame-panel-partial", "D-single-point-revision-bound-main-translation"],
|
"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-multipoint-dope-gizmo-restart", "E-desktop-browser-golden-export-opfs"],
|
"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"],
|
"acceptance": ["web:test:grease-pencil", "web:test:grease-pencil-editor", "web:e2e:N-016 Grease Pencil"],
|
||||||
"dependencies": ["N-015"]
|
"dependencies": ["N-015"]
|
||||||
},
|
},
|
||||||
@@ -29,8 +30,8 @@
|
|||||||
"name": "Paint and weights",
|
"name": "Paint and weights",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"roadmapStatus": "planned",
|
||||||
"completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "A-bounded-cpu-candidate-falloff", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", "B-selected-vertex-color-weight-ui-transaction-partial", "C-udim-memory-range-patch-hash-boundary"],
|
"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-mask-selection-gpu-quota-golden-ui"],
|
"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"],
|
"acceptance": ["web:test:paint-roundtrip", "web:e2e:N-017 paint"],
|
||||||
"dependencies": ["N-016"]
|
"dependencies": ["N-016"]
|
||||||
},
|
},
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
"name": "Physics and simulation",
|
"name": "Physics and simulation",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"roadmapStatus": "planned",
|
||||||
"completedSlices": ["A-family-capability-inventory", "A-authoritative-main-physics-reader-partial", "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate", "C-browser-transform-frame-binary-decoder"],
|
"completedSlices": ["A-family-capability-inventory", "A-authoritative-main-physics-reader-partial", "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate", "C-browser-transform-frame-binary-decoder", "C-browser-transform-sceneir-preview", "C-browser-transform-cancellable-playback-session", "C-browser-transform-storage-restart-playback"],
|
||||||
"blockedSlices": ["A-B-full-family-settings-effectors-cache-binding", "C-desktop-bake-decoder-playback-100-frame-golden", "D-wasm-solvers", "E-bake-server-recovery-progress-ui"],
|
"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"],
|
"acceptance": ["web:e2e:N-018 physics", "web:test:simulation-cache", "web:test:physics-main-reader"],
|
||||||
"dependencies": ["N-017"]
|
"dependencies": ["N-017"]
|
||||||
@@ -49,8 +50,8 @@
|
|||||||
"name": "Lighting and render",
|
"name": "Lighting and render",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"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"],
|
"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"],
|
"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"],
|
"acceptance": ["web:test:lighting-roundtrip", "web:e2e:N-019 Scene exposure"],
|
||||||
"dependencies": ["N-018"]
|
"dependencies": ["N-018"]
|
||||||
},
|
},
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
"name": "Compositor",
|
"name": "Compositor",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"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"],
|
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||||
"acceptance": ["web:test:compositor-main-reader", "web:e2e:N-020 CPU compositor"],
|
"acceptance": ["web:test:compositor-main-reader", "web:e2e:N-020 CPU compositor"],
|
||||||
"dependencies": ["N-019"]
|
"dependencies": ["N-019"]
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
"name": "Sequencer and audio",
|
"name": "Sequencer and audio",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"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"],
|
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||||
"acceptance": ["web:test:sequencer-main-reader", "web:e2e:N-021 sequencer"],
|
"acceptance": ["web:test:sequencer-main-reader", "web:e2e:N-021 sequencer"],
|
||||||
"dependencies": ["N-020"]
|
"dependencies": ["N-020"]
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
"name": "Tracking and masks",
|
"name": "Tracking and masks",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"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"],
|
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||||
"acceptance": ["web:test:mask-main-reader", "web:e2e:N-022 tracking"],
|
"acceptance": ["web:test:mask-main-reader", "web:e2e:N-022 tracking"],
|
||||||
"dependencies": ["N-021"]
|
"dependencies": ["N-021"]
|
||||||
@@ -89,9 +90,9 @@
|
|||||||
"name": "Assets, libraries and IO",
|
"name": "Assets, libraries and IO",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"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"],
|
"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"],
|
"blockedSlices": ["A", "B", "C", "D", "E", "E-nanovdb-gpu-resident-page-cache-large-bundle"],
|
||||||
"acceptance": ["web:test:library-main-reader", "web:e2e:N-023 asset"],
|
"acceptance": ["web:test:library-main-reader", "web:test:vdb-faults", "web:e2e:N-023 asset"],
|
||||||
"dependencies": ["N-022"]
|
"dependencies": ["N-022"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
"name": "Editors and workflow",
|
"name": "Editors and workflow",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"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"],
|
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||||
"acceptance": ["web:test:editor-main-reader", "web:e2e:N-024 editor"],
|
"acceptance": ["web:test:editor-main-reader", "web:e2e:N-024 editor"],
|
||||||
"dependencies": ["N-023"]
|
"dependencies": ["N-023"]
|
||||||
@@ -109,7 +110,7 @@
|
|||||||
"name": "Scripting and platform",
|
"name": "Scripting and platform",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"roadmapStatus": "planned",
|
||||||
"completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "B-approved-key-still-sandbox-blocked-audit", "C-server-source-hash-job-gate", "D-platform-capability-report"],
|
"completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "B-approved-key-still-sandbox-blocked-audit", "B-request-decision-audit-receipt", "C-server-source-hash-job-gate", "D-platform-capability-report", "E-bounded-replay-resistant-audit-hash-chain"],
|
||||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||||
"acceptance": ["web:test:script-main-reader", "web:test:scripting-isolation", "web:e2e:N-025 script"],
|
"acceptance": ["web:test:script-main-reader", "web:test:scripting-isolation", "web:e2e:N-025 script"],
|
||||||
"dependencies": ["N-024"]
|
"dependencies": ["N-024"]
|
||||||
@@ -119,8 +120,8 @@
|
|||||||
"name": "Release gate",
|
"name": "Release gate",
|
||||||
"status": "BLOCKED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "planned",
|
"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"],
|
"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"],
|
"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"],
|
"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"]
|
"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,
|
"schemaVersion": 3,
|
||||||
"source": "docs/status/parity-ledger.json",
|
"source": "docs/status/parity-ledger.json",
|
||||||
"sourceSha256": "bc1de45b23987518f38d629361693bad977a49da69b509ade1bee0aee36e272f",
|
"sourceSha256": "fbd0b368e00d2dbeb7d63e248ae2cdb6228a20520b44a14722a21aa0693a6395",
|
||||||
"generatedAt": "2026-08-12T23:22:09.909Z",
|
"generatedAt": "2026-08-14T14:56:43.298Z",
|
||||||
"families": [
|
"families": [
|
||||||
{
|
{
|
||||||
"id": "N-015",
|
"id": "N-015",
|
||||||
"name": "Non-mesh geometry",
|
"name": "Non-mesh geometry",
|
||||||
"status": "LOCAL_BOUNDED",
|
"status": "BLOCKED",
|
||||||
"roadmapStatus": "in_progress",
|
"roadmapStatus": "in_progress",
|
||||||
"completedSlices": [
|
"completedSlices": [
|
||||||
"A1",
|
"A1",
|
||||||
@@ -16,13 +16,24 @@
|
|||||||
"A2-multichunk-attribute-completeness",
|
"A2-multichunk-attribute-completeness",
|
||||||
"B2",
|
"B2",
|
||||||
"B3-metadata",
|
"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-topology-partial",
|
||||||
"C1-handle-points-partial",
|
"C1-handle-points-partial",
|
||||||
"C1-handle-preview-raycast-partial",
|
"C1-handle-preview-raycast-partial",
|
||||||
"C1-handle-identity-gizmo-main-roundtrip",
|
"C1-handle-identity-gizmo-main-roundtrip",
|
||||||
"C1-multi-handle-selection-highlight-bounded-commit",
|
"C1-multi-handle-selection-highlight-bounded-commit",
|
||||||
"C1-gizmo-revision-delta-transaction-boundary",
|
"C1-gizmo-revision-delta-transaction-boundary",
|
||||||
|
"C1-continuous-handle-gizmo-preview-single-commit",
|
||||||
|
"C1-handle-local-origin-orientation-gizmo",
|
||||||
"C1-rename-partial",
|
"C1-rename-partial",
|
||||||
"C1-create-delete-poly-partial",
|
"C1-create-delete-poly-partial",
|
||||||
"C1-poly-bezier-nurbs-1d-conversion",
|
"C1-poly-bezier-nurbs-1d-conversion",
|
||||||
@@ -34,12 +45,17 @@
|
|||||||
"C2-existing-packed-vfont-style-links",
|
"C2-existing-packed-vfont-style-links",
|
||||||
"C2-builtin-font-evaluation-exact",
|
"C2-builtin-font-evaluation-exact",
|
||||||
"C3-roundtrip",
|
"C3-roundtrip",
|
||||||
|
"C3-volume-main-properties-save-reopen",
|
||||||
|
"C3-nanovdb-opfs-binding-worker-reopen",
|
||||||
"D1-raycast-partial",
|
"D1-raycast-partial",
|
||||||
"D1-offscreen-vert-edge-partial",
|
"D1-offscreen-vert-edge-partial",
|
||||||
"D1-selection-history-partial",
|
"D1-selection-history-partial",
|
||||||
"D1-cross-object-history-range-patch",
|
"D1-cross-object-history-range-patch",
|
||||||
"D1-handle-identity-axis-gizmo",
|
"D1-handle-identity-axis-gizmo",
|
||||||
"D2-partial",
|
"D2-partial",
|
||||||
|
"D2-nanovdb-float32-cpu-wgsl-sampling",
|
||||||
|
"D2-nanovdb-bounded-webgpu-integration",
|
||||||
|
"D2-webgpu-device-loss-session-recovery",
|
||||||
"E1-partial",
|
"E1-partial",
|
||||||
"E1-desktop-geometry-golden",
|
"E1-desktop-geometry-golden",
|
||||||
"E1-true-2d-surface-desktop-golden",
|
"E1-true-2d-surface-desktop-golden",
|
||||||
@@ -49,14 +65,17 @@
|
|||||||
"E1-pointcloud-curves-hair-glb-usd-loss-fixture",
|
"E1-pointcloud-curves-hair-glb-usd-loss-fixture",
|
||||||
"E2-1M-chromium",
|
"E2-1M-chromium",
|
||||||
"E2-chromium-worker-recovery",
|
"E2-chromium-worker-recovery",
|
||||||
"E2-opfs-quota-chromium"
|
"E2-opfs-quota-chromium",
|
||||||
|
"E2-vdb-network-worker-quota-tamper-rollback-faults"
|
||||||
],
|
],
|
||||||
"blockedSlices": [
|
"blockedSlices": [
|
||||||
"B3-vdb-renderer",
|
|
||||||
"C1-continuous-handle-gizmo-preview",
|
|
||||||
"C2-new-external-font-import",
|
"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": [
|
"acceptance": [
|
||||||
"web:test:nonmesh-roundtrip",
|
"web:test:nonmesh-roundtrip",
|
||||||
"web:test:nonmesh-desktop-golden",
|
"web:test:nonmesh-desktop-golden",
|
||||||
@@ -64,10 +83,16 @@
|
|||||||
"web:test:nonmesh-usd-serialization",
|
"web:test:nonmesh-usd-serialization",
|
||||||
"web:test:nonmesh-usd-blender-roundtrip",
|
"web:test:nonmesh-usd-blender-roundtrip",
|
||||||
"web:test:nonmesh-binary",
|
"web:test:nonmesh-binary",
|
||||||
"web:test:vdb",
|
|
||||||
"web:test:vdb-availability",
|
|
||||||
"web:test:selection-history",
|
"web:test:selection-history",
|
||||||
"web:test:nonmesh-interaction",
|
"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:N-015|non-mesh",
|
||||||
"web:e2e:real OPFS quota"
|
"web:e2e:real OPFS quota"
|
||||||
],
|
],
|
||||||
@@ -87,11 +112,15 @@
|
|||||||
"D-current-frame-stroke-preview-main-offscreen-chromium",
|
"D-current-frame-stroke-preview-main-offscreen-chromium",
|
||||||
"D-editor-layer-frame-selection-context-partial",
|
"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-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": [
|
"blockedSlices": [
|
||||||
"C-material-modifier-full-semantics",
|
"C-material-modifier-full-semantics",
|
||||||
"D-full-2d-canvas-multipoint-dope-gizmo-restart",
|
"D-full-2d-canvas-marquee-dope-editor",
|
||||||
"E-desktop-browser-golden-export-opfs"
|
"E-desktop-browser-golden-export-opfs"
|
||||||
],
|
],
|
||||||
"acceptance": [
|
"acceptance": [
|
||||||
@@ -112,16 +141,20 @@
|
|||||||
"A-stroke-hit-weight-schema-budget-partial",
|
"A-stroke-hit-weight-schema-budget-partial",
|
||||||
"A-three-raycast-source-face-barycentric-uv",
|
"A-three-raycast-source-face-barycentric-uv",
|
||||||
"A-bounded-cpu-candidate-falloff",
|
"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-color-partial",
|
||||||
"B-main-vertex-weight-normalize-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"
|
"C-udim-memory-range-patch-hash-boundary"
|
||||||
],
|
],
|
||||||
"blockedSlices": [
|
"blockedSlices": [
|
||||||
"A-real-pbvh-depth-occlusion-desktop-falloff",
|
"A-real-pbvh-depth-occlusion-desktop-falloff",
|
||||||
"B-clean-mirror-desktop-brush",
|
"B-clean-mirror-desktop-brush",
|
||||||
"C-blender-packed-udim-dirty-atomic-save",
|
"C-blender-packed-udim-dirty-atomic-save",
|
||||||
"D-E-mask-selection-gpu-quota-golden-ui"
|
"D-E-face-mask-gpu-quota-desktop-golden"
|
||||||
],
|
],
|
||||||
"acceptance": [
|
"acceptance": [
|
||||||
"web:test:paint-roundtrip",
|
"web:test:paint-roundtrip",
|
||||||
@@ -143,7 +176,10 @@
|
|||||||
"B-cloth-softbody-collision-settings-sha256-save-reopen",
|
"B-cloth-softbody-collision-settings-sha256-save-reopen",
|
||||||
"C-exact-frame-selection-gate",
|
"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-frame-binary-decoder",
|
||||||
|
"C-browser-transform-sceneir-preview",
|
||||||
|
"C-browser-transform-cancellable-playback-session",
|
||||||
|
"C-browser-transform-storage-restart-playback"
|
||||||
],
|
],
|
||||||
"blockedSlices": [
|
"blockedSlices": [
|
||||||
"A-B-full-family-settings-effectors-cache-binding",
|
"A-B-full-family-settings-effectors-cache-binding",
|
||||||
@@ -169,15 +205,21 @@
|
|||||||
"A-camera-light-world-scene-reader-partial",
|
"A-camera-light-world-scene-reader-partial",
|
||||||
"A-main-camera-dof-properties-partial",
|
"A-main-camera-dof-properties-partial",
|
||||||
"A-main-light-world-properties-partial",
|
"A-main-light-world-properties-partial",
|
||||||
|
"A-white-balance-integrity-gate",
|
||||||
"B-three-exposure-shadow-mapping-partial",
|
"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": [
|
"blockedSlices": [
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
"D",
|
"D",
|
||||||
"E"
|
"E",
|
||||||
|
"B-C-nanovdb-production-viewport-paging-advanced-material",
|
||||||
|
"E-volume-desktop-chromium-pixel-golden"
|
||||||
],
|
],
|
||||||
"acceptance": [
|
"acceptance": [
|
||||||
"web:test:lighting-roundtrip",
|
"web:test:lighting-roundtrip",
|
||||||
@@ -195,8 +237,11 @@
|
|||||||
"completedSlices": [
|
"completedSlices": [
|
||||||
"A-graph-resource-cycle-schema",
|
"A-graph-resource-cycle-schema",
|
||||||
"A-main-graph-structure-reader-partial",
|
"A-main-graph-structure-reader-partial",
|
||||||
|
"A-main-exposure-default-invert-parameter-reader",
|
||||||
"B-bounded-cpu-executor-partial",
|
"B-bounded-cpu-executor-partial",
|
||||||
|
"B-real-main-exposure-invert-cpu-chain",
|
||||||
"C-image-operation-budget-cancel-partial",
|
"C-image-operation-budget-cancel-partial",
|
||||||
|
"C-content-addressed-frame-lru-cache",
|
||||||
"D-unsupported-node-preservation-gate"
|
"D-unsupported-node-preservation-gate"
|
||||||
],
|
],
|
||||||
"blockedSlices": [
|
"blockedSlices": [
|
||||||
@@ -224,6 +269,9 @@
|
|||||||
"A-main-strip-timeline-reader-partial",
|
"A-main-strip-timeline-reader-partial",
|
||||||
"B-deterministic-move-trim-split-partial",
|
"B-deterministic-move-trim-split-partial",
|
||||||
"B-source-frame-seek",
|
"B-source-frame-seek",
|
||||||
|
"B-active-frame-dependency-resolution",
|
||||||
|
"B-cross-transition-progress-input-resolution",
|
||||||
|
"B-real-main-cross-still-frame-resolution",
|
||||||
"C-runtime-codec-probe-gate"
|
"C-runtime-codec-probe-gate"
|
||||||
],
|
],
|
||||||
"blockedSlices": [
|
"blockedSlices": [
|
||||||
@@ -251,7 +299,10 @@
|
|||||||
"A-main-mask-layer-spline-point-reader",
|
"A-main-mask-layer-spline-point-reader",
|
||||||
"B-revision-marker-mask-edit-partial",
|
"B-revision-marker-mask-edit-partial",
|
||||||
"B-source-hash-binding-validation",
|
"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": [
|
"blockedSlices": [
|
||||||
"A",
|
"A",
|
||||||
@@ -276,20 +327,29 @@
|
|||||||
"completedSlices": [
|
"completedSlices": [
|
||||||
"A-catalog-asset-license-source-schema",
|
"A-catalog-asset-license-source-schema",
|
||||||
"A-content-addressed-opfs-capability",
|
"A-content-addressed-opfs-capability",
|
||||||
|
"A-preview-content-signature-dimension-verification",
|
||||||
|
"A-vdb-external-resource-license-sha-catalog",
|
||||||
"B-library-dependency-order-partial",
|
"B-library-dependency-order-partial",
|
||||||
"B-main-library-inventory-reader",
|
"B-main-library-inventory-reader",
|
||||||
"C-glb-export-usd-analysis-gates",
|
"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": [
|
"blockedSlices": [
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
"D",
|
"D",
|
||||||
"E"
|
"E",
|
||||||
|
"E-nanovdb-gpu-resident-page-cache-large-bundle"
|
||||||
],
|
],
|
||||||
"acceptance": [
|
"acceptance": [
|
||||||
"web:test:library-main-reader",
|
"web:test:library-main-reader",
|
||||||
|
"web:test:vdb-faults",
|
||||||
"web:e2e:N-023 asset"
|
"web:e2e:N-023 asset"
|
||||||
],
|
],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -306,7 +366,10 @@
|
|||||||
"B-read-only-editor-manifest-partial",
|
"B-read-only-editor-manifest-partial",
|
||||||
"B-main-workspace-area-region-reader",
|
"B-main-workspace-area-region-reader",
|
||||||
"C-selection-sync-revision-partial",
|
"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": [
|
"blockedSlices": [
|
||||||
"A",
|
"A",
|
||||||
@@ -333,8 +396,10 @@
|
|||||||
"A-main-text-source-inventory-reader",
|
"A-main-text-source-inventory-reader",
|
||||||
"B-signed-manifest-permission-budget",
|
"B-signed-manifest-permission-budget",
|
||||||
"B-approved-key-still-sandbox-blocked-audit",
|
"B-approved-key-still-sandbox-blocked-audit",
|
||||||
|
"B-request-decision-audit-receipt",
|
||||||
"C-server-source-hash-job-gate",
|
"C-server-source-hash-job-gate",
|
||||||
"D-platform-capability-report"
|
"D-platform-capability-report",
|
||||||
|
"E-bounded-replay-resistant-audit-hash-chain"
|
||||||
],
|
],
|
||||||
"blockedSlices": [
|
"blockedSlices": [
|
||||||
"A",
|
"A",
|
||||||
@@ -361,11 +426,19 @@
|
|||||||
"A-machine-readable-parity-manifest",
|
"A-machine-readable-parity-manifest",
|
||||||
"A-dependency-status-validation",
|
"A-dependency-status-validation",
|
||||||
"A-command-hash-evidence-binding",
|
"A-command-hash-evidence-binding",
|
||||||
|
"A-known-enabled-field-artifact-binding",
|
||||||
"B-chromium-runtime-evidence-gate",
|
"B-chromium-runtime-evidence-gate",
|
||||||
"B-chromium-full-suite-evidence",
|
"B-chromium-full-suite-evidence",
|
||||||
"C-performance-fault-provenance-gate",
|
"C-performance-fault-provenance-gate",
|
||||||
"C-performance-1M-malicious-input-partial",
|
"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-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-release-package-notices-partial",
|
||||||
"C-spdx-2.3-lockfile-sbom",
|
"C-spdx-2.3-lockfile-sbom",
|
||||||
"D-deterministic-manifest-serialization",
|
"D-deterministic-manifest-serialization",
|
||||||
@@ -376,7 +449,8 @@
|
|||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
"D",
|
"D",
|
||||||
"E"
|
"E",
|
||||||
|
"C-vdb-server-webgpu-save-reopen-golden-evidence"
|
||||||
],
|
],
|
||||||
"acceptance": [
|
"acceptance": [
|
||||||
"web:e2e:N-026 release",
|
"web:e2e:N-026 release",
|
||||||
@@ -414,15 +488,15 @@
|
|||||||
"performance": {
|
"performance": {
|
||||||
"geometry1M": true,
|
"geometry1M": true,
|
||||||
"geometry10M": false,
|
"geometry10M": false,
|
||||||
"texture4K": false,
|
"texture4K": true,
|
||||||
"texture8K": false,
|
"texture8K": true,
|
||||||
"longMedia": false,
|
"longMedia": false,
|
||||||
"simulationCache": false
|
"simulationCache": true
|
||||||
},
|
},
|
||||||
"faults": {
|
"faults": {
|
||||||
"oom": false,
|
"oom": false,
|
||||||
"deviceLoss": false,
|
"deviceLoss": true,
|
||||||
"networkInterrupt": false,
|
"networkInterrupt": true,
|
||||||
"malformedBlend": true,
|
"malformedBlend": true,
|
||||||
"zipBomb": true
|
"zipBomb": true
|
||||||
},
|
},
|
||||||
@@ -441,7 +515,7 @@
|
|||||||
],
|
],
|
||||||
"command": "npm --prefix web run release:sbom",
|
"command": "npm --prefix web run release:sbom",
|
||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"durationMs": 361,
|
"durationMs": 372,
|
||||||
"output": "> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
"output": "> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||||
"artifactSha256": [
|
"artifactSha256": [
|
||||||
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||||
@@ -449,6 +523,21 @@
|
|||||||
"3c65ff96cc15c9072d2517555484ccfd0009a2814d96faadcc5bd7b2e3458503"
|
"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",
|
"id": "chromium",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -459,10 +548,10 @@
|
|||||||
],
|
],
|
||||||
"command": "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser",
|
"command": "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser",
|
||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"durationMs": 147877,
|
"durationMs": 219085,
|
||||||
"output": "and LOD profiles at the protocol boundary (1.1s)\n ✓ 73 tests/e2e/smoke.spec.ts:1866:1 › normalizes skin influences and rejects shape-key loss explicitly (1.1s)\n ✓ 74 tests/e2e/smoke.spec.ts:1891:1 › remaps skin weights and shape keys through the native Collapse worker path (1.4s)\n ✓ 75 tests/e2e/smoke.spec.ts:1947:1 › reports GLB export blockers before any binary export (1.1s)\n ✓ 76 tests/e2e/smoke.spec.ts:1959:1 › blocks Shader graphs that cannot be mapped to glTF PBR (1.0s)\n ✓ 77 tests/e2e/smoke.spec.ts:1986:1 › maps bounded RGB and Value Shader constants to glTF PBR factors (1.0s)\n ✓ 78 tests/e2e/smoke.spec.ts:2019:1 › exports a local SceneIR mesh as a standards-shaped GLB (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2052:1 › keeps per-vertex UV and color attributes in GLB output (1.0s)\n ✓ 80 tests/e2e/smoke.spec.ts:2079:1 › evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.1s)\n ✓ 81 tests/e2e/smoke.spec.ts:2094:1 › embeds local textures and exports glTF skin and animation records (1.1s)\n ✓ 82 tests/e2e/smoke.spec.ts:2144:1 › reports lightweight budget violations without altering usage (1.0s)\n ✓ 83 tests/e2e/smoke.spec.ts:2162:1 › aggregates project, collection, object and LOD budgets without double counting LOD (1.0s)\n ✓ 84 tests/e2e/smoke.spec.ts:2188:1 › round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 85 tests/e2e/smoke.spec.ts:2212:1 › blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 86 tests/e2e/smoke.spec.ts:2255:1 › extracts Blender packed image bytes through the local asset request API (1.3s)\n ✓ 87 tests/e2e/smoke.spec.ts:2300:1 › matches Blender Depsgraph deformation golden within the declared error budget (1.4s)\n ✓ 88 tests/e2e/smoke.spec.ts:2339:1 › evaluates the full Blender Depsgraph or reports its safe capability gate (1.4s)\n ✓ 89 tests/e2e/smoke.spec.ts:2399:1 › exports layered Action keyframes through SceneIR (1.3s)\n ✓ 90 tests/e2e/smoke.spec.ts:2440:1 › patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 91 tests/e2e/smoke.spec.ts:2464:1 › renders through the capability-gated OffscreenCanvas worker (1.3s)\n ✓ 92 tests/e2e/smoke.spec.ts:2477:1 › coalesces linked mesh objects into a raycastable instance group (1.9s)\n ✓ 93 tests/e2e/smoke.spec.ts:2485:1 › returns structured gates for the undeclared capability protocols (1.2s)\n ✓ 94 tests/e2e/smoke.spec.ts:2544:1 › exposes PBR-007 to PBR-012 renderer security gates (1.1s)\n ✓ 95 tests/e2e/smoke.spec.ts:2578:1 › transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 96 tests/e2e/smoke.spec.ts:2587:1 › uses the same packed texture payload in the OffscreenCanvas renderer (1.9s)\n\n 96 passed (2.3m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] › tests/e2e/cross-browser.spec.ts:6:1 › boots the offline engine, renders SceneIR and performs a Main edit (2.8s)\n ✓ 2 [chromium] › tests/e2e/cross-browser.spec.ts:32:1 › keeps content-addressed asset recovery available in Chromium (1.2s)\n ✓ 3 [chromium] › tests/e2e/cross-browser.spec.ts:51:1 › keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.7s)\n\n[WebServer] (node:1350053) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1350065) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1355845) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1355857) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
"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": [
|
"artifactSha256": [
|
||||||
"c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c"
|
"5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -472,10 +561,25 @@
|
|||||||
],
|
],
|
||||||
"command": "npm --prefix web run test:release-performance",
|
"command": "npm --prefix web run test:release-performance",
|
||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"durationMs": 89206,
|
"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\":88134,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":535,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.2166599999909522 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.10756300001230557 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.04437800000596326 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 1.4113389999984065 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.4725370000087423 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4998959999938961 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.49847900000168 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.4405399999959627 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.4891829999978654 msecs. Success: true",
|
"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": [
|
"artifactSha256": [
|
||||||
"c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c"
|
"5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "simulation-cache",
|
||||||
|
"fields": [
|
||||||
|
"performance.simulationCache"
|
||||||
|
],
|
||||||
|
"command": "npm --prefix web run test:simulation-cache-performance",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 7549,
|
||||||
|
"output": "> blender-web-editor@0.1.0 test:simulation-cache-performance\n> playwright test --config playwright.config.ts tests/e2e/simulation-cache-performance.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/simulation-cache-performance.spec.ts:3:1 › meets the Chromium OPFS Simulation cache playback performance gate (5.1s)\n\n 1 passed (6.7s)\n\n[WebServer] (node:1994548) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1994560) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
||||||
|
"artifactSha256": [
|
||||||
|
"c8074425a4556c8d08c2382dc9fd7be8aa6078d11a45640c4b19bf6ef45aa590",
|
||||||
|
"331d5c6a59dc80a12cc7105abbd60d25a64a113809eae9ad3d4061d81ec5f59f",
|
||||||
|
"535285f629b981e63e423f16df47d90afea4f5f17fca2bef36d0f6a8236697b2"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -485,12 +589,68 @@
|
|||||||
],
|
],
|
||||||
"command": "npm --prefix web run test:malicious-blends",
|
"command": "npm --prefix web run test:malicious-blends",
|
||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"durationMs": 561,
|
"durationMs": 541,
|
||||||
"output": "> blender-web-editor@0.1.0 test:malicious-blends\n> node ../tools/web/check-malicious-blends.mjs\n\nmalicious-blends-ok rejected=5",
|
"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": [
|
"artifactSha256": [
|
||||||
"6fcc55bda74da8c95da96ba068b60339bf60ca44147ff8000fbb95d296db2a73"
|
"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",
|
"id": "zip-bomb",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -498,10 +658,10 @@
|
|||||||
],
|
],
|
||||||
"command": "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library",
|
"command": "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library",
|
||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"durationMs": 4132,
|
"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:711:1 › validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.3s)\n\n[WebServer] (node:1356360) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1356372) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
"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": [
|
"artifactSha256": [
|
||||||
"6169748c5bf78a8e101196e964db67b8a65c2ba95bb3b4a54e052721be01a7bf"
|
"5c62539db3833a2113007dfbdfd12ca06704d86e0cf0e57ad97f7fbf6acda7f4"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -509,11 +669,11 @@
|
|||||||
"fields": [],
|
"fields": [],
|
||||||
"command": "npm --prefix web run test:release-package",
|
"command": "npm --prefix web run test:release-package",
|
||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"durationMs": 4871,
|
"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...✓ 45 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-hjSYzFAQ.js 36.77 kB\ndist/assets/web-engine.worker-BpNH8d2z.js 243.09 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-B3UcZYVN.js 890.91 kB │ gzip: 239.96 kB\n\n✓ built in 561ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=31935334\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
|
"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": [
|
"artifactSha256": [
|
||||||
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||||
"c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c"
|
"5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -524,12 +684,12 @@
|
|||||||
],
|
],
|
||||||
"command": "npm --prefix web run release:offline",
|
"command": "npm --prefix web run release:offline",
|
||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"durationMs": 36624,
|
"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...✓ 45 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-hjSYzFAQ.js 36.77 kB\ndist/assets/web-engine.worker-BpNH8d2z.js 243.09 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-B3UcZYVN.js 890.91 kB │ gzip: 239.96 kB\n\n✓ built in 563ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7542083 source=205692822 sha256=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7542083 source=205692822 sha256=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b\noffline-reproducibility-ok binary=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b source=ee2cbbe62f42abae959bd5e3d1aa4cc5bee491ae23a85fa50b7519dc6a2549b0\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
|
"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": [
|
"artifactSha256": [
|
||||||
"2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b",
|
"078f45513ab579800ab3688f7c5e35d31be0d9dc283db5cee1a68d77553bfb34",
|
||||||
"ee2cbbe62f42abae959bd5e3d1aa4cc5bee491ae23a85fa50b7519dc6a2549b0",
|
"ca97bef8cbe7943586e7af1df631775ce6ea041677c8bbb98da2fcc73baa9e1a",
|
||||||
"3d807f874e9703322a69318c856ff493dd7489357f06dc78f3496c7a46a30612"
|
"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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Emscripten runtime | 保留 | Blender C/C++ to WebAssembly | W-020~W-025 |
|
||||||
|
|||||||
Binary file not shown.
@@ -155,7 +155,7 @@
|
|||||||
"path": "compositor_scene.blend",
|
"path": "compositor_scene.blend",
|
||||||
"objects": 0,
|
"objects": 0,
|
||||||
"meshes": 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",
|
"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");
|
assert.equal(scene?.compositorStatus, "AVAILABLE");
|
||||||
const graph = scene.compositorGraph;
|
const graph = scene.compositorGraph;
|
||||||
assert.equal(graph.schemaVersion, 1);
|
assert.equal(graph.schemaVersion, 1);
|
||||||
assert.equal(graph.nodes.length, 4);
|
assert.equal(graph.nodes.length, 6);
|
||||||
assert.equal(graph.links.length, 2);
|
assert.equal(graph.links.length, 4);
|
||||||
const color = graph.nodes.find((node) => node.name === "WebConstantColor");
|
const color = graph.nodes.find((node) => node.name === "WebConstantColor");
|
||||||
assert.equal(color.type, "CONSTANT_COLOR");
|
assert.equal(color.type, "CONSTANT_COLOR");
|
||||||
assert.deepEqual(color.properties.color, [0.125, 0.25, 0.5, 0.75]);
|
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");
|
assert.equal(graph.nodes.find((node) => node.name === "WebViewer").type, "VIEWER");
|
||||||
const composite = graph.nodes.find((node) => node.name === "WebComposite");
|
const composite = graph.nodes.find((node) => node.name === "WebComposite");
|
||||||
assert.equal(composite.type, "COMPOSITE");
|
assert.equal(composite.type, "COMPOSITE");
|
||||||
assert.equal(graph.outputNodeId, composite.id);
|
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");
|
const unsupported = graph.nodes.find((node) => node.name === "PreservedUnsupportedGlare");
|
||||||
assert.equal(unsupported.type, "UNSUPPORTED");
|
assert.equal(unsupported.type, "UNSUPPORTED");
|
||||||
assert.equal(unsupported.blenderType, "CompositorNodeGlare");
|
assert.equal(unsupported.blenderType, "CompositorNodeGlare");
|
||||||
assert.ok(graph.links.every((link) => graph.nodes.some((node) => node.id === link.fromNodeId) &&
|
assert.ok(graph.links.every((link) => graph.nodes.some((node) => node.id === link.fromNodeId) &&
|
||||||
graph.nodes.some((node) => node.id === link.toNodeId)));
|
graph.nodes.some((node) => node.id === link.toNodeId)));
|
||||||
engine._web_engine_destroy(handle);
|
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);
|
assert.equal(project.masks.length, 1);
|
||||||
const mask = project.masks[0];
|
const mask = project.masks[0];
|
||||||
assert.equal(mask.id, "mask:WebMask");
|
assert.equal(mask.id, "mask:WebMask");
|
||||||
assert.equal(mask.layers.length, 1);
|
assert.equal(mask.layers.length, 2);
|
||||||
const layer = mask.layers[0];
|
const layer = mask.layers.find((candidate) => candidate.name === "WebMaskLayer");
|
||||||
assert.equal(layer.name, "WebMaskLayer");
|
assert.equal(layer.name, "WebMaskLayer");
|
||||||
assert.equal(layer.locked, true);
|
assert.equal(layer.locked, true);
|
||||||
close(layer.opacity, 0.625, "layer opacity");
|
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");
|
close(spline.points[2].feather, 0.75, "point feather");
|
||||||
assert.deepEqual(spline.points.map((point) => point.selected), [true, false, true]);
|
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);
|
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 surface = before.nonMeshData.find((data) => data.type === "SURFACE");
|
||||||
const font = before.nonMeshData.find((data) => data.type === "FONT");
|
const font = before.nonMeshData.find((data) => data.type === "FONT");
|
||||||
const metaball = before.nonMeshData.find((data) => data.type === "METABALL");
|
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(curve?.controlPoints?.length && curve.splineOffsets?.length);
|
||||||
assert.ok(surface?.controlPoints?.length && surface.splineOffsets?.length);
|
assert.ok(surface?.controlPoints?.length && surface.splineOffsets?.length);
|
||||||
assert.deepEqual(surface.splineDimensions, [{ u: 4, v: 4, orderU: 4, orderV: 4 }]);
|
assert.deepEqual(surface.splineDimensions, [{ u: 4, v: 4, orderU: 4, orderV: 4 }]);
|
||||||
assert.equal(surface.pointWeights?.length, 16);
|
assert.equal(surface.pointWeights?.length, 16);
|
||||||
assert.ok(font?.text && metaball?.elements?.length);
|
assert.ok(font?.text && metaball?.elements?.length);
|
||||||
|
assert.ok(volume?.sourcePath && volume?.volumeProperties);
|
||||||
assert.equal(before.vfonts?.length, 2);
|
assert.equal(before.vfonts?.length, 2);
|
||||||
assert.ok(before.vfonts.every((resource) => resource.packed && resource.id.startsWith("vfont:")));
|
assert.ok(before.vfonts.every((resource) => resource.packed && resource.id.startsWith("vfont:")));
|
||||||
assert.ok(font.fontLinks);
|
assert.ok(font.fontLinks);
|
||||||
@@ -93,6 +95,27 @@ assert.equal(curve.handleTypes?.length, 6);
|
|||||||
assert.equal(curve.handlePoints?.length, 18);
|
assert.equal(curve.handlePoints?.length, 18);
|
||||||
assert.deepEqual(curve.handlePointIndices, [4, 5, 6]);
|
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" });
|
command(engine, handle, { type: "renameId", id: curve.id, name: "WebCurveRenamed" });
|
||||||
const renamedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.type === "CURVE" && data.name === "WebCurveRenamed");
|
const renamedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.type === "CURVE" && data.name === "WebCurveRenamed");
|
||||||
assert.equal(renamedCurve?.id, "curve:WebCurveRenamed");
|
assert.equal(renamedCurve?.id, "curve:WebCurveRenamed");
|
||||||
@@ -284,6 +307,9 @@ engine._web_engine_destroy(handle);
|
|||||||
const reopened = engine._web_engine_create();
|
const reopened = engine._web_engine_create();
|
||||||
open(engine, reopened, saved);
|
open(engine, reopened, saved);
|
||||||
const roundtripped = snapshot(engine, reopened);
|
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");
|
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.deepEqual(roundtripped.nonMeshData.find((data) => data.id === surface.id).splineDimensions, surfaceDimensions);
|
||||||
assert.equal(roundtripped.nonMeshData.find((data) => data.id === surface.id).pointCount, 20);
|
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 });
|
command(engine, reopened, { type: "deleteNonMeshData", dataId: createdAfterReopen.id });
|
||||||
assert.equal(snapshot(engine, reopened).nonMeshData.some((data) => data.id === createdAfterReopen.id), false);
|
assert.equal(snapshot(engine, reopened).nonMeshData.some((data) => data.id === createdAfterReopen.id), false);
|
||||||
engine._web_engine_destroy(reopened);
|
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;
|
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 engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||||
|
|
||||||
const colorHandle = engine._web_engine_create();
|
const colorHandle = engine._web_engine_create();
|
||||||
@@ -126,6 +131,7 @@ apply(engine, weightHandle, {
|
|||||||
normalize: false,
|
normalize: false,
|
||||||
});
|
});
|
||||||
let weightedMesh = snapshot(engine, weightHandle).meshes.find((mesh) => mesh.id === meshId);
|
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, 0, "WebPaintGroup"), 0.75, "vertex 0 paint weight");
|
||||||
close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.25, "vertex 1 paint weight");
|
close(vertexWeight(weightedMesh, 1, "WebPaintGroup"), 0.25, "vertex 1 paint weight");
|
||||||
apply(engine, weightHandle, {
|
apply(engine, weightHandle, {
|
||||||
@@ -159,6 +165,7 @@ engine._web_engine_destroy(weightHandle);
|
|||||||
const reopenedWeights = engine._web_engine_create();
|
const reopenedWeights = engine._web_engine_create();
|
||||||
open(engine, reopenedWeights, savedWeights);
|
open(engine, reopenedWeights, savedWeights);
|
||||||
weightedMesh = snapshot(engine, reopenedWeights).meshes.find((mesh) => mesh.id === meshId);
|
weightedMesh = snapshot(engine, reopenedWeights).meshes.find((mesh) => mesh.id === meshId);
|
||||||
|
assertVertexGroup(weightedMesh, "WebPaintGroup");
|
||||||
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "reopened vertex 0 paint weight");
|
close(vertexWeight(weightedMesh, 0, "WebPaintGroup"), 0.75, "reopened vertex 0 paint weight");
|
||||||
close(vertexWeight(weightedMesh, 2, "WebPaintGroup"), 0.5 / 1.75, "reopened normalized vertex 2 paint weight");
|
close(vertexWeight(weightedMesh, 2, "WebPaintGroup"), 0.5 / 1.75, "reopened normalized vertex 2 paint weight");
|
||||||
engine._web_engine_destroy(reopenedWeights);
|
engine._web_engine_destroy(reopenedWeights);
|
||||||
|
|||||||
@@ -25,10 +25,19 @@ try {
|
|||||||
const evaluation = module.evaluateReleaseManifest(parsed);
|
const evaluation = module.evaluateReleaseManifest(parsed);
|
||||||
assert.equal(evaluation.status, "BLOCKED");
|
assert.equal(evaluation.status, "BLOCKED");
|
||||||
assert.ok(evaluation.missing.includes("performance.geometry10M"));
|
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.equal(evaluation.missing.includes("browser.chromium"), false);
|
||||||
assert.ok(parsed.evidence.records.length >= 7);
|
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("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`);
|
process.stdout.write(`release-evidence-check-ok records=${parsed.evidence.records.length} missing=${evaluation.missing.length} status=${evaluation.status}\n`);
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ try {
|
|||||||
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
fs.writeFileSync(path.join(temporary, `${name}.cjs`), output);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { gateScriptExecution, gateServerScriptJob } = require(path.join(temporary, "scripting-platform.cjs"));
|
const { createScriptExecutionAudit, gateScriptExecution, gateServerScriptJob } = require(path.join(temporary, "scripting-platform.cjs"));
|
||||||
const digest = "a".repeat(64);
|
const digest = "a".repeat(64);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -56,7 +56,13 @@ try {
|
|||||||
assert.equal(approved.issues[0]?.code, "SCRIPT_SANDBOX_UNAVAILABLE");
|
assert.equal(approved.issues[0]?.code, "SCRIPT_SANDBOX_UNAVAILABLE");
|
||||||
assert.equal(server.status, "BLOCKED");
|
assert.equal(server.status, "BLOCKED");
|
||||||
assert.equal(server.issues[0]?.code, "SERVER_JOB_UNAVAILABLE");
|
assert.equal(server.issues[0]?.code, "SERVER_JOB_UNAVAILABLE");
|
||||||
process.stdout.write("scripting-isolation-ok status=BLOCKED approved-key=sandbox-unavailable server=unavailable execution=disabled\n");
|
const audit = await createScriptExecutionAudit(manifest, "clean", new Set(["approved-key"]), { requestId: "isolation-check", requestedAt: "2026-08-12T12:00:00.000Z" });
|
||||||
|
assert.equal(audit.decision, "DENY");
|
||||||
|
assert.equal(audit.reason, "SCRIPT_SANDBOX_UNAVAILABLE");
|
||||||
|
assert.equal(audit.approvedKey, true);
|
||||||
|
assert.match(audit.manifestSha256, /^[a-f0-9]{64}$/);
|
||||||
|
assert.match(audit.requestSha256, /^[a-f0-9]{64}$/);
|
||||||
|
process.stdout.write("scripting-isolation-ok status=BLOCKED audit=DENY approved-key=sandbox-unavailable server=unavailable execution=disabled\n");
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
fs.rmSync(temporary, { recursive: true, force: true });
|
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");
|
const image = timeline.strips.find((strip) => strip.name === "WebImage");
|
||||||
assert.equal(image.type, "IMAGE");
|
assert.equal(image.type, "IMAGE");
|
||||||
assert.equal(image.sourcePath, "//media/sequencer-frame.png");
|
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");
|
const cross = timeline.strips.find((strip) => strip.name === "WebCross");
|
||||||
assert.equal(cross.type, "EFFECT");
|
assert.equal(cross.type, "EFFECT");
|
||||||
assert.equal(cross.effectType, "CROSS");
|
assert.equal(cross.effectType, "CROSS");
|
||||||
assert.equal(cross.inputStripIds.length, 2);
|
assert.equal(cross.inputStripIds.length, 2);
|
||||||
assert.ok(cross.inputStripIds.every((id) => timeline.strips.some((strip) => strip.id === id)));
|
assert.ok(cross.inputStripIds.every((id) => timeline.strips.some((strip) => strip.id === id)));
|
||||||
engine._web_engine_destroy(handle);
|
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 root = path.resolve(new URL("../..", import.meta.url).pathname);
|
||||||
const cachePath = path.join(root, "build_web_blender6", "CMakeCache.txt");
|
const cachePath = path.join(root, "build_web_blender6", "CMakeCache.txt");
|
||||||
const cache = fs.readFileSync(cachePath, "utf8");
|
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_OPENVDB:BOOL=OFF$/m, "Browser OpenVDB must remain disabled; conversion belongs to desktop/server targets");
|
||||||
assert.match(cache, /^WITH_NANOVDB:BOOL=ON$/m, "NanoVDB metadata support is expected");
|
|
||||||
|
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 roots = [path.join(root, "resource-library"), path.join(root, "tests"), "/home/mes123456/resource-library"];
|
||||||
const vdbFiles = [];
|
const vdbFiles = [];
|
||||||
@@ -15,9 +20,22 @@ function scan(directory) {
|
|||||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||||
const fullPath = path.join(directory, entry.name);
|
const fullPath = path.join(directory, entry.name);
|
||||||
if (entry.isDirectory()) scan(fullPath);
|
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);
|
for (const directory of roots) scan(directory);
|
||||||
assert.equal(vdbFiles.length, 0, `unexpected local VDB resource(s): ${vdbFiles.join(", ")}`);
|
const converterCandidates = [
|
||||||
process.stdout.write("vdb-availability-ok status=BLOCKED openvdb=disabled nanovdb=metadata-only local-vdb=0 renderer=blocked decoder=blocked\n");
|
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("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("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("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("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("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("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"]);
|
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 };
|
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.ok(manifest.families.some((family) => family.status === "BLOCKED"));
|
||||||
assert.equal(manifest.evidence.performance.geometry10M, false);
|
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`);
|
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`);
|
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.name = "WebConstantColor"
|
||||||
color.outputs["Color"].default_value = (0.125, 0.25, 0.5, 0.75)
|
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 = tree.nodes.new("CompositorNodeViewer")
|
||||||
viewer.name = "WebViewer"
|
viewer.name = "WebViewer"
|
||||||
composite = tree.nodes.new("NodeGroupOutput")
|
composite = tree.nodes.new("NodeGroupOutput")
|
||||||
@@ -23,8 +29,10 @@ def main(output_path):
|
|||||||
glare = tree.nodes.new("CompositorNodeGlare")
|
glare = tree.nodes.new("CompositorNodeGlare")
|
||||||
glare.name = "PreservedUnsupportedGlare"
|
glare.name = "PreservedUnsupportedGlare"
|
||||||
|
|
||||||
tree.links.new(color.outputs["Color"], viewer.inputs["Image"])
|
tree.links.new(color.outputs["Color"], exposure.inputs["Image"])
|
||||||
tree.links.new(color.outputs["Color"], composite.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 = pathlib.Path(output_path).resolve()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ def main(output_path):
|
|||||||
point.weight = weight
|
point.weight = weight
|
||||||
point.select = selected
|
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 = pathlib.Path(output_path).resolve()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
|
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"id": "web-engine-bootstrap",
|
"id": "web-engine-bootstrap",
|
||||||
"fileName": "web_engine.wasm",
|
"fileName": "web_engine.wasm",
|
||||||
"url": "/vendor/blender/web_engine.wasm",
|
"url": "/vendor/blender/web_engine.wasm",
|
||||||
"sha256": "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c",
|
"sha256": "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
|
||||||
"required": true
|
"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 { ViewportRenderer } from "../three-adapter/viewport";
|
||||||
import { acquireOffscreenViewportRenderer, OffscreenViewportRenderer, releaseOffscreenViewportRenderer, supportsOffscreenViewport, type ViewportBackend } from "../three-adapter/offscreen-viewport";
|
import { acquireOffscreenViewportRenderer, OffscreenViewportRenderer, releaseOffscreenViewportRenderer, supportsOffscreenViewport, type ViewportBackend } from "../three-adapter/offscreen-viewport";
|
||||||
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
|
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
|
||||||
|
import type { GreasePencilPointPreview, GreasePencilPointRef } from "../three-adapter/grease-pencil";
|
||||||
import { buildLODCacheKey } from "../three-adapter/lod";
|
import { buildLODCacheKey } from "../three-adapter/lod";
|
||||||
import { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache";
|
import { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache";
|
||||||
import type { LODCacheRecord } from "../../../protocol/lod";
|
import type { LODCacheRecord } from "../../../protocol/lod";
|
||||||
@@ -20,8 +21,16 @@ import { modifierStackHash } from "../../../protocol/modifier";
|
|||||||
import { exportGLB } from "../../../protocol/glb-export";
|
import { exportGLB } from "../../../protocol/glb-export";
|
||||||
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export";
|
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export";
|
||||||
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
|
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 { 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 { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
|
||||||
import "./app-shell.css";
|
import "./app-shell.css";
|
||||||
|
|
||||||
@@ -34,6 +43,11 @@ function errorMessage(error: unknown): string {
|
|||||||
return String(error);
|
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 {
|
interface AreaProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
editor: EditorType;
|
editor: EditorType;
|
||||||
@@ -62,34 +76,143 @@ interface MeshEditSelection {
|
|||||||
indices: Set<number>;
|
indices: Set<number>;
|
||||||
nonMeshKind?: NonMeshElementKind;
|
nonMeshKind?: NonMeshElementKind;
|
||||||
nonMeshSelections?: Map<NonMeshElementKind, Set<number>>;
|
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;
|
snapshot: SceneSnapshotIR | null;
|
||||||
geometryBuffers: MeshGeometryBuffer[];
|
geometryBuffers: MeshGeometryBuffer[];
|
||||||
nonMeshGeometryBuffers: NonMeshGeometryChunk[];
|
nonMeshGeometryBuffers: NonMeshGeometryChunk[];
|
||||||
textureAssets: GPUTextureAsset[];
|
textureAssets: GPUTextureAsset[];
|
||||||
|
volumeProject: NanoVDBViewportProjectContextIR | null;
|
||||||
lodLevels: Record<string, WebEngineLODLevelResult[]> | null;
|
lodLevels: Record<string, WebEngineLODLevelResult[]> | null;
|
||||||
selectedObjectIds: ReadonlySet<string>;
|
selectedObjectIds: ReadonlySet<string>;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
meshSelection: MeshEditSelection;
|
meshSelection: MeshEditSelection;
|
||||||
onSelect: (id: string, additive: boolean) => void;
|
onSelect: (id: string, additive: boolean) => void;
|
||||||
onElementSelect: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => 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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const rendererRef = useRef<ViewportBackend | null>(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 [viewportError, setViewportError] = useState<string | null>(null);
|
||||||
const [activeTool, setActiveTool] = useState<"translate" | "rotate" | "scale">("translate");
|
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(() => {
|
useEffect(() => {
|
||||||
if (!canvasRef.current) return;
|
if (!canvasRef.current) return;
|
||||||
try {
|
try {
|
||||||
const offscreenRequested = new URLSearchParams(window.location.search).get("offscreen") === "1";
|
const offscreenRequested = new URLSearchParams(window.location.search).get("offscreen") === "1";
|
||||||
const canvas = canvasRef.current;
|
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)
|
const renderer = offscreenRequested && supportsOffscreenViewport(canvas)
|
||||||
? acquireOffscreenViewportRenderer(canvas, onSelect, onElementSelect)
|
? acquireOffscreenViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint)
|
||||||
: new ViewportRenderer(canvas, onSelect, onElementSelect);
|
: new ViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint);
|
||||||
rendererRef.current = renderer;
|
rendererRef.current = renderer;
|
||||||
return () => {
|
return () => {
|
||||||
rendererRef.current = null;
|
rendererRef.current = null;
|
||||||
@@ -121,14 +244,59 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
|
|||||||
const elementSelection = meshSelection.meshId && meshSelection.nonMeshSelections
|
const elementSelection = meshSelection.meshId && meshSelection.nonMeshSelections
|
||||||
? new Map([[meshSelection.meshId, meshSelection.nonMeshSelections]])
|
? new Map([[meshSelection.meshId, meshSelection.nonMeshSelections]])
|
||||||
: undefined;
|
: undefined;
|
||||||
renderer?.setSelection(selectedObjectIds, elementSelection);
|
renderer?.setSelection(selectedObjectIds, elementSelection, meshSelection.greasePencilPoints);
|
||||||
renderer?.setInteractionMode(editMode, meshSelection.mode);
|
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(() => {
|
useEffect(() => {
|
||||||
rendererRef.current?.setTextureAssets(textureAssets);
|
rendererRef.current?.setTextureAssets(textureAssets);
|
||||||
}, [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 (
|
return (
|
||||||
<div className="viewport-placeholder" role="img" aria-label="Three.js 视口占位区域">
|
<div className="viewport-placeholder" role="img" aria-label="Three.js 视口占位区域">
|
||||||
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
|
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
|
||||||
@@ -143,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 === "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>
|
<button type="button" className={`tool-button${activeTool === "scale" ? " active" : ""}`} aria-label="缩放工具" title="缩放" onClick={() => setActiveTool("scale")}>⤢</button>
|
||||||
</div>
|
</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 start = { x: event.clientX, y: event.clientY };
|
||||||
|
const screenAxis = curveGizmoScreenFrame?.axes[axis];
|
||||||
|
const axisVector = curveGizmo?.frame.axes[axis];
|
||||||
const pointerId = event.pointerId;
|
const pointerId = event.pointerId;
|
||||||
event.currentTarget.setPointerCapture(pointerId);
|
event.currentTarget.setPointerCapture(pointerId);
|
||||||
const target = event.currentTarget;
|
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 => {
|
const finish = (up: PointerEvent): void => {
|
||||||
|
target.removeEventListener("pointermove", move);
|
||||||
target.removeEventListener("pointerup", finish);
|
target.removeEventListener("pointerup", finish);
|
||||||
target.removeEventListener("pointercancel", finish);
|
target.removeEventListener("pointercancel", finish);
|
||||||
const distance = (up.clientX - start.x) - (up.clientY - start.y);
|
if (previewDataId) rendererRef.current?.setCurveHandlePreview(previewDataId, null);
|
||||||
if (Math.abs(distance) >= 2) onTransform(activeTool, distance / 100, axis);
|
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("pointerup", finish);
|
||||||
target.addEventListener("pointercancel", finish);
|
target.addEventListener("pointercancel", finish);
|
||||||
}}>{"XYZ"[axis]}</button>)}</div> : null}
|
}}>{"XYZ"[axis]}</button>)}</div> : null}
|
||||||
<aside className="viewport-sidebar" aria-label="视口侧栏">
|
<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>View</span>
|
||||||
<span>Item</span>
|
<span>Item</span>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -190,10 +374,11 @@ function Outliner({ snapshot, onSelect, onToggleVisibility }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
|
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, greasePencilPointSelection, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
|
||||||
snapshot: SceneSnapshotIR | null;
|
snapshot: SceneSnapshotIR | null;
|
||||||
selectedFaceIndices: number[];
|
selectedFaceIndices: number[];
|
||||||
selectedVertexIndices: number[];
|
selectedVertexIndices: number[];
|
||||||
|
greasePencilPointSelection: readonly GreasePencilPointRef[];
|
||||||
onCommand: (command: WebEngineEditCommand) => void;
|
onCommand: (command: WebEngineEditCommand) => void;
|
||||||
onImportImage: (file: File) => void;
|
onImportImage: (file: File) => void;
|
||||||
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
|
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
|
||||||
@@ -225,6 +410,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
|
|||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
const [paintColor, setPaintColor] = useState("#cc6633");
|
const [paintColor, setPaintColor] = useState("#cc6633");
|
||||||
const [paintWeight, setPaintWeight] = useState(1);
|
const [paintWeight, setPaintWeight] = useState(1);
|
||||||
|
const [paintSelectionMask, setPaintSelectionMask] = useState(0.5);
|
||||||
const [paintGroup, setPaintGroup] = useState("WebPaint");
|
const [paintGroup, setPaintGroup] = useState("WebPaint");
|
||||||
const [greasePencilLayerId, setGreasePencilLayerId] = useState("");
|
const [greasePencilLayerId, setGreasePencilLayerId] = useState("");
|
||||||
const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer");
|
const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer");
|
||||||
@@ -234,6 +420,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
|
|||||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||||
const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined;
|
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 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]);
|
const activeMaterial = snapshot?.materials.find((material) => material.id === activeMesh?.materialSlotIds?.[0]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeMaterial) return;
|
if (!activeMaterial) return;
|
||||||
@@ -252,11 +439,46 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
|
|||||||
if (!activeGreasePencil) setGreasePencilLayerId("");
|
if (!activeGreasePencil) setGreasePencilLayerId("");
|
||||||
else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? "");
|
else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? "");
|
||||||
}, [activeGreasePencil, greasePencilLayerId]);
|
}, [activeGreasePencil, greasePencilLayerId]);
|
||||||
const activeGreasePencilFrame = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.find((entry) => entry.frame === (snapshot?.frame.current ?? 1));
|
useEffect(() => {
|
||||||
|
if (!selectedGreasePencilPoint) return;
|
||||||
|
setGreasePencilLayerId(selectedGreasePencilPoint.layerId);
|
||||||
|
setGreasePencilStrokeIndex(selectedGreasePencilPoint.strokeIndex);
|
||||||
|
setGreasePencilPointIndex(selectedGreasePencilPoint.pointIndex);
|
||||||
|
}, [selectedGreasePencilPoint?.dataId, selectedGreasePencilPoint?.layerId, selectedGreasePencilPoint?.frame, selectedGreasePencilPoint?.strokeIndex, selectedGreasePencilPoint?.pointIndex]);
|
||||||
|
const activeGreasePencilFrame = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.find((entry) => entry.frame === (selectedGreasePencilPoint?.frame ?? snapshot?.frame.current ?? 1));
|
||||||
const activeGreasePencilStroke = activeGreasePencilFrame?.drawing.strokes[greasePencilStrokeIndex];
|
const activeGreasePencilStroke = activeGreasePencilFrame?.drawing.strokes[greasePencilStrokeIndex];
|
||||||
const activeGreasePencilPoint = activeGreasePencilStroke?.points?.[greasePencilPointIndex];
|
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 => {
|
const translateGreasePencilPoint = (): void => {
|
||||||
if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return;
|
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({
|
const result = applyGreasePencilPointTranslation({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
revision: snapshot.revision,
|
revision: snapshot.revision,
|
||||||
@@ -264,8 +486,8 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
|
|||||||
layerId: greasePencilLayerId,
|
layerId: greasePencilLayerId,
|
||||||
frame: activeGreasePencilFrame.frame,
|
frame: activeGreasePencilFrame.frame,
|
||||||
onionSkinning: activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.onionSkinning ?? false,
|
onionSkinning: activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.onionSkinning ?? false,
|
||||||
selectedStrokeIndices: [greasePencilStrokeIndex],
|
selectedStrokeIndices: [...new Set(selectedPoints.map((point) => point.strokeIndex))],
|
||||||
selectedPoints: [{ strokeIndex: greasePencilStrokeIndex, pointIndex: greasePencilPointIndex }],
|
selectedPoints,
|
||||||
}, activeGreasePencilFrame.drawing.strokes, {
|
}, activeGreasePencilFrame.drawing.strokes, {
|
||||||
type: "TRANSLATE_POINTS",
|
type: "TRANSLATE_POINTS",
|
||||||
revision: snapshot.revision,
|
revision: snapshot.revision,
|
||||||
@@ -321,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}
|
{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>
|
<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}
|
{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); 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>{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}
|
{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><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}
|
{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">
|
{activeMesh ? <div className="property-section">
|
||||||
<h3>Material Slots</h3>
|
<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>
|
<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>
|
||||||
@@ -365,14 +587,22 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OperatorSearch({ onClose }: { onClose: () => void }) {
|
interface OperatorCommand {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
keywords: string;
|
||||||
|
execute: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperatorSearch({ commands, onClose }: { commands: readonly OperatorCommand[]; onClose: () => void }) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const operators = ["Add Cube", "Apply Transform", "Frame Selected", "Save Project"];
|
const normalized = query.trim().toLowerCase();
|
||||||
const matches = operators.filter((operator) => operator.toLowerCase().includes(query.toLowerCase()));
|
const matches = commands.filter((command) => `${command.label} ${command.keywords}`.toLowerCase().includes(normalized));
|
||||||
|
const run = (command: OperatorCommand): void => { command.execute(); onClose(); };
|
||||||
return (
|
return (
|
||||||
<div className="operator-search" role="dialog" aria-label="Operator Search">
|
<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="搜索操作" />
|
<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((operator) => <button key={operator} type="button" onClick={onClose}>{operator}</button>)}</div>
|
<div className="operator-results">{matches.map((command) => <button key={command.id} type="button" onClick={() => run(command)}>{command.label}</button>)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -395,13 +625,19 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
|
|||||||
const third = Math.round(start + (end - start) * 0.6);
|
const third = Math.round(start + (end - start) * 0.6);
|
||||||
const fourth = Math.round(start + (end - start) * 0.8);
|
const fourth = Math.round(start + (end - start) * 0.8);
|
||||||
const animation = snapshot?.animations.find((candidate) => candidate.targetId === snapshot.activeObjectId);
|
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 (
|
return (
|
||||||
<div className="timeline-content">
|
<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>
|
<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="当前帧" />
|
<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="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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -420,6 +656,7 @@ export function App() {
|
|||||||
const [geometryBuffers, setGeometryBuffers] = useState<MeshGeometryBuffer[]>([]);
|
const [geometryBuffers, setGeometryBuffers] = useState<MeshGeometryBuffer[]>([]);
|
||||||
const [nonMeshGeometryBuffers, setNonMeshGeometryBuffers] = useState<NonMeshGeometryChunk[]>([]);
|
const [nonMeshGeometryBuffers, setNonMeshGeometryBuffers] = useState<NonMeshGeometryChunk[]>([]);
|
||||||
const [gpuTextureAssets, setGPUTextureAssets] = useState<GPUTextureAsset[]>([]);
|
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 [preview, setPreview] = useState<{ snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] } | null>(null);
|
||||||
const [lodLevels, setLodLevels] = useState<Record<string, WebEngineLODLevelResult[]> | null>(null);
|
const [lodLevels, setLodLevels] = useState<Record<string, WebEngineLODLevelResult[]> | null>(null);
|
||||||
const [openProgress, setOpenProgress] = useState<ProgressEvent | null>(null);
|
const [openProgress, setOpenProgress] = useState<ProgressEvent | null>(null);
|
||||||
@@ -441,7 +678,7 @@ export function App() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setSnapshot((current) => current ? { ...current, activeObjectId: id } : current);
|
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 selectMeshElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => {
|
||||||
const owner = snapshot?.nodes.find((node) => node.dataId === meshId);
|
const owner = snapshot?.nodes.find((node) => node.dataId === meshId);
|
||||||
@@ -454,7 +691,7 @@ export function App() {
|
|||||||
const next = preserve ? new Set(current.indices) : new Set<number>();
|
const next = preserve ? new Set(current.indices) : new Set<number>();
|
||||||
if (next.has(index)) next.delete(index);
|
if (next.has(index)) next.delete(index);
|
||||||
else next.add(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 selections = new Map<NonMeshElementKind, Set<number>>(preserve ? [...(current.nonMeshSelections ?? [])].map(([kind, values]) => [kind, new Set(values)]) : []);
|
||||||
const kindIndices = selections.get(nonMeshKind) ?? new Set<number>();
|
const kindIndices = selections.get(nonMeshKind) ?? new Set<number>();
|
||||||
if (kindIndices.has(index)) kindIndices.delete(index);
|
if (kindIndices.has(index)) kindIndices.delete(index);
|
||||||
@@ -463,7 +700,23 @@ export function App() {
|
|||||||
else selections.set(nonMeshKind, kindIndices);
|
else selections.set(nonMeshKind, kindIndices);
|
||||||
const combined = new Set<number>();
|
const combined = new Set<number>();
|
||||||
for (const values of selections.values()) for (const value of values) combined.add(value);
|
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> => {
|
const restoreCachedLODs = async (projectId: string, scene: SceneSnapshotIR): Promise<void> => {
|
||||||
@@ -579,10 +832,19 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const setMeshSelectionMode = (mode: MeshElementMode): void => {
|
const setMeshSelectionMode = (mode: MeshElementMode): void => {
|
||||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
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 selectAllMeshElements = (): void => {
|
||||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
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);
|
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode?.dataId);
|
||||||
if (!mesh) return;
|
if (!mesh) return;
|
||||||
const count = meshSelection.mode === "VERT" ? mesh.vertexCount : meshSelection.mode === "EDGE" ? mesh.edgeCount : mesh.faceCount;
|
const count = meshSelection.mode === "VERT" ? mesh.vertexCount : meshSelection.mode === "EDGE" ? mesh.edgeCount : mesh.faceCount;
|
||||||
@@ -609,10 +871,37 @@ export function App() {
|
|||||||
setEngineStatus(`Image import failed${error instanceof Error ? ` (${error.message})` : ""}`);
|
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);
|
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||||
if (!snapshot || !activeNode) return;
|
if (!snapshot || !activeNode) return;
|
||||||
if (uiState.context.mode === "Edit" && activeNode.dataId) {
|
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);
|
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) {
|
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id && nonMesh.handlePoints && meshSelection.nonMeshSelections && meshSelection.nonMeshSelections.size > 0) {
|
||||||
const handlePoints = nonMesh.handlePoints.slice();
|
const handlePoints = nonMesh.handlePoints.slice();
|
||||||
@@ -630,9 +919,9 @@ export function App() {
|
|||||||
if (handles.length === 0) return;
|
if (handles.length === 0) return;
|
||||||
let applied;
|
let applied;
|
||||||
try {
|
try {
|
||||||
const delta: [number, number, number] = [0, 0, 0];
|
const delta: [number, number, number] = axisVector ? [axisVector[0] * amount, axisVector[1] * amount, axisVector[2] * amount] : [0, 0, 0];
|
||||||
delta[axis] = amount;
|
if (!axisVector) delta[axis] = amount;
|
||||||
applied = applyCurveGizmoDelta({ schemaVersion: 1, dataId: nonMesh.id, baseRevision: snapshot.revision, phase: "COMMIT", axis, delta, handles }, snapshot.revision);
|
applied = applyCurveGizmoDelta({ schemaVersion: 1, dataId: nonMesh.id, baseRevision: snapshot.revision, phase: "COMMIT", axis, axisVector, delta, handles }, snapshot.revision);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
setEngineStatus(`Curve gizmo rejected${error instanceof Error ? ` (${error.message})` : ""}`);
|
setEngineStatus(`Curve gizmo rejected${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||||
@@ -668,6 +957,16 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent): void => {
|
const onKeyDown = (event: KeyboardEvent): void => {
|
||||||
const target = event.target as HTMLElement | null;
|
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;
|
if (target?.matches("input, textarea, select")) return;
|
||||||
const activeId = snapshot?.activeObjectId;
|
const activeId = snapshot?.activeObjectId;
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
@@ -701,7 +1000,7 @@ export function App() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("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 generateLOD = async (meshId: string, triangleCount: number): Promise<void> => {
|
||||||
const client = webClientRef.current;
|
const client = webClientRef.current;
|
||||||
if (!client || !snapshot || triangleCount <= 0) return;
|
if (!client || !snapshot || triangleCount <= 0) return;
|
||||||
@@ -880,6 +1179,7 @@ export function App() {
|
|||||||
const projectId = file.name.replace(/\.blend$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled";
|
const projectId = file.name.replace(/\.blend$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled";
|
||||||
projectIdRef.current = projectId;
|
projectIdRef.current = projectId;
|
||||||
const result = await client.openBlend(input, setOpenProgress);
|
const result = await client.openBlend(input, setOpenProgress);
|
||||||
|
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(input) });
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setLodLevels(null);
|
setLodLevels(null);
|
||||||
setGPUTextureAssets([]);
|
setGPUTextureAssets([]);
|
||||||
@@ -911,6 +1211,7 @@ export function App() {
|
|||||||
await storage.saveSnapshot(projectIdRef.current, snapshot.revision, data.slice(0));
|
await storage.saveSnapshot(projectIdRef.current, snapshot.revision, data.slice(0));
|
||||||
await storage.pruneOperations(projectIdRef.current, snapshot.revision);
|
await storage.pruneOperations(projectIdRef.current, snapshot.revision);
|
||||||
}
|
}
|
||||||
|
setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: await sha256Hex(data) });
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
@@ -937,6 +1238,7 @@ export function App() {
|
|||||||
buffer = saved.buffer;
|
buffer = saved.buffer;
|
||||||
}
|
}
|
||||||
let opened = await client.openBlend(buffer);
|
let opened = await client.openBlend(buffer);
|
||||||
|
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(buffer) });
|
||||||
const replay = await storage.listOperations(projectId, baseRevision);
|
const replay = await storage.listOperations(projectId, baseRevision);
|
||||||
for (const operation of replay.operations) {
|
for (const operation of replay.operations) {
|
||||||
const payload = operation.payload as WebEngineEditCommand;
|
const payload = operation.payload as WebEngineEditCommand;
|
||||||
@@ -1027,6 +1329,17 @@ export function App() {
|
|||||||
const vertexCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.vertexCount, 0) ?? 0;
|
const vertexCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.vertexCount, 0) ?? 0;
|
||||||
const faceCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.faceCount, 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 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 (
|
return (
|
||||||
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
|
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
|
||||||
@@ -1039,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>
|
<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 = ""; }} />
|
<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>
|
</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">
|
<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="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>
|
<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>
|
</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>
|
<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>
|
</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-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-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-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 { 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 strong { color: #f3f4f6; font-size: 15px; }
|
||||||
.viewport-message span { color: #969ba4; }
|
.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; }
|
.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; }
|
.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 { 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 { 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; }
|
.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; }
|
.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,
|
Group,
|
||||||
Line,
|
Line,
|
||||||
LineBasicMaterial,
|
LineBasicMaterial,
|
||||||
|
Points,
|
||||||
|
PointsMaterial,
|
||||||
type Object3D,
|
type Object3D,
|
||||||
} from "../vendor/three/three.module.js";
|
} from "../vendor/three/three.module.js";
|
||||||
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
|
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
|
||||||
@@ -12,9 +14,26 @@ import type { SceneNodeIR } from "../../../protocol/scene-ir";
|
|||||||
|
|
||||||
interface DrawingPreview {
|
interface DrawingPreview {
|
||||||
drawing: GreasePencilDrawingIR;
|
drawing: GreasePencilDrawingIR;
|
||||||
|
frame: number;
|
||||||
onion: "NONE" | "PREVIOUS" | "NEXT";
|
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 {
|
function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined {
|
||||||
let selected: GreasePencilFrameIR | undefined;
|
let selected: GreasePencilFrameIR | undefined;
|
||||||
for (const candidate of frames) {
|
for (const candidate of frames) {
|
||||||
@@ -26,20 +45,26 @@ function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): Gre
|
|||||||
function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPreview[] {
|
function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPreview[] {
|
||||||
const current = activeFrame(layer.frames, frame);
|
const current = activeFrame(layer.frames, frame);
|
||||||
if (!current) return [];
|
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;
|
if (!layer.onionSkinning) return result;
|
||||||
const sorted = [...layer.frames].sort((left, right) => left.frame - right.frame);
|
const sorted = [...layer.frames].sort((left, right) => left.frame - right.frame);
|
||||||
const currentIndex = sorted.findIndex((candidate) => candidate.frame === current.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) 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, onion: "NEXT" });
|
if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, frame: sorted[currentIndex + 1].frame, onion: "NEXT" });
|
||||||
return result;
|
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;
|
let count = 0;
|
||||||
for (const stroke of preview.drawing.strokes) {
|
for (let strokeIndex = 0; strokeIndex < preview.drawing.strokes.length; strokeIndex++) {
|
||||||
if (!stroke.points || stroke.points.length < 2) continue;
|
const stroke = preview.drawing.strokes[strokeIndex];
|
||||||
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
|
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);
|
const positions = new Float32Array(pointCount * 3);
|
||||||
let red = 0;
|
let red = 0;
|
||||||
let green = 0;
|
let green = 0;
|
||||||
@@ -59,19 +84,42 @@ function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPr
|
|||||||
opacity += point.opacity * color[3];
|
opacity += point.opacity * color[3];
|
||||||
}
|
}
|
||||||
const divisor = stroke.points.length;
|
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 onionColor = preview.onion === "PREVIOUS" ? new Color(0x6aa8ff) : preview.onion === "NEXT" ? new Color(0xff8a63) : null;
|
||||||
const material = new LineBasicMaterial({
|
let strokeObject: Line | null = null;
|
||||||
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
|
if (stroke.points.length > 1) {
|
||||||
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
|
const geometry = new BufferGeometry();
|
||||||
transparent: true,
|
geometry.setAttribute("position", new Float32BufferAttribute(positions.subarray(0, linePointCount * 3), 3));
|
||||||
depthWrite: preview.onion === "NONE",
|
const material = new LineBasicMaterial({
|
||||||
});
|
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
|
||||||
const line = new Line(geometry, material);
|
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
|
||||||
line.userData.greasePencilOnion = preview.onion;
|
transparent: true,
|
||||||
line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0;
|
depthWrite: preview.onion === "NONE",
|
||||||
group.add(line);
|
});
|
||||||
|
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++;
|
count++;
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
@@ -85,7 +133,7 @@ export function createGreasePencilObject(data: GreasePencilDataIR, frame: number
|
|||||||
for (const layer of data.layers) {
|
for (const layer of data.layers) {
|
||||||
if (!layer.visible || layer.opacity <= 0) continue;
|
if (!layer.visible || layer.opacity <= 0) continue;
|
||||||
for (const preview of layerDrawings(layer, frame)) {
|
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;
|
if (preview.onion === "NONE") currentDrawingCount += added;
|
||||||
else onionDrawingCount += added;
|
else onionDrawingCount += added;
|
||||||
}
|
}
|
||||||
@@ -95,6 +143,66 @@ export function createGreasePencilObject(data: GreasePencilDataIR, frame: number
|
|||||||
return group.children.length > 0 ? group : null;
|
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 {
|
export function applyGreasePencilTransform(object: Object3D, node: SceneNodeIR): void {
|
||||||
const [x, y, z] = node.transform.translation;
|
const [x, y, z] = node.transform.translation;
|
||||||
const [rx, ry, rz] = node.transform.rotationEuler;
|
const [rx, ry, rz] = node.transform.rotationEuler;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "../vendor/three/three.module.js";
|
} from "../vendor/three/three.module.js";
|
||||||
import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
|
import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
|
||||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
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 NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
|
||||||
export type NonMeshElementSelection = ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>;
|
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();
|
const lineGeometry = new BufferGeometry();
|
||||||
lineGeometry.setAttribute("position", new Float32BufferAttribute(handleLines, 3));
|
lineGeometry.setAttribute("position", new Float32BufferAttribute(handleLines, 3));
|
||||||
const lines = new LineSegments(lineGeometry, new LineBasicMaterial({ color: new Color(0x9a7bff), transparent: true, opacity: 0.65 }));
|
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);
|
group.add(lines);
|
||||||
const pointGeometry = new BufferGeometry();
|
const pointGeometry = new BufferGeometry();
|
||||||
pointGeometry.setAttribute("position", new Float32BufferAttribute(handlePositions, 3));
|
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.nonMeshDataId = data.id;
|
||||||
handles.userData.nonMeshPointIndexMap = handleIndexMap;
|
handles.userData.nonMeshPointIndexMap = handleIndexMap;
|
||||||
handles.userData.nonMeshPointKindMap = handleKindMap;
|
handles.userData.nonMeshPointKindMap = handleKindMap;
|
||||||
|
handles.userData.nonMeshHandleBasePositions = new Float32Array(handlePositions);
|
||||||
group.add(handles);
|
group.add(handles);
|
||||||
}
|
}
|
||||||
return group.children.length > 0 ? group : null;
|
return group.children.length > 0 ? group : null;
|
||||||
@@ -195,3 +200,37 @@ export function applyNonMeshElementSelection(root: Object3D, selection: NonMeshE
|
|||||||
object.material.needsUpdate = true;
|
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 { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||||
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
||||||
import type { NonMeshElementKind } from "./nonmesh";
|
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 =
|
export type OffscreenViewportRequest =
|
||||||
| { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number }
|
| { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number }
|
||||||
| { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] }
|
| { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] }
|
||||||
| { type: "textureAssets"; assets: GPUTextureAsset[] }
|
| { type: "textureAssets"; assets: GPUTextureAsset[] }
|
||||||
|
| { type: "volumeAssets"; assets: NanoVDBViewportAssetIR[] }
|
||||||
| { type: "resize"; width: number; height: number; pixelRatio: number }
|
| { 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: "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: "orbit"; deltaX: number; deltaY: number; zoom: number }
|
||||||
| { type: "pick"; x: number; y: number; additive: boolean }
|
| { type: "pick"; x: number; y: number; additive: boolean }
|
||||||
| { type: "dispose" };
|
| { type: "dispose" };
|
||||||
@@ -20,6 +27,9 @@ export type OffscreenViewportResponse =
|
|||||||
| { type: "frame"; visiblePixels: number }
|
| { type: "frame"; visiblePixels: number }
|
||||||
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: 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: "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: "selected"; objectId: string; additive: boolean }
|
||||||
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }
|
| { 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 };
|
| { type: "error"; message: string };
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ import { nonMeshChunkTransferables } from "../../../protocol/nonmesh-binary";
|
|||||||
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offscreen-viewport-protocol";
|
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offscreen-viewport-protocol";
|
||||||
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
|
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
|
||||||
import type { NonMeshElementKind } from "./nonmesh";
|
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 {
|
export interface ViewportBackend {
|
||||||
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
|
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
|
||||||
setTextureAssets(assets: readonly GPUTextureAsset[]): 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;
|
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;
|
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +41,7 @@ export function acquireOffscreenViewportRenderer(
|
|||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
onSelect?: (objectId: string, additive: boolean) => void,
|
onSelect?: (objectId: string, additive: boolean) => void,
|
||||||
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
|
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
|
||||||
|
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
|
||||||
): OffscreenViewportRenderer {
|
): OffscreenViewportRenderer {
|
||||||
const existing = sharedBackends.get(canvas);
|
const existing = sharedBackends.get(canvas);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -42,7 +50,7 @@ export function acquireOffscreenViewportRenderer(
|
|||||||
existing.references += 1;
|
existing.references += 1;
|
||||||
return existing.renderer;
|
return existing.renderer;
|
||||||
}
|
}
|
||||||
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect);
|
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect);
|
||||||
sharedBackends.set(canvas, { renderer, references: 1 });
|
sharedBackends.set(canvas, { renderer, references: 1 });
|
||||||
return renderer;
|
return renderer;
|
||||||
}
|
}
|
||||||
@@ -65,6 +73,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
|||||||
private readonly resizeObserver: ResizeObserver;
|
private readonly resizeObserver: ResizeObserver;
|
||||||
private readonly onSelect?: (objectId: string, additive: boolean) => void;
|
private readonly onSelect?: (objectId: string, additive: boolean) => void;
|
||||||
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => 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 pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
|
||||||
private lastSnapshot: SceneSnapshotIR | null = null;
|
private lastSnapshot: SceneSnapshotIR | null = null;
|
||||||
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
|
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
|
||||||
@@ -74,11 +83,13 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
|||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
onSelect?: (objectId: string, additive: boolean) => void,
|
onSelect?: (objectId: string, additive: boolean) => void,
|
||||||
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => 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");
|
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.onSelect = onSelect;
|
this.onSelect = onSelect;
|
||||||
this.onElementSelect = onElementSelect;
|
this.onElementSelect = onElementSelect;
|
||||||
|
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
|
||||||
this.worker = new Worker(new URL("../workers/viewport-render.worker.ts", import.meta.url), { type: "module" });
|
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);
|
this.worker.onmessage = (event: MessageEvent<OffscreenViewportResponse>) => this.handleMessage(event.data);
|
||||||
const offscreen = canvas.transferControlToOffscreen();
|
const offscreen = canvas.transferControlToOffscreen();
|
||||||
@@ -147,15 +158,34 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
|||||||
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
|
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 }))));
|
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 {
|
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||||
this.worker.postMessage({ type: "interaction", editMode, selectionMode } satisfies OffscreenViewportRequest);
|
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 {
|
installLODLevels(): void {
|
||||||
// The main-thread renderer remains the adaptive LOD owner; the worker path
|
// The main-thread renderer remains the adaptive LOD owner; the worker path
|
||||||
// renders the source instanced mesh and retains native frustum culling.
|
// renders the source instanced mesh and retains native frustum culling.
|
||||||
@@ -204,6 +234,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
|||||||
private handleMessage(message: OffscreenViewportResponse): void {
|
private handleMessage(message: OffscreenViewportResponse): void {
|
||||||
if (message.type === "selected") this.onSelect?.(message.objectId, message.additive);
|
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 === "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 === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels);
|
||||||
else if (message.type === "snapshotStatus") {
|
else if (message.type === "snapshotStatus") {
|
||||||
this.canvas.dataset.nonMeshCount = String(message.nonMeshCount);
|
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.textureBytes = String(message.bytes);
|
||||||
this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? "";
|
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;
|
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;
|
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 {
|
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 intensity = blenderLightIntensity(definition);
|
||||||
const light = definition.lightType === 1 ? new DirectionalLight(color, intensity) :
|
const light = definition.lightType === 1 ? new DirectionalLight(color, intensity) :
|
||||||
definition.lightType === 2 ? new SpotLight(color, intensity, 0, definition.spotAngle, definition.spotBlend, 2) :
|
definition.lightType === 2 ? new SpotLight(color, intensity, 0, definition.spotAngle, definition.spotBlend, 2) :
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
InstancedMesh,
|
InstancedMesh,
|
||||||
Matrix4,
|
Matrix4,
|
||||||
Mesh,
|
Mesh,
|
||||||
|
MeshBasicMaterial,
|
||||||
HemisphereLight,
|
HemisphereLight,
|
||||||
MeshPhysicalMaterial,
|
MeshPhysicalMaterial,
|
||||||
Raycaster,
|
Raycaster,
|
||||||
@@ -24,7 +25,7 @@ import {
|
|||||||
} from "../vendor/three/three.module.js";
|
} from "../vendor/three/three.module.js";
|
||||||
import { OrbitControls } from "../vendor/three/addons/controls/OrbitControls.js";
|
import { OrbitControls } from "../vendor/three/addons/controls/OrbitControls.js";
|
||||||
import type { MaterialIR, SceneSnapshotIR } from "../../../protocol/scene-ir";
|
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 type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine";
|
||||||
import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod";
|
import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod";
|
||||||
import {
|
import {
|
||||||
@@ -40,9 +41,21 @@ import {
|
|||||||
import { GPUTextureStore } from "./texture-assets";
|
import { GPUTextureStore } from "./texture-assets";
|
||||||
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
||||||
import { gateEnvironmentImage, gateUDIMImage } 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 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[]> {
|
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
|
||||||
const groups = new Map<string, string[]>();
|
const groups = new Map<string, string[]>();
|
||||||
@@ -68,6 +81,7 @@ export class ViewportRenderer {
|
|||||||
private readonly resizeObserver: ResizeObserver;
|
private readonly resizeObserver: ResizeObserver;
|
||||||
private animationFrame = 0;
|
private animationFrame = 0;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
private contextLost = false;
|
||||||
private currentSnapshot: SceneSnapshotIR | null = null;
|
private currentSnapshot: SceneSnapshotIR | null = null;
|
||||||
private readonly objectByBlenderId = new Map<string, Object3D>();
|
private readonly objectByBlenderId = new Map<string, Object3D>();
|
||||||
private readonly instanceIndexByBlenderId = new Map<string, number>();
|
private readonly instanceIndexByBlenderId = new Map<string, number>();
|
||||||
@@ -77,17 +91,33 @@ export class ViewportRenderer {
|
|||||||
private readonly pointer = new Vector2();
|
private readonly pointer = new Vector2();
|
||||||
private readonly onSelect?: (objectId: string, additive: boolean) => void;
|
private readonly onSelect?: (objectId: string, additive: boolean) => void;
|
||||||
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => 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 editMode = false;
|
||||||
private selectionMode: MeshElementMode = "FACE";
|
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(
|
constructor(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
onSelect?: (objectId: string, additive: boolean) => void,
|
onSelect?: (objectId: string, additive: boolean) => void,
|
||||||
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
|
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
|
||||||
|
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
|
||||||
) {
|
) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.onSelect = onSelect;
|
this.onSelect = onSelect;
|
||||||
this.onElementSelect = onElementSelect;
|
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 });
|
this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||||
configurePBRRenderer(this.renderer);
|
configurePBRRenderer(this.renderer);
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
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.pbrProfile = PBR_PROFILE;
|
||||||
this.canvas.dataset.toneMapping = PBR_TONE_MAPPING;
|
this.canvas.dataset.toneMapping = PBR_TONE_MAPPING;
|
||||||
this.canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
|
this.canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
|
||||||
|
this.canvas.dataset.deviceStatus = "ready";
|
||||||
this.scene = new Scene();
|
this.scene = new Scene();
|
||||||
this.camera = new PerspectiveCamera(45, 1, 0.01, 1000);
|
this.camera = new PerspectiveCamera(45, 1, 0.01, 1000);
|
||||||
this.camera.position.set(4.5, -4.5, 3.5);
|
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 = new ResizeObserver(() => this.resize());
|
||||||
this.resizeObserver.observe(canvas);
|
this.resizeObserver.observe(canvas);
|
||||||
this.canvas.addEventListener("click", this.handleClick);
|
this.canvas.addEventListener("click", this.handleClick);
|
||||||
|
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
|
||||||
|
this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
|
||||||
this.resize();
|
this.resize();
|
||||||
this.renderLoop();
|
this.renderLoop();
|
||||||
}
|
}
|
||||||
@@ -243,6 +276,7 @@ export class ViewportRenderer {
|
|||||||
this.objectByBlenderId.set(node.id, mesh);
|
this.objectByBlenderId.set(node.id, mesh);
|
||||||
}
|
}
|
||||||
this.coalesceMeshInstances(snapshot);
|
this.coalesceMeshInstances(snapshot);
|
||||||
|
void this.refreshVolumes();
|
||||||
if (this.importedRoot.children.length > 0) {
|
if (this.importedRoot.children.length > 0) {
|
||||||
this.controls.target.set(0, 0, 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;
|
if (!node.visible || !node.dataId || node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA") continue;
|
||||||
const data = dataById.get(node.dataId);
|
const data = dataById.get(node.dataId);
|
||||||
if (!data) continue;
|
if (!data) continue;
|
||||||
|
if (data.type === "VOLUME") continue;
|
||||||
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
|
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
blockedCount++;
|
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 {
|
applyDelta(delta: SceneDelta, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
|
||||||
if (!this.currentSnapshot) throw new Error("Cannot apply a SceneDelta before a snapshot");
|
if (!this.currentSnapshot) throw new Error("Cannot apply a SceneDelta before a snapshot");
|
||||||
const next = applySceneDelta(this.currentSnapshot, delta);
|
const next = applySceneDelta(this.currentSnapshot, delta);
|
||||||
const hasLifecycleChanges = Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length ||
|
const hasLifecycleChanges = sceneDeltaRequiresRendererRebuild(delta);
|
||||||
delta.meshes || delta.materials || delta.cameras || delta.lights || delta.animations);
|
|
||||||
if (hasLifecycleChanges) {
|
if (hasLifecycleChanges) {
|
||||||
this.setSnapshot(next, geometryBuffers, nonMeshGeometryBuffers);
|
this.setSnapshot(next, geometryBuffers, nonMeshGeometryBuffers);
|
||||||
return;
|
return;
|
||||||
@@ -353,7 +445,11 @@ export class ViewportRenderer {
|
|||||||
this.currentSnapshot = next;
|
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>();
|
const visitedInstances = new Set<InstancedMesh>();
|
||||||
for (const [objectId, object] of this.objectByBlenderId) {
|
for (const [objectId, object] of this.objectByBlenderId) {
|
||||||
if (object instanceof InstancedMesh) {
|
if (object instanceof InstancedMesh) {
|
||||||
@@ -374,11 +470,30 @@ export class ViewportRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
|
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
|
||||||
|
applyGreasePencilPointSelection(this.importedRoot, greasePencilPoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||||
this.editMode = editMode;
|
this.editMode = editMode;
|
||||||
this.selectionMode = selectionMode;
|
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 {
|
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();
|
if (mesh.geometry && typeof mesh.geometry.dispose === "function") mesh.geometry.dispose();
|
||||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||||
for (const material of materials) {
|
for (const material of materials) {
|
||||||
|
if (mesh.userData.nanoVDBVolume && material instanceof MeshBasicMaterial) material.map?.dispose();
|
||||||
if (material && typeof material.dispose === "function") material.dispose();
|
if (material && typeof material.dispose === "function") material.dispose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -602,8 +718,17 @@ export class ViewportRenderer {
|
|||||||
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
||||||
);
|
);
|
||||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||||
const hit = this.raycaster.intersectObjects(this.importedRoot.children, true)
|
const hits = this.raycaster.intersectObjects(this.importedRoot.children, true);
|
||||||
.find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds));
|
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;
|
if (!hit) return;
|
||||||
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
|
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||||
const nonMeshDataId = hit.object.userData.nonMeshDataId;
|
const nonMeshDataId = hit.object.userData.nonMeshDataId;
|
||||||
@@ -611,7 +736,9 @@ export class ViewportRenderer {
|
|||||||
const indexMap = hit.object.userData.nonMeshPointIndexMap as number[] | undefined;
|
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 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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const meshId = hit.object.userData.meshId;
|
const meshId = hit.object.userData.meshId;
|
||||||
@@ -662,20 +789,74 @@ export class ViewportRenderer {
|
|||||||
|
|
||||||
private renderLoop = (): void => {
|
private renderLoop = (): void => {
|
||||||
if (this.disposed) return;
|
if (this.disposed) return;
|
||||||
this.controls.update();
|
if (!this.contextLost) {
|
||||||
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
|
this.controls.update();
|
||||||
this.renderer.render(this.scene, this.camera);
|
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);
|
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 {
|
dispose(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
window.cancelAnimationFrame(this.animationFrame);
|
window.cancelAnimationFrame(this.animationFrame);
|
||||||
this.resizeObserver.disconnect();
|
this.resizeObserver.disconnect();
|
||||||
this.canvas.removeEventListener("click", this.handleClick);
|
this.canvas.removeEventListener("click", this.handleClick);
|
||||||
|
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
|
||||||
|
this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
|
||||||
this.controls.dispose();
|
this.controls.dispose();
|
||||||
this.lodAdapter.clear();
|
this.lodAdapter.clear();
|
||||||
this.clearImportedScene();
|
this.clearImportedScene();
|
||||||
|
this.volumeRenderGeneration++;
|
||||||
|
this.volumeRenderCache.clear();
|
||||||
|
this.volumeRenderSession.dispose();
|
||||||
this.textureStore.dispose();
|
this.textureStore.dispose();
|
||||||
this.renderer.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 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 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 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: [] };
|
const glbRequest = { format: "GLB", operation: "EXPORT", externalUris: [], archiveEntries: [] };
|
||||||
|
|
||||||
self.onmessage = () => {
|
self.onmessage = async () => {
|
||||||
const result: Record<string, unknown> = {};
|
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 { 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 { 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, 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 { 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: "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); }
|
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.glb = gateIORequest(glbRequest).status;
|
||||||
result.obj = gateIORequest({ ...glbRequest, format: "OBJ", operation: "IMPORT" }).issues[0]?.code;
|
result.obj = gateIORequest({ ...glbRequest, format: "OBJ", operation: "IMPORT" }).issues[0]?.code;
|
||||||
result.library = gateLibraryMutation("APPEND").status;
|
result.library = gateLibraryMutation("APPEND").status;
|
||||||
result.storage = assetStorageCapabilities();
|
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);
|
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 node = (id: string, type: string, properties: Record<string, unknown> = {}) => ({ id, type, name: id, properties });
|
||||||
const valid = {
|
const valid = {
|
||||||
@@ -26,7 +26,7 @@ const valid = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
self.onmessage = () => {
|
self.onmessage = async () => {
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
const parsed = parseCompositorGraph(valid);
|
const parsed = parseCompositorGraph(valid);
|
||||||
@@ -54,5 +54,13 @@ self.onmessage = () => {
|
|||||||
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||||
try { executeCompositorGraph(valid, new Map(), { width: 2, height: 2, cancelled: () => true }); }
|
try { executeCompositorGraph(valid, new Map(), { width: 2, height: 2, cancelled: () => true }); }
|
||||||
catch (error) { result.cancelled = error instanceof Error ? error.message : String(error); }
|
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);
|
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 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 });
|
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 = () => {
|
self.onmessage = () => {
|
||||||
const result: Record<string, unknown> = {};
|
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 { 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, 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, 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;
|
result.view = gateEditorOperation("READ_ONLY_VIEW").status; result.writer = gateEditorOperation("WRITER").issues[0]?.code; result.gizmo = gateEditorOperation("GIZMO").issues[0]?.code;
|
||||||
self.postMessage(result);
|
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 = () => {
|
self.onmessage = () => {
|
||||||
const result: Record<string, unknown> = {};
|
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] }] };
|
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 { 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, 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, 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 { 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);
|
self.postMessage(result);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PAINT_BUDGET, applyUdimTilePatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch } from "../../../protocol/paint";
|
import { PAINT_BUDGET, applyUdimTilePatch, buildPaintBrushSpatialIndex, composePaintColorPatch, composePaintWeightPatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch, queryPaintBrushSpatialIndex } from "../../../protocol/paint";
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -25,6 +25,27 @@ self.onmessage = async () => {
|
|||||||
{ index: 4, position: [3, 0, 0], normal: [0, 0, 1] },
|
{ index: 4, position: [3, 0, 0], normal: [0, 0, 1] },
|
||||||
], [0, 0, 0], 2, 0.8, { frontFaceOnly: true, viewDirection: [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); }
|
} 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 {
|
try {
|
||||||
const baseTile = new Uint8Array(16);
|
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 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("");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
physicsCapabilityInventory,
|
physicsCapabilityInventory,
|
||||||
selectPhysicsCacheFrame,
|
selectPhysicsCacheFrame,
|
||||||
} from "../../../protocol/physics-simulation";
|
} from "../../../protocol/physics-simulation";
|
||||||
|
import { applyBrowserTransformCachePreview, BrowserTransformCachePlaybackSession } from "../../../protocol/physics-cache-playback";
|
||||||
|
|
||||||
const hash = "a".repeat(64);
|
const hash = "a".repeat(64);
|
||||||
const base = {
|
const base = {
|
||||||
@@ -30,7 +31,19 @@ const base = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.onmessage = () => {
|
function browserFrame(frame: number, translation: [number, number, number]): ArrayBuffer {
|
||||||
|
const bytes = new ArrayBuffer(16 + 72);
|
||||||
|
const view = new DataView(bytes);
|
||||||
|
view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, frame, true); view.setUint32(12, 1, true);
|
||||||
|
const id = new TextEncoder().encode("object:Cloth"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id);
|
||||||
|
[...translation, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||||
|
const scene = { schemaVersion: 1 as const, revision: 3, sceneId: "scene:Physics", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:Cloth", frame: { current: 1, start: 1, end: 10 }, nodes: [{ id: "object:Cloth", name: "Cloth", type: "MESH" as const, parentId: null, dataId: "mesh:Cloth", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [] };
|
||||||
|
|
||||||
|
self.onmessage = async () => {
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
const parsed = parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [base] });
|
const parsed = parsePhysicsSimulationManifest({ schemaVersion: 1, systems: [base] });
|
||||||
@@ -63,16 +76,40 @@ self.onmessage = () => {
|
|||||||
result.manifest = gatePhysicsExecution("RIGID_BODY", "CACHE_MANIFEST").status;
|
result.manifest = gatePhysicsExecution("RIGID_BODY", "CACHE_MANIFEST").status;
|
||||||
result.familyCount = PHYSICS_FAMILIES.length;
|
result.familyCount = PHYSICS_FAMILIES.length;
|
||||||
try {
|
try {
|
||||||
const bytes = new ArrayBuffer(16 + 72);
|
const bytes = browserFrame(7, [1, 2, 3]);
|
||||||
const view = new DataView(bytes);
|
const view = new DataView(bytes);
|
||||||
view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, 7, true); view.setUint32(12, 1, true);
|
|
||||||
const id = new TextEncoder().encode("object:Cloth"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id);
|
|
||||||
[1, 2, 3, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
|
|
||||||
const decoded = decodeBrowserTransformCacheFrame(bytes);
|
const decoded = decodeBrowserTransformCacheFrame(bytes);
|
||||||
result.browserPlayback = [decoded.frame, decoded.objects[0].objectId, decoded.objects[0].translation];
|
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);
|
view.setFloat32(48 + 3 * 4, 2, true);
|
||||||
try { decodeBrowserTransformCacheFrame(bytes); } catch (error) { result.browserRotation = error instanceof Error ? error.message : String(error); }
|
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); }
|
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);
|
self.postMessage(result);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate";
|
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 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 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 = () => {
|
self.onmessage = () => {
|
||||||
const result: Record<string, unknown> = {};
|
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", ["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, 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, 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);
|
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 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 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] };
|
const base = { schemaVersion: 1, scripts: [script] };
|
||||||
|
|
||||||
self.onmessage = () => {
|
self.onmessage = async () => {
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
try { result.valid = parseScriptingManifest(base).scripts[0].entryPath; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
|
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;
|
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;
|
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, 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); }
|
try { parseScriptingManifest({ ...base, scripts: [{ ...script, autorun: true }] }); } catch (error) { result.policy = error instanceof Error ? error.message : String(error); }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
applySequencerEdit,
|
applySequencerEdit,
|
||||||
gateSequencerCodec,
|
gateSequencerCodec,
|
||||||
parseSequencerTimeline,
|
parseSequencerTimeline,
|
||||||
|
resolveSequencerFrame,
|
||||||
|
resolveSequencerTransitionFrame,
|
||||||
sequencerRuntimeCapabilities,
|
sequencerRuntimeCapabilities,
|
||||||
sequencerSourceFrame,
|
sequencerSourceFrame,
|
||||||
} from "../../../protocol/sequencer";
|
} 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 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" });
|
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.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 }); }
|
try { applySequencerEdit(timeline, { type: "MOVE", revision: 2, stripId: movie.id, frameDelta: 1 }); }
|
||||||
catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
|
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); }
|
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||||
result.codec = gateSequencerCodec("video/mp4", new Set()).issues[0]?.code;
|
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();
|
result.runtime = sequencerRuntimeCapabilities();
|
||||||
self.postMessage(result);
|
self.postMessage(result);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
applyTrackingMaskEdit,
|
applyTrackingMaskEdit,
|
||||||
gateTrackingOperation,
|
gateTrackingOperation,
|
||||||
parseTrackingMaskProject,
|
parseTrackingMaskProject,
|
||||||
|
raycastMaskProject,
|
||||||
|
selectMaskPointsInBounds,
|
||||||
type MaskPointIR,
|
type MaskPointIR,
|
||||||
type TrackingMarkerIR,
|
type TrackingMarkerIR,
|
||||||
} from "../../../protocol/tracking-mask";
|
} 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 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 } });
|
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.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); }
|
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); }
|
} 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); }
|
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 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 => {
|
scope.onmessage = (): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const data = Uint8Array.from([0x76, 0x64, 0x62, 0x01]);
|
// These deterministic bytes exercise the transport contract only. They are not
|
||||||
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (value) => value.toString(16).padStart(2, "0")).join("");
|
// presented as an OpenVDB or NanoVDB decoder fixture.
|
||||||
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 sourceBytes = bytes(64, 11);
|
||||||
const decoded = await decodeVDBResource({ ...manifest, data: data.buffer }, async (request, signal) => {
|
const source: VDBResourceManifest = {
|
||||||
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
|
projectId: "vdb-protocol-test",
|
||||||
return { metadata: request, decodedByteLength: request.data.byteLength };
|
sourcePath: "//volumes/smoke.vdb",
|
||||||
}, new AbortController().signal);
|
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 = "";
|
let outsideProject = "";
|
||||||
try {
|
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); }
|
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 controller = new AbortController();
|
||||||
const cancelled = decodeVDBResource({ ...manifest, data: data.buffer }, async (_request, signal) => new Promise((resolve, reject) => {
|
controller.abort();
|
||||||
const timer = setTimeout(() => resolve({ metadata: manifest, decodedByteLength: data.byteLength }), 20);
|
const cancelled = prepareVDBConversionInput({ ...source, data: arrayBuffer(sourceBytes) }, controller.signal)
|
||||||
signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("cancelled", "AbortError")); }, { once: true });
|
.then(() => false)
|
||||||
}), controller.signal).then(() => false).catch((error) => error instanceof DOMException && error.name === "AbortError");
|
.catch((error) => error instanceof DOMException && error.name === "AbortError");
|
||||||
setTimeout(() => controller.abort(), 1);
|
const rawBrowserGate = gateNanoVDBPipeline("RAW_VDB_BROWSER_DECODE");
|
||||||
scope.postMessage({ decodedByteLength: decoded.decodedByteLength, outsideProject, cancelled: await cancelled });
|
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) }));
|
})().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,
|
InstancedMesh,
|
||||||
Matrix4,
|
Matrix4,
|
||||||
Mesh,
|
Mesh,
|
||||||
|
MeshBasicMaterial,
|
||||||
MeshPhysicalMaterial,
|
MeshPhysicalMaterial,
|
||||||
PerspectiveCamera,
|
PerspectiveCamera,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
@@ -27,6 +28,10 @@ import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-
|
|||||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||||
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "../three-adapter/offscreen-viewport-protocol";
|
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "../three-adapter/offscreen-viewport-protocol";
|
||||||
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
|
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 {
|
import {
|
||||||
configurePBRLight,
|
configurePBRLight,
|
||||||
configurePBRRenderer,
|
configurePBRRenderer,
|
||||||
@@ -35,8 +40,15 @@ import {
|
|||||||
setPBRMaterialSelected,
|
setPBRMaterialSelected,
|
||||||
} from "../three-adapter/pbr";
|
} from "../three-adapter/pbr";
|
||||||
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
||||||
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||||
import { applyGreasePencilTransform, createGreasePencilObject } from "../three-adapter/grease-pencil";
|
import {
|
||||||
|
applyGreasePencilPointSelection,
|
||||||
|
applyGreasePencilPointPreview,
|
||||||
|
applyGreasePencilTransform,
|
||||||
|
createGreasePencilObject,
|
||||||
|
greasePencilPointRef,
|
||||||
|
type GreasePencilPointRef,
|
||||||
|
} from "../three-adapter/grease-pencil";
|
||||||
|
|
||||||
const workerScope = self as unknown as {
|
const workerScope = self as unknown as {
|
||||||
onmessage: ((event: MessageEvent<OffscreenViewportRequest>) => void) | null;
|
onmessage: ((event: MessageEvent<OffscreenViewportRequest>) => void) | null;
|
||||||
@@ -47,6 +59,7 @@ let scene: Scene | null = null;
|
|||||||
let camera: PerspectiveCamera | null = null;
|
let camera: PerspectiveCamera | null = null;
|
||||||
let root: Group | null = null;
|
let root: Group | null = null;
|
||||||
let importedLights: Group | null = null;
|
let importedLights: Group | null = null;
|
||||||
|
let contextLost = false;
|
||||||
let width = 1;
|
let width = 1;
|
||||||
let height = 1;
|
let height = 1;
|
||||||
let yaw = -Math.PI / 4;
|
let yaw = -Math.PI / 4;
|
||||||
@@ -57,17 +70,27 @@ let selectionMode: MeshElementMode = "FACE";
|
|||||||
let currentSnapshot: SceneSnapshotIR | null = null;
|
let currentSnapshot: SceneSnapshotIR | null = null;
|
||||||
const textureStore = new GPUTextureStore();
|
const textureStore = new GPUTextureStore();
|
||||||
const raycaster = new Raycaster();
|
const raycaster = new Raycaster();
|
||||||
|
raycaster.params.Points.threshold = 0.14;
|
||||||
const objectById = new Map<string, Object3D>();
|
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 {
|
function post(message: OffscreenViewportResponse): void {
|
||||||
workerScope.postMessage(message);
|
workerScope.postMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(): void {
|
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.position.set(distance * Math.cos(pitch) * Math.cos(yaw), distance * Math.cos(pitch) * Math.sin(yaw), distance * Math.sin(pitch));
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(0, 0, 0);
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
|
publishCurveGizmoFrame();
|
||||||
const gl = renderer.getContext();
|
const gl = renderer.getContext();
|
||||||
const sampleWidth = Math.min(16, gl.drawingBufferWidth);
|
const sampleWidth = Math.min(16, gl.drawingBufferWidth);
|
||||||
const sampleHeight = Math.min(16, gl.drawingBufferHeight);
|
const sampleHeight = Math.min(16, gl.drawingBufferHeight);
|
||||||
@@ -88,6 +111,28 @@ function render(): void {
|
|||||||
post({ type: "frame", visiblePixels });
|
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 {
|
function resize(nextWidth: number, nextHeight: number, pixelRatio: number): void {
|
||||||
width = Math.max(1, nextWidth);
|
width = Math.max(1, nextWidth);
|
||||||
height = Math.max(1, nextHeight);
|
height = Math.max(1, nextHeight);
|
||||||
@@ -172,12 +217,65 @@ function clearRoot(): void {
|
|||||||
const mesh = object as Mesh;
|
const mesh = object as Mesh;
|
||||||
mesh.geometry?.dispose();
|
mesh.geometry?.dispose();
|
||||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
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();
|
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 {
|
function applyTextureAssets(assets: readonly GPUTextureAsset[]): void {
|
||||||
void textureStore.upload(assets).then((status) => {
|
void textureStore.upload(assets).then((status) => {
|
||||||
if (currentSnapshot && root) textureStore.applySnapshotMaterials(root, currentSnapshot);
|
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;
|
if (node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA" || !node.visible || !node.dataId) continue;
|
||||||
const data = dataById.get(node.dataId);
|
const data = dataById.get(node.dataId);
|
||||||
if (!data) continue;
|
if (!data) continue;
|
||||||
|
if (data.type === "VOLUME") continue;
|
||||||
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
|
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
nonMeshBlockedCount++;
|
nonMeshBlockedCount++;
|
||||||
@@ -310,10 +409,11 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
|||||||
objectById.set(node.id, light);
|
objectById.set(node.id, light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void refreshVolumes();
|
||||||
render();
|
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 selected = new Set(ids);
|
||||||
const visited = new Set<Object3D>();
|
const visited = new Set<Object3D>();
|
||||||
for (const [id, object] of objectById) {
|
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);
|
selection.set(element.dataId, kinds);
|
||||||
}
|
}
|
||||||
if (root) applyNonMeshElementSelection(root, selection);
|
if (root) applyNonMeshElementSelection(root, selection);
|
||||||
|
if (root) applyGreasePencilPointSelection(root, greasePencilPoints);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(x: number, y: number, additive: boolean): void {
|
function pick(x: number, y: number, additive: boolean): void {
|
||||||
if (!root || !camera) return;
|
if (!root || !camera) return;
|
||||||
raycaster.setFromCamera(new Vector2(x, y), camera);
|
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;
|
if (!hit) return;
|
||||||
const nonMeshDataId = hit.object.userData.nonMeshDataId;
|
const nonMeshDataId = hit.object.userData.nonMeshDataId;
|
||||||
if (typeof nonMeshDataId === "string" && hit.index !== undefined) {
|
if (typeof nonMeshDataId === "string" && hit.index !== undefined) {
|
||||||
@@ -403,6 +514,18 @@ workerScope.onmessage = (event): void => {
|
|||||||
const message = event.data;
|
const message = event.data;
|
||||||
if (message.type === "init") {
|
if (message.type === "init") {
|
||||||
renderer = new WebGLRenderer({ canvas: message.canvas, antialias: true, preserveDrawingBuffer: true });
|
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);
|
configurePBRRenderer(renderer);
|
||||||
scene = new Scene();
|
scene = new Scene();
|
||||||
camera = new PerspectiveCamera(45, 1, 0.01, 1000);
|
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 === "snapshot") setSnapshot(message.snapshot, message.geometryBuffers, message.nonMeshGeometryBuffers);
|
||||||
else if (message.type === "textureAssets") applyTextureAssets(message.assets);
|
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 === "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") {
|
else if (message.type === "interaction") {
|
||||||
editMode = message.editMode;
|
editMode = message.editMode;
|
||||||
selectionMode = message.selectionMode;
|
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") {
|
else if (message.type === "orbit") {
|
||||||
yaw -= message.deltaX * 0.008;
|
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 === "pick") pick(message.x, message.y, message.additive);
|
||||||
else if (message.type === "dispose") {
|
else if (message.type === "dispose") {
|
||||||
clearRoot();
|
clearRoot();
|
||||||
|
volumeRenderGeneration++;
|
||||||
|
volumeRenderCache.clear();
|
||||||
|
volumeRenderSession.dispose();
|
||||||
clearLights();
|
clearLights();
|
||||||
renderer?.dispose();
|
renderer?.dispose();
|
||||||
textureStore.dispose();
|
textureStore.dispose();
|
||||||
@@ -444,6 +590,7 @@ workerScope.onmessage = (event): void => {
|
|||||||
camera = null;
|
camera = null;
|
||||||
root = null;
|
root = null;
|
||||||
importedLights = null;
|
importedLights = null;
|
||||||
|
contextLost = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ let module: WasmModule | null = null;
|
|||||||
let wasmFactory: WasmFactory | null = null;
|
let wasmFactory: WasmFactory | null = null;
|
||||||
let wasmBinary: ArrayBuffer | null = null;
|
let wasmBinary: ArrayBuffer | null = null;
|
||||||
let handle = 0;
|
let handle = 0;
|
||||||
|
let initializationPromise: Promise<WebEngineStatus> | null = null;
|
||||||
let currentSnapshot: ReturnType<typeof parseSceneSnapshotIR> | null = null;
|
let currentSnapshot: ReturnType<typeof parseSceneSnapshotIR> | null = null;
|
||||||
let currentGeometryBuffers: MeshGeometryBuffer[] = [];
|
let currentGeometryBuffers: MeshGeometryBuffer[] = [];
|
||||||
let sourceBlendBuffer: ArrayBuffer | null = null;
|
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");
|
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;
|
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": {
|
case "setMetaballElements": {
|
||||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
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}`);
|
if (!data || data.type !== "METABALL") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Metaball data block is unavailable: ${payload.dataId}`);
|
||||||
@@ -459,20 +471,32 @@ function nativeError(fallbackCode: ErrorReport["code"]): ErrorReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initialize(): Promise<WebEngineStatus> {
|
async function initialize(): Promise<WebEngineStatus> {
|
||||||
if (!module) {
|
if (module) return status();
|
||||||
const imported = await import("../vendor/blender/web_engine.js") as unknown as { default: WasmFactory };
|
if (!initializationPromise) {
|
||||||
const factory = imported.default;
|
initializationPromise = (async () => {
|
||||||
const binary = await fetch("/vendor/blender/web_engine.wasm?v=2").then((response) => {
|
const imported = await import("../vendor/blender/web_engine.js") as unknown as { default: WasmFactory };
|
||||||
if (!response.ok) throw new Error(`web_engine.wasm request failed: ${response.status}`);
|
const factory = imported.default;
|
||||||
return response.arrayBuffer();
|
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 } {
|
function copyIntoWasm(buffer: ArrayBuffer): { pointer: number; length: number } {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { defineConfig, type Plugin } from "vite";
|
import { defineConfig, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
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 = {
|
const isolationHeaders = {
|
||||||
"Cross-Origin-Opener-Policy": "same-origin",
|
"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({
|
export default defineConfig({
|
||||||
root: "app",
|
root: "app",
|
||||||
plugins: [preserveIsolationHeaders(), react()],
|
plugins: [preserveIsolationHeaders(), localVDBFixture(), react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: false,
|
strictPort: false,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@
|
|||||||
"test:e2e": "playwright test --config playwright.config.ts",
|
"test:e2e": "playwright test --config playwright.config.ts",
|
||||||
"test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"",
|
"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": "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:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs",
|
||||||
"test:browser": "playwright test --config playwright.release.config.ts",
|
"test:browser": "playwright test --config playwright.release.config.ts",
|
||||||
"test:cross-browser": "npm run test:browser",
|
"test:cross-browser": "npm run test:browser",
|
||||||
@@ -21,7 +26,7 @@
|
|||||||
"test:topology-collapse": "node ../tools/web/check-topology-collapse.mjs",
|
"test:topology-collapse": "node ../tools/web/check-topology-collapse.mjs",
|
||||||
"test:depsgraph": "node ../tools/web/check-depsgraph.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: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:frame-evaluation": "node ../tools/web/check-frame-evaluation.mjs",
|
||||||
"test:pose-constraint-goldens": "node ../tools/web/check-pose-constraint-goldens.mjs",
|
"test:pose-constraint-goldens": "node ../tools/web/check-pose-constraint-goldens.mjs",
|
||||||
"test:main-roundtrip": "node ../tools/web/check-main-roundtrip.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: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: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-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": "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:grease-pencil-editor": "playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"",
|
||||||
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",
|
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
|||||||
headless: true,
|
headless: true,
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
executablePath: chromePath,
|
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",
|
screenshot: "only-on-failure",
|
||||||
trace: "retain-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 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 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 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 interface IORequestIR { format: IOFormat; operation: "IMPORT" | "EXPORT" | "ANALYZE"; sourcePath?: string; sourceSha256?: string; byteLength?: number; externalUris: string[]; archiveEntries: IOArchiveEntryIR[] }
|
||||||
|
|
||||||
export class AssetLibraryValidationError extends Error {
|
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}`);
|
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 {
|
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 (!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");
|
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.sourcePath !== undefined) request.sourcePath = projectPath(value.sourcePath, "sourcePath", "IO_EXTERNAL_URI_BLOCKED");
|
||||||
if (value.sourceSha256 !== undefined) request.sourceSha256 = digest(value.sourceSha256, "sourceSha256");
|
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);
|
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 => {
|
request.archiveEntries = value.archiveEntries.map((entry, index): IOArchiveEntryIR => {
|
||||||
if (!record(entry)) throw new AssetLibraryValidationError("IO_ARCHIVE_UNSAFE", `archiveEntries[${index}] is invalid`);
|
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);
|
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 };
|
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;
|
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 {
|
export function gateIORequest(value: unknown): CapabilityGateResult {
|
||||||
const request = parseIORequest(value); const capability = `${request.format}_${request.operation}`;
|
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);
|
if ((request.format === "GLB" && (request.operation === "ANALYZE" || request.operation === "EXPORT")) || (request.format === "USD" && request.operation === "ANALYZE")) return readyGate("N-023", capability);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const COMPOSITOR_BUDGET = {
|
|||||||
maxImageBytes: 256 * 1024 * 1024,
|
maxImageBytes: 256 * 1024 * 1024,
|
||||||
maxBlurRadius: 32,
|
maxBlurRadius: 32,
|
||||||
maxOperations: 100_000_000,
|
maxOperations: 100_000_000,
|
||||||
|
maxFrameCacheBytes: 256 * 1024 * 1024,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const COMPOSITOR_NODE_TYPES = [
|
export const COMPOSITOR_NODE_TYPES = [
|
||||||
@@ -77,6 +78,69 @@ export interface CompositorExecutionResult {
|
|||||||
evaluatedNodeIds: string[];
|
evaluatedNodeIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompositorCachedExecutionResult extends CompositorExecutionResult {
|
||||||
|
cacheKey: string;
|
||||||
|
cacheHit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompositorFrameCacheEntry {
|
||||||
|
result: CompositorExecutionResult;
|
||||||
|
byteLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneImage(image: CompositorImageBuffer): CompositorImageBuffer {
|
||||||
|
return { ...image, data: image.data.slice() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneExecution(result: CompositorExecutionResult): CompositorExecutionResult {
|
||||||
|
return { composite: cloneImage(result.composite), viewers: new Map([...result.viewers].map(([id, image]) => [id, cloneImage(image)])), evaluatedNodeIds: [...result.evaluatedNodeIds] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function executionBytes(result: CompositorExecutionResult): number {
|
||||||
|
const unique = new Set<ArrayBuffer>();
|
||||||
|
unique.add(result.composite.data.buffer as ArrayBuffer);
|
||||||
|
for (const image of result.viewers.values()) unique.add(image.data.buffer as ArrayBuffer);
|
||||||
|
return [...unique].reduce((total, buffer) => total + buffer.byteLength, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompositorFrameCache {
|
||||||
|
readonly maxBytes: number;
|
||||||
|
private readonly entries = new Map<string, CompositorFrameCacheEntry>();
|
||||||
|
private currentBytes = 0;
|
||||||
|
|
||||||
|
constructor(maxBytes = COMPOSITOR_BUDGET.maxFrameCacheBytes) {
|
||||||
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > COMPOSITOR_BUDGET.maxFrameCacheBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame cache byte budget is invalid");
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
get byteLength(): number { return this.currentBytes; }
|
||||||
|
get size(): number { return this.entries.size; }
|
||||||
|
|
||||||
|
get(key: string): CompositorExecutionResult | undefined {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
this.entries.delete(key);
|
||||||
|
this.entries.set(key, entry);
|
||||||
|
return cloneExecution(entry.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, result: CompositorExecutionResult): void {
|
||||||
|
const clone = cloneExecution(result);
|
||||||
|
const byteLength = executionBytes(clone);
|
||||||
|
if (byteLength > this.maxBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame exceeds the cache byte budget");
|
||||||
|
const previous = this.entries.get(key);
|
||||||
|
if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); }
|
||||||
|
while (this.currentBytes + byteLength > this.maxBytes) {
|
||||||
|
const oldest = this.entries.entries().next().value as [string, CompositorFrameCacheEntry] | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
this.entries.delete(oldest[0]);
|
||||||
|
this.currentBytes -= oldest[1].byteLength;
|
||||||
|
}
|
||||||
|
this.entries.set(key, { result: clone, byteLength });
|
||||||
|
this.currentBytes += byteLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class CompositorValidationError extends Error {
|
export class CompositorValidationError extends Error {
|
||||||
readonly code: ErrorCode;
|
readonly code: ErrorCode;
|
||||||
|
|
||||||
@@ -267,6 +331,11 @@ export function executeCompositorGraph(
|
|||||||
const outputs = new Map<string, CompositorImageBuffer>();
|
const outputs = new Map<string, CompositorImageBuffer>();
|
||||||
const viewers = new Map<string, CompositorImageBuffer>();
|
const viewers = new Map<string, CompositorImageBuffer>();
|
||||||
const evaluatedNodeIds: string[] = [];
|
const evaluatedNodeIds: string[] = [];
|
||||||
|
let operationCounter = 0;
|
||||||
|
const checkCancelled = (operations = 1): void => {
|
||||||
|
operationCounter += operations;
|
||||||
|
if ((operationCounter === operations || operationCounter % 16_384 < operations) && options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||||
|
};
|
||||||
const requireInput = (nodeId: string, socket: string): CompositorImageBuffer => {
|
const requireInput = (nodeId: string, socket: string): CompositorImageBuffer => {
|
||||||
const source = incoming.get(`${nodeId}:${socket}`)?.fromNodeId;
|
const source = incoming.get(`${nodeId}:${socket}`)?.fromNodeId;
|
||||||
const image = source ? outputs.get(source) : undefined;
|
const image = source ? outputs.get(source) : undefined;
|
||||||
@@ -279,7 +348,7 @@ export function executeCompositorGraph(
|
|||||||
const evaluate = (id: string): CompositorImageBuffer => {
|
const evaluate = (id: string): CompositorImageBuffer => {
|
||||||
const existing = outputs.get(id);
|
const existing = outputs.get(id);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
checkCancelled();
|
||||||
const node = byId.get(id);
|
const node = byId.get(id);
|
||||||
if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing node ${id}`);
|
if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing node ${id}`);
|
||||||
for (const link of graph.links.filter((candidate) => candidate.toNodeId === id)) evaluate(link.fromNodeId);
|
for (const link of graph.links.filter((candidate) => candidate.toNodeId === id)) evaluate(link.fromNodeId);
|
||||||
@@ -297,7 +366,7 @@ export function executeCompositorGraph(
|
|||||||
const height = options.height ?? 1;
|
const height = options.height ?? 1;
|
||||||
output = allocate(width, height);
|
output = allocate(width, height);
|
||||||
const color = node.properties.color as number[];
|
const color = node.properties.color as number[];
|
||||||
for (let offset = 0; offset < output.data.length; offset += 4) output.data.set(color, offset);
|
for (let offset = 0; offset < output.data.length; offset += 4) { checkCancelled(); output.data.set(color, offset); }
|
||||||
}
|
}
|
||||||
else if (node.type === "TRANSFORM") {
|
else if (node.type === "TRANSFORM") {
|
||||||
const input = requireInput(id, "Image");
|
const input = requireInput(id, "Image");
|
||||||
@@ -305,6 +374,7 @@ export function executeCompositorGraph(
|
|||||||
const tx = Number(node.properties.translateX ?? 0), ty = Number(node.properties.translateY ?? 0);
|
const tx = Number(node.properties.translateX ?? 0), ty = Number(node.properties.translateY ?? 0);
|
||||||
const sx = Number(node.properties.scaleX ?? 1), sy = Number(node.properties.scaleY ?? 1);
|
const sx = Number(node.properties.scaleX ?? 1), sy = Number(node.properties.scaleY ?? 1);
|
||||||
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
|
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
|
||||||
|
checkCancelled();
|
||||||
const sourceX = Math.round((x - tx) / sx), sourceY = Math.round((y - ty) / sy);
|
const sourceX = Math.round((x - tx) / sx), sourceY = Math.round((y - ty) / sy);
|
||||||
if (sourceX < 0 || sourceX >= input.width || sourceY < 0 || sourceY >= input.height) continue;
|
if (sourceX < 0 || sourceX >= input.width || sourceY < 0 || sourceY >= input.height) continue;
|
||||||
output.data.set(input.data.subarray((sourceY * input.width + sourceX) * 4, (sourceY * input.width + sourceX) * 4 + 4), (y * input.width + x) * 4);
|
output.data.set(input.data.subarray((sourceY * input.width + sourceX) * 4, (sourceY * input.width + sourceX) * 4 + 4), (y * input.width + x) * 4);
|
||||||
@@ -315,6 +385,7 @@ export function executeCompositorGraph(
|
|||||||
output = allocate(input.width, input.height);
|
output = allocate(input.width, input.height);
|
||||||
const multiplier = node.type === "EXPOSURE" ? 2 ** Number(node.properties.exposure ?? 0) : 1;
|
const multiplier = node.type === "EXPOSURE" ? 2 ** Number(node.properties.exposure ?? 0) : 1;
|
||||||
for (let offset = 0; offset < input.data.length; offset += 4) {
|
for (let offset = 0; offset < input.data.length; offset += 4) {
|
||||||
|
checkCancelled();
|
||||||
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = node.type === "INVERT" ? 1 - input.data[offset + channel] : input.data[offset + channel] * multiplier;
|
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = node.type === "INVERT" ? 1 - input.data[offset + channel] : input.data[offset + channel] * multiplier;
|
||||||
output.data[offset + 3] = input.data[offset + 3];
|
output.data[offset + 3] = input.data[offset + 3];
|
||||||
}
|
}
|
||||||
@@ -325,6 +396,7 @@ export function executeCompositorGraph(
|
|||||||
sameSize(left, right, id);
|
sameSize(left, right, id);
|
||||||
output = allocate(left.width, left.height);
|
output = allocate(left.width, left.height);
|
||||||
for (let offset = 0; offset < left.data.length; offset += 4) {
|
for (let offset = 0; offset < left.data.length; offset += 4) {
|
||||||
|
checkCancelled();
|
||||||
if (node.type === "MIX") {
|
if (node.type === "MIX") {
|
||||||
const factor = Number(node.properties.factor ?? 0.5);
|
const factor = Number(node.properties.factor ?? 0.5);
|
||||||
for (let channel = 0; channel < 4; channel++) output.data[offset + channel] = left.data[offset + channel] * (1 - factor) + right.data[offset + channel] * factor;
|
for (let channel = 0; channel < 4; channel++) output.data[offset + channel] = left.data[offset + channel] * (1 - factor) + right.data[offset + channel] * factor;
|
||||||
@@ -344,6 +416,7 @@ export function executeCompositorGraph(
|
|||||||
if (operations > COMPOSITOR_BUDGET.maxOperations) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${id} exceeds the blur operation budget`);
|
if (operations > COMPOSITOR_BUDGET.maxOperations) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${id} exceeds the blur operation budget`);
|
||||||
output = allocate(input.width, input.height);
|
output = allocate(input.width, input.height);
|
||||||
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
|
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
|
||||||
|
checkCancelled((radius * 2 + 1) ** 2);
|
||||||
const target = (y * input.width + x) * 4;
|
const target = (y * input.width + x) * 4;
|
||||||
let samples = 0;
|
let samples = 0;
|
||||||
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
|
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
|
||||||
@@ -366,3 +439,52 @@ export function executeCompositorGraph(
|
|||||||
};
|
};
|
||||||
return { composite: evaluate(graph.outputNodeId), viewers, evaluatedNodeIds };
|
return { composite: evaluate(graph.outputNodeId), viewers, evaluatedNodeIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sha256Bytes(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("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableJSON(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||||
|
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compositorFrameCacheKey(
|
||||||
|
value: unknown,
|
||||||
|
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
|
||||||
|
frame: number,
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const graph = parseCompositorGraph(value);
|
||||||
|
if (!Number.isSafeInteger(frame) || frame < -1_000_000 || frame > 1_000_000) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "Compositor cache frame is invalid");
|
||||||
|
const sources: Array<{ id: string; width: number; height: number; sha256: string }> = [];
|
||||||
|
for (const resource of [...graph.resources].sort((left, right) => left.sourceId.localeCompare(right.sourceId))) {
|
||||||
|
const image = sourceImages.get(resource.sourceId);
|
||||||
|
if (!image) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resource ${resource.sourceId}`);
|
||||||
|
validateImage(image, resource.sourceId);
|
||||||
|
const sha256 = await sha256Bytes(image.data.buffer.slice(image.data.byteOffset, image.data.byteOffset + image.data.byteLength) as ArrayBuffer);
|
||||||
|
if (resource.sha256 && resource.sha256 !== sha256) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Compositor resource ${resource.sourceId} failed SHA-256 verification`);
|
||||||
|
sources.push({ id: resource.sourceId, width: image.width, height: image.height, sha256 });
|
||||||
|
}
|
||||||
|
const descriptor = new TextEncoder().encode(stableJSON({ graph, sources, frame, width: width ?? null, height: height ?? null }));
|
||||||
|
return sha256Bytes(descriptor.buffer as ArrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeCompositorGraphCached(
|
||||||
|
value: unknown,
|
||||||
|
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
|
||||||
|
cache: CompositorFrameCache,
|
||||||
|
options: { frame: number; width?: number; height?: number; cancelled?: () => boolean },
|
||||||
|
): Promise<CompositorCachedExecutionResult> {
|
||||||
|
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||||
|
const cacheKey = await compositorFrameCacheKey(value, sourceImages, options.frame, options.width, options.height);
|
||||||
|
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return { ...cached, cacheKey, cacheHit: true };
|
||||||
|
const result = executeCompositorGraph(value, sourceImages, options);
|
||||||
|
cache.set(cacheKey, result);
|
||||||
|
return { ...result, cacheKey, cacheHit: false };
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,8 +12,18 @@ export interface EditorRegionIR { id: string; kind: EditorRegionKind; visible: b
|
|||||||
export interface EditorAreaIR { id: string; editor: EditorTypeIR; regions: EditorRegionIR[]; rect: { x: number; y: number; width: number; height: number }; maximized: boolean }
|
export interface EditorAreaIR { id: string; editor: EditorTypeIR; regions: EditorRegionIR[]; rect: { x: number; y: number; width: number; height: number }; maximized: boolean }
|
||||||
export interface EditorWorkspaceIR { id: string; name: string; areas: EditorAreaIR[]; activeAreaId: string; revision: number }
|
export interface EditorWorkspaceIR { id: string; name: string; areas: EditorAreaIR[]; activeAreaId: string; revision: number }
|
||||||
export interface EditorContextIR { workspaceId: string; activeAreaId: string; activeEditor: EditorTypeIR; mode: EditorMode; activeObjectId: string | null; selection: string[]; viewLayer: string; pinnedData: string | null; revision: number }
|
export interface EditorContextIR { workspaceId: string; activeAreaId: string; activeEditor: EditorTypeIR; mode: EditorMode; activeObjectId: string | null; selection: string[]; viewLayer: string; pinnedData: string | null; revision: number }
|
||||||
export interface KeymapBindingIR { id: string; key: string; modifiers: string[]; command: string; enabled: boolean }
|
export interface KeymapBindingIR {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
modifiers: string[];
|
||||||
|
command: string;
|
||||||
|
enabled: boolean;
|
||||||
|
workspaceIds?: string[];
|
||||||
|
editors?: EditorTypeIR[];
|
||||||
|
modes?: EditorMode[];
|
||||||
|
}
|
||||||
export interface EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[] }
|
export interface EditorWorkflowIR { schemaVersion: typeof EDITOR_WORKFLOW_SCHEMA; workspaces: EditorWorkspaceIR[]; context: EditorContextIR; keymaps: KeymapBindingIR[] }
|
||||||
|
export interface KeyChordIR { key: string; modifiers: Array<"ALT" | "CTRL" | "META" | "SHIFT"> }
|
||||||
export type EditorWorkflowEditIR =
|
export type EditorWorkflowEditIR =
|
||||||
| { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string }
|
| { type: "SWITCH_WORKSPACE"; revision: number; workspaceId: string }
|
||||||
| { type: "SET_ACTIVE_AREA"; revision: number; areaId: string }
|
| { type: "SET_ACTIVE_AREA"; revision: number; areaId: string }
|
||||||
@@ -38,6 +48,8 @@ function rect(value: unknown, name: string): EditorAreaIR["rect"] {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
function overlap(a: EditorAreaIR["rect"], b: EditorAreaIR["rect"]): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; }
|
function overlap(a: EditorAreaIR["rect"], b: EditorAreaIR["rect"]): boolean { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; }
|
||||||
|
function scopeOverlaps<T>(left: readonly T[] | undefined, right: readonly T[] | undefined): boolean { return !left?.length || !right?.length || left.some((value) => right.includes(value)); }
|
||||||
|
function keymapScopesOverlap(left: KeymapBindingIR, right: KeymapBindingIR): boolean { return scopeOverlaps(left.workspaceIds, right.workspaceIds) && scopeOverlaps(left.editors, right.editors) && scopeOverlaps(left.modes, right.modes); }
|
||||||
|
|
||||||
export function parseEditorWorkflow(value: unknown): EditorWorkflowIR {
|
export function parseEditorWorkflow(value: unknown): EditorWorkflowIR {
|
||||||
if (!record(value) || value.schemaVersion !== EDITOR_WORKFLOW_SCHEMA || !Array.isArray(value.workspaces) || !record(value.context) || !Array.isArray(value.keymaps)) throw new EditorWorkflowValidationError("PROTOCOL_MISMATCH", "Unsupported editor workflow schema");
|
if (!record(value) || value.schemaVersion !== EDITOR_WORKFLOW_SCHEMA || !Array.isArray(value.workspaces) || !record(value.context) || !Array.isArray(value.keymaps)) throw new EditorWorkflowValidationError("PROTOCOL_MISMATCH", "Unsupported editor workflow schema");
|
||||||
@@ -64,10 +76,47 @@ export function parseEditorWorkflow(value: unknown): EditorWorkflowIR {
|
|||||||
if (!EDITOR_TYPES.includes(contextValue.activeEditor as EditorTypeIR) || !["OBJECT", "EDIT", "POSE"].includes(contextValue.mode as string) || !Array.isArray(contextValue.selection)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context is invalid");
|
if (!EDITOR_TYPES.includes(contextValue.activeEditor as EditorTypeIR) || !["OBJECT", "EDIT", "POSE"].includes(contextValue.mode as string) || !Array.isArray(contextValue.selection)) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", "Context is invalid");
|
||||||
if (contextValue.selection.length > EDITOR_WORKFLOW_BUDGET.maxSelection || contextValue.selection.some((item) => typeof item !== "string" || item.length === 0)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection exceeds the budget");
|
if (contextValue.selection.length > EDITOR_WORKFLOW_BUDGET.maxSelection || contextValue.selection.some((item) => typeof item !== "string" || item.length === 0)) throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Selection exceeds the budget");
|
||||||
if (contextValue.activeObjectId !== null && typeof contextValue.activeObjectId !== "string") throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object is invalid");
|
if (contextValue.activeObjectId !== null && typeof contextValue.activeObjectId !== "string") throw new EditorWorkflowValidationError("EDITOR_SELECTION_INVALID", "Active object is invalid");
|
||||||
const keymapIds = new Set<string>(); const keymaps = value.keymaps.map((bindingValue, index): KeymapBindingIR => { const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`); const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id); const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16)); if (new Set(modifiers).size !== modifiers.length) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers duplicate`); return { id, key: text(bindingValue.key, `${name}.key`, 32), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false }; });
|
const keymapIds = new Set<string>(); const keymaps: KeymapBindingIR[] = [];
|
||||||
|
value.keymaps.forEach((bindingValue, index) => {
|
||||||
|
const name = `keymaps[${index}]`; if (!record(bindingValue) || !Array.isArray(bindingValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} is invalid`);
|
||||||
|
const id = text(bindingValue.id, `${name}.id`); if (keymapIds.has(id)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `Duplicate keymap ${id}`); keymapIds.add(id);
|
||||||
|
const modifiers = bindingValue.modifiers.map((modifier, modifierIndex) => text(modifier, `${name}.modifiers[${modifierIndex}]`, 16).toUpperCase()); if (new Set(modifiers).size !== modifiers.length || modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} modifiers are invalid or duplicate`); modifiers.sort();
|
||||||
|
const parseScope = <T extends string>(field: "workspaceIds" | "editors" | "modes", allowed?: readonly T[]): T[] | undefined => {
|
||||||
|
const source = bindingValue[field]; if (source === undefined) return undefined;
|
||||||
|
if (!Array.isArray(source) || source.length === 0 || source.length > 64) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid`);
|
||||||
|
const parsed = source.map((item, scopeIndex) => text(item, `${name}.${field}[${scopeIndex}]`, 256) as T);
|
||||||
|
if (new Set(parsed).size !== parsed.length || (allowed && parsed.some((item) => !allowed.includes(item)))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.${field} is invalid or duplicated`);
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
const binding: KeymapBindingIR = { id, key: text(bindingValue.key, `${name}.key`, 32).toUpperCase(), modifiers, command: text(bindingValue.command, `${name}.command`, 128), enabled: bindingValue.enabled !== false, workspaceIds: parseScope("workspaceIds"), editors: parseScope("editors", EDITOR_TYPES), modes: parseScope("modes", ["OBJECT", "EDIT", "POSE"] as const) };
|
||||||
|
if (binding.workspaceIds?.some((workspace) => !workspaceIds.has(workspace))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name}.workspaceIds references a missing workspace`);
|
||||||
|
if (binding.enabled && keymaps.some((candidate) => candidate.enabled && candidate.key === binding.key && candidate.modifiers.join("+") === binding.modifiers.join("+") && keymapScopesOverlap(candidate, binding))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", `${name} conflicts with another enabled keymap in the same context`);
|
||||||
|
keymaps.push(binding);
|
||||||
|
});
|
||||||
return { schemaVersion: EDITOR_WORKFLOW_SCHEMA, workspaces, context: { workspaceId, activeAreaId, activeEditor: contextValue.activeEditor as EditorTypeIR, mode: contextValue.mode as EditorMode, activeObjectId: contextValue.activeObjectId as string | null, selection: [...contextValue.selection] as string[], viewLayer: text(contextValue.viewLayer, "context.viewLayer"), pinnedData: contextValue.pinnedData === null ? null : text(contextValue.pinnedData, "context.pinnedData"), revision: integer(contextValue.revision, "context.revision", 0, Number.MAX_SAFE_INTEGER) }, keymaps };
|
return { schemaVersion: EDITOR_WORKFLOW_SCHEMA, workspaces, context: { workspaceId, activeAreaId, activeEditor: contextValue.activeEditor as EditorTypeIR, mode: contextValue.mode as EditorMode, activeObjectId: contextValue.activeObjectId as string | null, selection: [...contextValue.selection] as string[], viewLayer: text(contextValue.viewLayer, "context.viewLayer"), pinnedData: contextValue.pinnedData === null ? null : text(contextValue.pinnedData, "context.pinnedData"), revision: integer(contextValue.revision, "context.revision", 0, Number.MAX_SAFE_INTEGER) }, keymaps };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function keyChordFromKeyboardEvent(event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">): KeyChordIR {
|
||||||
|
const key = text(event.key, "event.key", 32).toUpperCase();
|
||||||
|
const modifiers: KeyChordIR["modifiers"] = [];
|
||||||
|
if (event.altKey) modifiers.push("ALT");
|
||||||
|
if (event.ctrlKey) modifiers.push("CTRL");
|
||||||
|
if (event.metaKey) modifiers.push("META");
|
||||||
|
if (event.shiftKey) modifiers.push("SHIFT");
|
||||||
|
return { key, modifiers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveKeymapCommand(value: unknown, chordValue: unknown): string | null {
|
||||||
|
const workflow = parseEditorWorkflow(value);
|
||||||
|
if (!record(chordValue) || !Array.isArray(chordValue.modifiers)) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord is invalid");
|
||||||
|
const chord = { key: text(chordValue.key, "keyChord.key", 32).toUpperCase(), modifiers: chordValue.modifiers.map((modifier, index) => text(modifier, `keyChord.modifiers[${index}]`, 16).toUpperCase()).sort() };
|
||||||
|
if (new Set(chord.modifiers).size !== chord.modifiers.length || chord.modifiers.some((modifier) => !["ALT", "CTRL", "META", "SHIFT"].includes(modifier))) throw new EditorWorkflowValidationError("EDITOR_KEYMAP_INVALID", "Key chord modifiers are invalid");
|
||||||
|
return workflow.keymaps.find((binding) => binding.enabled && binding.key === chord.key && binding.modifiers.join("+") === chord.modifiers.join("+") &&
|
||||||
|
(!binding.workspaceIds || binding.workspaceIds.includes(workflow.context.workspaceId)) &&
|
||||||
|
(!binding.editors || binding.editors.includes(workflow.context.activeEditor)) &&
|
||||||
|
(!binding.modes || binding.modes.includes(workflow.context.mode)))?.command ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyEditorWorkflowEdit(value: unknown, edit: EditorWorkflowEditIR): EditorWorkflowIR {
|
export function applyEditorWorkflowEdit(value: unknown, edit: EditorWorkflowEditIR): EditorWorkflowIR {
|
||||||
const workflow = parseEditorWorkflow(value); if (edit.revision !== workflow.context.revision) throw new EditorWorkflowValidationError("REVISION_CONFLICT", "Editor context revision is stale"); const clone = structuredClone(workflow);
|
const workflow = parseEditorWorkflow(value); if (edit.revision !== workflow.context.revision) throw new EditorWorkflowValidationError("REVISION_CONFLICT", "Editor context revision is stale"); const clone = structuredClone(workflow);
|
||||||
if (edit.type === "SWITCH_WORKSPACE") { const workspace = clone.workspaces.find((item) => item.id === edit.workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown workspace ${edit.workspaceId}`); clone.context.workspaceId = workspace.id; clone.context.activeAreaId = workspace.activeAreaId; clone.context.activeEditor = workspace.areas.find((item) => item.id === workspace.activeAreaId)?.editor ?? "VIEW_3D"; }
|
if (edit.type === "SWITCH_WORKSPACE") { const workspace = clone.workspaces.find((item) => item.id === edit.workspaceId); if (!workspace) throw new EditorWorkflowValidationError("EDITOR_LAYOUT_INVALID", `Unknown workspace ${edit.workspaceId}`); clone.context.workspaceId = workspace.id; clone.context.activeAreaId = workspace.activeAreaId; clone.context.activeEditor = workspace.areas.find((item) => item.id === workspace.activeAreaId)?.editor ?? "VIEW_3D"; }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user