Advance Blender WebEngine N-015 through N-022 parity
This commit is contained in:
@@ -1525,7 +1525,7 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
|
||||
type == "removeGreasePencilLayer" || type == "moveGreasePencilLayer" ||
|
||||
type == "insertGreasePencilFrame" || type == "removeGreasePencilFrame" ||
|
||||
type == "setGreasePencilStrokes" || type == "setVertexColors" ||
|
||||
type == "setVertexWeights" || type == "setLightProperties" ||
|
||||
type == "setVertexWeights" || type == "setCameraProperties" || type == "setLightProperties" ||
|
||||
type == "setWorldProperties" ||
|
||||
type == "setSculptMeshAttributes" ||
|
||||
type == "sculptStroke") {
|
||||
@@ -2031,7 +2031,7 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
|
||||
command.value("values", std::vector<float>()),
|
||||
command.value("normalize", false), command.value("mirror", false), main_error);
|
||||
}
|
||||
else if (type == "setLightProperties" || type == "setWorldProperties")
|
||||
else if (type == "setCameraProperties" || type == "setLightProperties" || type == "setWorldProperties")
|
||||
{
|
||||
const json properties = command.value("properties", json::object());
|
||||
applied = web_engine_blend_main_set_render_properties(
|
||||
|
||||
@@ -23,15 +23,19 @@
|
||||
|
||||
#include "BLI_endian_defines.h"
|
||||
#include "BLI_filereader.h"
|
||||
#include "BLI_utildefines.h"
|
||||
|
||||
#include "BLO_core_bhead.hh"
|
||||
#include "BLO_core_blend_header.hh"
|
||||
#include "BLO_core_file_reader.hh"
|
||||
|
||||
#include "DNA_genfile.h"
|
||||
#include "DNA_curve_enums.h"
|
||||
#include "DNA_mask_types.h"
|
||||
#include "DNA_modifier_types.h"
|
||||
#include "DNA_node_types.h"
|
||||
#include "DNA_sdna_types.h"
|
||||
#include "DNA_sequence_types.h"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -460,6 +464,7 @@ std::string id_prefix(const std::string &type_name)
|
||||
if (type_name == "GreasePencil") return "grease-pencil";
|
||||
if (type_name == "Text") return "text";
|
||||
if (type_name == "VFont") return "vfont";
|
||||
if (type_name == "Mask") return "mask";
|
||||
return "datablock";
|
||||
}
|
||||
|
||||
@@ -472,7 +477,7 @@ bool is_exported_id_type(const std::string &type_name)
|
||||
type_name == "bArmature" || type_name == "World" || type_name == "Curve" ||
|
||||
type_name == "MetaBall" || type_name == "PointCloud" || type_name == "Curves" ||
|
||||
type_name == "Volume" || type_name == "GreasePencil" || type_name == "Text" ||
|
||||
type_name == "VFont";
|
||||
type_name == "VFont" || type_name == "Mask";
|
||||
}
|
||||
|
||||
std::string unique_id(const std::string &prefix,
|
||||
@@ -655,6 +660,396 @@ std::vector<float> node_socket_default_values(const ParsedBlend &blend, const El
|
||||
return read_float_array(*blend.sdna, *default_value, "value", 4);
|
||||
}
|
||||
|
||||
std::optional<ElementRef> raw_array_element(const ParsedBlend &blend,
|
||||
uint64_t pointer,
|
||||
const std::string &type_name,
|
||||
size_t index);
|
||||
|
||||
std::string node_element_id(const ParsedBlend &blend, const ElementRef &node)
|
||||
{
|
||||
const int64_t identifier = read_integer(*blend.sdna, node, "identifier").value_or(0);
|
||||
return "compositor-node:" + std::to_string(identifier);
|
||||
}
|
||||
|
||||
std::optional<json> compositor_graph_from_scene(const ParsedBlend &blend,
|
||||
const ElementRef &scene,
|
||||
const std::string &scene_id,
|
||||
const std::string &scene_name)
|
||||
{
|
||||
std::optional<uint64_t> node_tree_pointer = read_pointer(
|
||||
*blend.sdna, scene, "compositing_node_group");
|
||||
if (!node_tree_pointer || *node_tree_pointer == 0) {
|
||||
node_tree_pointer = read_pointer(*blend.sdna, scene, "nodetree");
|
||||
}
|
||||
if (!node_tree_pointer || *node_tree_pointer == 0) return std::nullopt;
|
||||
const std::optional<ElementRef> node_tree = element_for_pointer(blend, *node_tree_pointer);
|
||||
if (!node_tree) return json{{"status", "BLOCKED"}};
|
||||
|
||||
const std::vector<ElementRef> source_nodes = linked_list_elements(blend, *node_tree, "nodes");
|
||||
const std::vector<ElementRef> source_links = linked_list_elements(blend, *node_tree, "links");
|
||||
if (source_nodes.empty() || source_nodes.size() > 4096 || source_links.size() > 16384) {
|
||||
return json{{"status", "BLOCKED"}};
|
||||
}
|
||||
|
||||
json nodes = json::array();
|
||||
json links = json::array();
|
||||
std::unordered_map<uint64_t, std::string> node_ids;
|
||||
std::unordered_set<std::string> used_node_ids;
|
||||
std::string output_node_id;
|
||||
std::string fallback_output_node_id;
|
||||
|
||||
for (const ElementRef &node : source_nodes) {
|
||||
if (node.block == nullptr) return json{{"status", "BLOCKED"}};
|
||||
const uint64_t pointer = block_pointer(*node.block) + node.offset;
|
||||
std::string node_id = node_element_id(blend, node);
|
||||
if (!used_node_ids.insert(node_id).second) node_id += ":" + std::to_string(pointer);
|
||||
if (node_id.size() > 256) return json{{"status", "BLOCKED"}};
|
||||
node_ids[pointer] = node_id;
|
||||
|
||||
const std::string blender_type = read_string(*blend.sdna, node, "idname");
|
||||
const std::string node_name = read_string(*blend.sdna, node, "name");
|
||||
json node_ir = {{"id", node_id},
|
||||
{"type", "UNSUPPORTED"},
|
||||
{"name", (node_name.empty() ? blender_type : node_name).substr(0, 256)},
|
||||
{"blenderType", blender_type.empty() ? "UNKNOWN" : blender_type.substr(0, 256)},
|
||||
{"properties", json::object()}};
|
||||
if (blender_type == "CompositorNodeComposite" || blender_type == "NodeGroupOutput") {
|
||||
node_ir["type"] = "COMPOSITE";
|
||||
node_ir.erase("blenderType");
|
||||
fallback_output_node_id = node_id;
|
||||
if ((read_integer(*blend.sdna, node, "flag").value_or(0) & NODE_DO_OUTPUT) != 0) {
|
||||
output_node_id = node_id;
|
||||
}
|
||||
}
|
||||
else if (blender_type == "CompositorNodeViewer") {
|
||||
node_ir["type"] = "VIEWER";
|
||||
node_ir.erase("blenderType");
|
||||
}
|
||||
else if (blender_type == "CompositorNodeRGB") {
|
||||
const std::vector<ElementRef> outputs = linked_list_elements(blend, node, "outputs");
|
||||
const std::vector<float> values = outputs.empty() ? std::vector<float>() :
|
||||
node_socket_default_values(blend, outputs.front());
|
||||
if (values.size() >= 4 && std::all_of(values.begin(), values.begin() + 4, [](const float value) {
|
||||
return std::isfinite(value) && value >= -65504.0f && value <= 65504.0f;
|
||||
}))
|
||||
{
|
||||
node_ir["type"] = "CONSTANT_COLOR";
|
||||
node_ir.erase("blenderType");
|
||||
node_ir["properties"] = {{"color", {values[0], values[1], values[2], values[3]}}};
|
||||
}
|
||||
}
|
||||
nodes.push_back(std::move(node_ir));
|
||||
}
|
||||
if (output_node_id.empty()) output_node_id = fallback_output_node_id;
|
||||
if (output_node_id.empty()) return json{{"status", "BLOCKED"}};
|
||||
|
||||
std::unordered_set<std::string> destinations;
|
||||
for (const ElementRef &link : source_links) {
|
||||
if ((read_integer(*blend.sdna, link, "flag").value_or(0) & NODE_LINK_MUTED) != 0) continue;
|
||||
const std::optional<uint64_t> from_node_pointer = read_pointer(*blend.sdna, link, "fromnode");
|
||||
const std::optional<uint64_t> to_node_pointer = read_pointer(*blend.sdna, link, "tonode");
|
||||
const std::optional<uint64_t> from_socket_pointer = read_pointer(*blend.sdna, link, "fromsock");
|
||||
const std::optional<uint64_t> to_socket_pointer = read_pointer(*blend.sdna, link, "tosock");
|
||||
if (!from_node_pointer || !to_node_pointer || !from_socket_pointer || !to_socket_pointer) continue;
|
||||
const auto from_id = node_ids.find(*from_node_pointer);
|
||||
const auto to_id = node_ids.find(*to_node_pointer);
|
||||
const std::optional<ElementRef> from_socket = element_for_pointer(blend, *from_socket_pointer);
|
||||
const std::optional<ElementRef> to_socket = element_for_pointer(blend, *to_socket_pointer);
|
||||
if (from_id == node_ids.end() || to_id == node_ids.end() || !from_socket || !to_socket) continue;
|
||||
std::string from_socket_name = read_string(*blend.sdna, *from_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 (to_socket_name.empty()) to_socket_name = read_string(*blend.sdna, *to_socket, "name");
|
||||
if (from_socket_name.empty() || to_socket_name.empty() || from_socket_name.size() > 256 ||
|
||||
to_socket_name.size() > 256)
|
||||
{
|
||||
return json{{"status", "BLOCKED"}};
|
||||
}
|
||||
const std::string destination = to_id->second + std::string(1, '\0') + to_socket_name;
|
||||
if (!destinations.insert(destination).second) return json{{"status", "BLOCKED"}};
|
||||
links.push_back({{"fromNodeId", from_id->second},
|
||||
{"fromSocket", from_socket_name},
|
||||
{"toNodeId", to_id->second},
|
||||
{"toSocket", to_socket_name}});
|
||||
}
|
||||
|
||||
return json{{"status", "AVAILABLE"},
|
||||
{"graph",
|
||||
{{"schemaVersion", 1},
|
||||
{"id", "compositor:" + scene_id},
|
||||
{"name", scene_name.empty() ? "Compositor" : scene_name + " Compositor"},
|
||||
{"outputNodeId", output_node_id},
|
||||
{"nodes", std::move(nodes)},
|
||||
{"links", std::move(links)},
|
||||
{"resources", json::array()}}}};
|
||||
}
|
||||
|
||||
std::optional<json> sequencer_timeline_from_scene(const ParsedBlend &blend,
|
||||
const ElementRef &scene,
|
||||
const std::string &scene_id,
|
||||
const uint64_t revision,
|
||||
const int frame_start,
|
||||
const int frame_end,
|
||||
const int fps,
|
||||
const float fps_base,
|
||||
const std::unordered_map<uint64_t, std::string> &ids_by_pointer)
|
||||
{
|
||||
const std::optional<uint64_t> editing_pointer = read_pointer(*blend.sdna, scene, "ed");
|
||||
if (!editing_pointer || *editing_pointer == 0) return std::nullopt;
|
||||
const std::optional<ElementRef> editing = element_for_pointer(blend, *editing_pointer);
|
||||
if (!editing) return json{{"status", "BLOCKED"}};
|
||||
|
||||
const std::vector<ElementRef> top_level = linked_list_elements(blend, *editing, "seqbase");
|
||||
if (top_level.size() > 100000 || fps <= 0 || !std::isfinite(fps_base) || fps_base <= 0.0f) {
|
||||
return json{{"status", "BLOCKED"}};
|
||||
}
|
||||
json strips = json::array();
|
||||
std::unordered_map<uint64_t, std::string> strip_ids;
|
||||
std::unordered_set<std::string> used_strip_ids;
|
||||
size_t total_strip_count = 0;
|
||||
auto reject = [](const std::string &) { return false; };
|
||||
auto strip_name = [&](const ElementRef &strip) {
|
||||
const std::string stored = read_string(*blend.sdna, strip, "name");
|
||||
return stored.rfind("SQ", 0) == 0 ? stored.substr(2) : stored;
|
||||
};
|
||||
|
||||
std::function<bool(const std::vector<ElementRef> &)> collect = [&](const std::vector<ElementRef> &items) {
|
||||
for (const ElementRef &strip : items) {
|
||||
if (strip.block == nullptr || ++total_strip_count > 100000) return reject("strip-budget");
|
||||
const uint64_t pointer = block_pointer(*strip.block) + strip.offset;
|
||||
const std::string name = strip_name(strip);
|
||||
std::string id = "strip:" + (name.empty() ? std::to_string(pointer) : name);
|
||||
if (!used_strip_ids.insert(id).second) id += ":" + std::to_string(pointer);
|
||||
if (id.size() > 256) return reject("strip-id");
|
||||
strip_ids[pointer] = id;
|
||||
const int64_t strip_type = read_integer(*blend.sdna, strip, "type").value_or(-1);
|
||||
if (strip_type == STRIP_TYPE_META) {
|
||||
const std::vector<ElementRef> children = linked_list_elements(blend, strip, "seqbase");
|
||||
if (!collect(children)) return reject("meta-collect");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (!collect(top_level)) return json{{"status", "BLOCKED"}};
|
||||
|
||||
std::function<bool(const std::vector<ElementRef> &)> emit = [&](const std::vector<ElementRef> &items) {
|
||||
for (const ElementRef &strip : items) {
|
||||
const uint64_t pointer = block_pointer(*strip.block) + strip.offset;
|
||||
const auto id_it = strip_ids.find(pointer);
|
||||
if (id_it == strip_ids.end()) return reject("strip-identity");
|
||||
const int64_t strip_type = read_integer(*blend.sdna, strip, "type").value_or(-1);
|
||||
const char *type = strip_type == STRIP_TYPE_SCENE ? "SCENE" :
|
||||
strip_type == STRIP_TYPE_MOVIE ? "MOVIE" :
|
||||
strip_type == STRIP_TYPE_IMAGE ? "IMAGE" :
|
||||
strip_type == STRIP_TYPE_SOUND ? "SOUND" :
|
||||
strip_type == STRIP_TYPE_META ? "META" :
|
||||
ELEM(strip_type,
|
||||
STRIP_TYPE_CROSS,
|
||||
STRIP_TYPE_GAMCROSS,
|
||||
STRIP_TYPE_ADD,
|
||||
STRIP_TYPE_MUL,
|
||||
STRIP_TYPE_TRANSFORM_LEGACY,
|
||||
STRIP_TYPE_COLOR) ?
|
||||
"EFFECT" :
|
||||
nullptr;
|
||||
if (type == nullptr) return reject("strip-type:" + std::to_string(strip_type));
|
||||
const int channel = int(read_integer(*blend.sdna, strip, "channel").value_or(0));
|
||||
const int length = int(read_integer(*blend.sdna, strip, "len").value_or(0));
|
||||
const float content_start = read_float(*blend.sdna, strip, "start").value_or(0.0f);
|
||||
const float start_offset = read_float(*blend.sdna, strip, "startofs").value_or(0.0f);
|
||||
const float end_offset = read_float(*blend.sdna, strip, "endofs").value_or(0.0f);
|
||||
const int display_start = int(std::round(content_start + start_offset));
|
||||
const int display_end = int(std::round(content_start + float(length) - end_offset));
|
||||
const int animation_start_offset = int(
|
||||
read_integer(*blend.sdna, strip, "anim_startofs").value_or(0));
|
||||
const int animation_end_offset = int(
|
||||
read_integer(*blend.sdna, strip, "anim_endofs").value_or(0));
|
||||
const float speed_factor = read_float(*blend.sdna, strip, "speed_factor").value_or(1.0f);
|
||||
const int64_t flags = read_integer(*blend.sdna, strip, "flag").value_or(0);
|
||||
if (channel < 1 || channel > 128 || display_end <= display_start || length < 0 ||
|
||||
!std::isfinite(speed_factor))
|
||||
{
|
||||
return reject("strip-range:" + id_it->second + ":" + std::to_string(channel) + ":" +
|
||||
std::to_string(display_start) + ":" + std::to_string(display_end) + ":" +
|
||||
std::to_string(length));
|
||||
}
|
||||
const int source_start = animation_start_offset;
|
||||
const int source_end = std::max(source_start, length - animation_end_offset);
|
||||
const float speed = speed_factor > 0.0f ? std::min(speed_factor, 1000.0f) : 1.0f;
|
||||
json result = {{"id", id_it->second},
|
||||
{"name", strip_name(strip)},
|
||||
{"type", type},
|
||||
{"channel", channel},
|
||||
{"frameStart", display_start},
|
||||
{"frameEnd", display_end},
|
||||
{"sourceStart", source_start},
|
||||
{"sourceEnd", source_end},
|
||||
{"speed", speed},
|
||||
{"muted", (flags & SEQ_MUTE) != 0},
|
||||
{"locked", (flags & SEQ_LOCK) != 0}};
|
||||
|
||||
if (strip_type == STRIP_TYPE_SCENE) {
|
||||
const std::optional<uint64_t> source_scene = read_pointer(*blend.sdna, strip, "scene");
|
||||
const auto source_id = source_scene ? ids_by_pointer.find(*source_scene) : ids_by_pointer.end();
|
||||
if (source_id == ids_by_pointer.end()) return reject("scene-source");
|
||||
result["sourceId"] = source_id->second;
|
||||
}
|
||||
else if (ELEM(strip_type, STRIP_TYPE_MOVIE, STRIP_TYPE_IMAGE)) {
|
||||
const std::optional<uint64_t> data_pointer = read_pointer(*blend.sdna, strip, "data");
|
||||
const std::optional<ElementRef> data = data_pointer ? element_for_pointer(blend, *data_pointer) :
|
||||
std::nullopt;
|
||||
if (!data) return reject("media-data");
|
||||
std::string path = read_string(*blend.sdna, *data, "dirpath");
|
||||
const std::optional<uint64_t> element_pointer = read_pointer(*blend.sdna, *data, "stripdata");
|
||||
const std::optional<ElementRef> source_element = element_pointer ?
|
||||
element_for_pointer(blend, *element_pointer) :
|
||||
std::nullopt;
|
||||
if (source_element) path += read_string(*blend.sdna, *source_element, "filename");
|
||||
if (path.rfind("//", 0) != 0 || path.size() <= 2 || path.size() > 2050) return reject("media-path:" + path);
|
||||
result["sourcePath"] = path;
|
||||
}
|
||||
else if (strip_type == STRIP_TYPE_SOUND) {
|
||||
const std::optional<uint64_t> sound_pointer = read_pointer(*blend.sdna, strip, "sound");
|
||||
const std::optional<ElementRef> sound = sound_pointer ? element_for_pointer(blend, *sound_pointer) :
|
||||
std::nullopt;
|
||||
if (!sound) return reject("sound-data");
|
||||
const std::string path = read_string(*blend.sdna, *sound, "filepath");
|
||||
if (path.rfind("//", 0) != 0 || path.size() <= 2 || path.size() > 2050) return reject("sound-path:" + path);
|
||||
result["sourcePath"] = path;
|
||||
}
|
||||
else if (strip_type == STRIP_TYPE_META) {
|
||||
const std::vector<ElementRef> children = linked_list_elements(blend, strip, "seqbase");
|
||||
json child_ids = json::array();
|
||||
for (const ElementRef &child : children) {
|
||||
const uint64_t child_pointer = block_pointer(*child.block) + child.offset;
|
||||
const auto child_id = strip_ids.find(child_pointer);
|
||||
if (child_id == strip_ids.end()) return reject("meta-child");
|
||||
child_ids.push_back(child_id->second);
|
||||
}
|
||||
result["childStripIds"] = std::move(child_ids);
|
||||
if (!emit(children)) return reject("meta-emit");
|
||||
}
|
||||
else {
|
||||
const char *effect = strip_type == STRIP_TYPE_CROSS ? "CROSS" :
|
||||
strip_type == STRIP_TYPE_GAMCROSS ? "GAMMA_CROSS" :
|
||||
strip_type == STRIP_TYPE_ADD ? "ADD" :
|
||||
strip_type == STRIP_TYPE_MUL ? "MULTIPLY" :
|
||||
strip_type == STRIP_TYPE_TRANSFORM_LEGACY ? "TRANSFORM" : "COLOR";
|
||||
json inputs = json::array();
|
||||
for (const char *member : {"input1", "input2"}) {
|
||||
const std::optional<uint64_t> input_pointer = read_pointer(*blend.sdna, strip, member);
|
||||
if (!input_pointer || *input_pointer == 0) continue;
|
||||
const auto input_id = strip_ids.find(*input_pointer);
|
||||
if (input_id == strip_ids.end()) return reject("effect-input");
|
||||
inputs.push_back(input_id->second);
|
||||
}
|
||||
if (inputs.empty()) return reject("effect-empty");
|
||||
result["effectType"] = effect;
|
||||
result["inputStripIds"] = std::move(inputs);
|
||||
}
|
||||
strips.push_back(std::move(result));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (!emit(top_level)) return json{{"status", "BLOCKED"}};
|
||||
const int fps_denominator = std::max(1, int(std::round(fps_base * 1000.0f)));
|
||||
return json{{"status", "AVAILABLE"},
|
||||
{"timeline",
|
||||
{{"schemaVersion", 1},
|
||||
{"id", "sequencer:" + scene_id},
|
||||
{"revision", revision},
|
||||
{"frameStart", frame_start},
|
||||
{"frameEnd", frame_end},
|
||||
{"fpsNumerator", fps * 1000},
|
||||
{"fpsDenominator", fps_denominator},
|
||||
{"strips", std::move(strips)}}}};
|
||||
}
|
||||
|
||||
std::optional<json> mask_from_record(const ParsedBlend &blend,
|
||||
const ElementRef &mask,
|
||||
const std::string &mask_id,
|
||||
const std::string &mask_name)
|
||||
{
|
||||
const std::vector<ElementRef> source_layers = linked_list_elements(blend, mask, "masklayers");
|
||||
if (source_layers.size() > 1024) return std::nullopt;
|
||||
json layers = json::array();
|
||||
size_t spline_count = 0;
|
||||
size_t point_count = 0;
|
||||
size_t layer_index = 0;
|
||||
for (const ElementRef &layer : source_layers) {
|
||||
const std::string layer_name = read_string(*blend.sdna, layer, "name");
|
||||
const int64_t layer_flags = read_integer(*blend.sdna, layer, "flag").value_or(0);
|
||||
const int64_t visibility_flags = read_integer(*blend.sdna, layer, "visibility_flag").value_or(0);
|
||||
const float opacity = read_float(*blend.sdna, layer, "alpha").value_or(1.0f);
|
||||
if (!std::isfinite(opacity) || opacity < 0.0f || opacity > 1.0f) return std::nullopt;
|
||||
json splines = json::array();
|
||||
size_t spline_index = 0;
|
||||
for (const ElementRef &spline : linked_list_elements(blend, layer, "splines")) {
|
||||
if (++spline_count > 100000) return std::nullopt;
|
||||
const int64_t spline_flags = read_integer(*blend.sdna, spline, "flag").value_or(0);
|
||||
const int64_t total_points = read_integer(*blend.sdna, spline, "tot_point").value_or(0);
|
||||
const std::optional<uint64_t> points_pointer = read_pointer(*blend.sdna, spline, "points");
|
||||
if (!points_pointer || total_points < 2 || total_points > 1000000 ||
|
||||
point_count > 1000000 - size_t(total_points))
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
point_count += size_t(total_points);
|
||||
json points = json::array();
|
||||
for (int64_t point_index = 0; point_index < total_points; point_index++) {
|
||||
const std::optional<ElementRef> point = raw_array_element(
|
||||
blend, *points_pointer, "MaskSplinePoint", size_t(point_index));
|
||||
if (!point) return std::nullopt;
|
||||
const std::optional<ElementRef> bezier = embedded_element(*blend.sdna, *point, "bezt");
|
||||
if (!bezier) return std::nullopt;
|
||||
const std::vector<float> coordinates = read_float_array(*blend.sdna, *bezier, "vec", 9);
|
||||
const int64_t left_handle = read_integer(*blend.sdna, *bezier, "h1").value_or(HD_FREE);
|
||||
const int64_t right_handle = read_integer(*blend.sdna, *bezier, "h2").value_or(HD_FREE);
|
||||
const int64_t selection = read_integer(*blend.sdna, *bezier, "f2").value_or(0);
|
||||
const float feather = read_float(*blend.sdna, *bezier, "weight").value_or(0.0f);
|
||||
if (coordinates.size() < 8 || std::any_of(coordinates.begin(), coordinates.end(), [](const float value) {
|
||||
return !std::isfinite(value) || value < -4.0f || value > 4.0f;
|
||||
}) || !std::isfinite(feather) || feather < 0.0f || feather > 100.0f)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
const char *handle_type = ELEM(left_handle, HD_AUTO, HD_AUTO_ANIM) &&
|
||||
ELEM(right_handle, HD_AUTO, HD_AUTO_ANIM) ?
|
||||
"AUTO" :
|
||||
left_handle == HD_VECT && right_handle == HD_VECT ? "VECTOR" :
|
||||
ELEM(left_handle, HD_ALIGN, HD_ALIGN_DOUBLESIDE) &&
|
||||
ELEM(right_handle, HD_ALIGN, HD_ALIGN_DOUBLESIDE) ?
|
||||
"ALIGNED" :
|
||||
"FREE";
|
||||
points.push_back({{"id", "mask-point:" + std::to_string(layer_index) + ":" +
|
||||
std::to_string(spline_index) + ":" + std::to_string(point_index)},
|
||||
{"co", {coordinates[3], coordinates[4]}},
|
||||
{"handleLeft", {coordinates[0], coordinates[1]}},
|
||||
{"handleRight", {coordinates[6], coordinates[7]}},
|
||||
{"handleType", handle_type},
|
||||
{"feather", feather},
|
||||
{"selected", (selection & BEZT_FLAG_SELECT) != 0}});
|
||||
}
|
||||
splines.push_back({{"id", "mask-spline:" + std::to_string(layer_index) + ":" +
|
||||
std::to_string(spline_index)},
|
||||
{"cyclic", (spline_flags & MASK_SPLINE_CYCLIC) != 0},
|
||||
{"fill", (spline_flags & MASK_SPLINE_NOFILL) == 0},
|
||||
{"points", std::move(points)}});
|
||||
spline_index++;
|
||||
}
|
||||
layers.push_back({{"id", "mask-layer:" + std::to_string(layer_index)},
|
||||
{"name", layer_name.empty() ? "Layer " + std::to_string(layer_index + 1) :
|
||||
layer_name},
|
||||
{"visible", (visibility_flags & MASK_HIDE_VIEW) == 0},
|
||||
{"locked", (layer_flags & MASK_LAYERFLAG_LOCKED) != 0 ||
|
||||
(visibility_flags & MASK_HIDE_SELECT) != 0},
|
||||
{"opacity", opacity},
|
||||
{"splines", std::move(splines)}});
|
||||
layer_index++;
|
||||
}
|
||||
return json{{"id", mask_id}, {"name", mask_name}, {"layers", std::move(layers)}};
|
||||
}
|
||||
|
||||
struct RawBlockSpan {
|
||||
const BlendBlock *block = nullptr;
|
||||
size_t offset = 0;
|
||||
@@ -2361,6 +2756,8 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
json non_mesh_data = json::array();
|
||||
json vfonts = json::array();
|
||||
json libraries = json::array();
|
||||
json masks = json::array();
|
||||
bool masks_blocked = false;
|
||||
json animations = json::array();
|
||||
json nla_tracks = json::array();
|
||||
json armatures = json::array();
|
||||
@@ -2960,6 +3357,10 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
}
|
||||
else if (record.type_name == "Scene") {
|
||||
json scene = {{"id", record.id}, {"name", record.name}};
|
||||
int scene_frame_start = frame_start;
|
||||
int scene_frame_end = frame_end;
|
||||
int scene_fps = 24;
|
||||
float scene_fps_base = 1.0f;
|
||||
if (const std::optional<uint64_t> root =
|
||||
read_pointer(*blend.sdna, element, "master_collection"))
|
||||
{
|
||||
@@ -2983,6 +3384,10 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
frame_current = int(read_integer(*blend.sdna, *render, "cfra").value_or(frame_current));
|
||||
frame_start = int(read_integer(*blend.sdna, *render, "sfra").value_or(frame_start));
|
||||
frame_end = int(read_integer(*blend.sdna, *render, "efra").value_or(frame_end));
|
||||
scene_frame_start = frame_start;
|
||||
scene_frame_end = frame_end;
|
||||
scene_fps = int(read_integer(*blend.sdna, *render, "frs_sec").value_or(24));
|
||||
scene_fps_base = read_float(*blend.sdna, *render, "frs_sec_base").value_or(1.0f);
|
||||
scene["renderEngine"] = read_string(*blend.sdna, *render, "engine");
|
||||
}
|
||||
if (const std::optional<ElementRef> units = embedded_element(*blend.sdna, element, "unit")) {
|
||||
@@ -3015,11 +3420,40 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
}
|
||||
scene["colorManagement"] = std::move(color_management);
|
||||
}
|
||||
if (const std::optional<json> compositor = compositor_graph_from_scene(
|
||||
blend, element, record.id, record.name))
|
||||
{
|
||||
scene["compositorStatus"] = compositor->value("status", "BLOCKED");
|
||||
if (compositor->contains("graph")) scene["compositorGraph"] = compositor->at("graph");
|
||||
}
|
||||
if (const std::optional<json> sequencer = sequencer_timeline_from_scene(blend,
|
||||
element,
|
||||
record.id,
|
||||
revision,
|
||||
scene_frame_start,
|
||||
scene_frame_end,
|
||||
scene_fps,
|
||||
scene_fps_base,
|
||||
ids_by_pointer))
|
||||
{
|
||||
scene["sequencerStatus"] = sequencer->value("status", "BLOCKED");
|
||||
if (sequencer->contains("timeline")) scene["sequencerTimeline"] = sequencer->at("timeline");
|
||||
}
|
||||
if (first_scene_id.empty()) {
|
||||
first_scene_id = record.id;
|
||||
}
|
||||
scenes.push_back(std::move(scene));
|
||||
}
|
||||
else if (record.type_name == "Mask") {
|
||||
if (const std::optional<json> parsed_mask = mask_from_record(
|
||||
blend, element, record.id, record.name))
|
||||
{
|
||||
masks.push_back(*parsed_mask);
|
||||
}
|
||||
else {
|
||||
masks_blocked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const BlendBlock &block : blend.blocks) {
|
||||
@@ -3111,6 +3545,7 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
{"nonMeshData", std::move(non_mesh_data)},
|
||||
{"vfonts", std::move(vfonts)},
|
||||
{"libraries", std::move(libraries)},
|
||||
{"trackingMaskStatus", masks_blocked ? "BLOCKED" : "AVAILABLE"},
|
||||
{"animations", std::move(animations)},
|
||||
{"nlaTracks", std::move(nla_tracks)},
|
||||
{"armatures", std::move(armatures)},
|
||||
@@ -3121,6 +3556,13 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
json(nullptr) :
|
||||
json(first_mesh_object_id.empty() ? first_object_id : first_mesh_object_id)},
|
||||
{"frame", {{"current", frame_current}, {"start", frame_start}, {"end", frame_end}}}};
|
||||
if (!masks_blocked) {
|
||||
snapshot["trackingMasks"] = {{"schemaVersion", 1},
|
||||
{"revision", revision},
|
||||
{"clips", json::array()},
|
||||
{"masks", std::move(masks)},
|
||||
{"bindings", json::array()}};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,14 @@ Curve *find_curve(Main *main, const char *data_id)
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Camera *find_camera(Main *main, const char *data_id)
|
||||
{
|
||||
if (main == nullptr || data_id == nullptr || strncmp(data_id, "camera:", 7) != 0) return nullptr;
|
||||
const std::string name(data_id + 7);
|
||||
for (Camera &camera : main->cameras) if (id_name(camera.id) == name) return &camera;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
VFont *find_vfont(Main *main, const char *font_id)
|
||||
{
|
||||
if (main == nullptr || font_id == nullptr || strncmp(font_id, "vfont:", 6) != 0) return nullptr;
|
||||
@@ -3976,6 +3984,120 @@ bool web_engine_blend_main_set_render_properties(WebBlendMainState *state,
|
||||
return true;
|
||||
};
|
||||
|
||||
if (strncmp(target_id, "camera:", 7) == 0) {
|
||||
Camera *camera = find_camera(state->main, target_id);
|
||||
if (camera == nullptr || !ensure_single_user_data(state->main, &camera->id, error) ||
|
||||
!reject_unknown({"projection", "lensMm", "sensorWidthMm", "sensorHeightMm", "sensorFit",
|
||||
"shift", "near", "far", "orthoScale", "depthOfField"}) ||
|
||||
!read_float("lensMm", 0.1f, 10000.0f, camera->lens) ||
|
||||
!read_float("sensorWidthMm", 0.1f, 10000.0f, camera->sensor_x) ||
|
||||
!read_float("sensorHeightMm", 0.1f, 10000.0f, camera->sensor_y) ||
|
||||
!read_float("near", 0.0001f, 1.0e9f, camera->clip_start) ||
|
||||
!read_float("far", 0.0002f, 1.0e12f, camera->clip_end) ||
|
||||
!read_float("orthoScale", 0.0001f, 1.0e9f, camera->ortho_scale))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (camera->clip_end <= camera->clip_start) {
|
||||
error = "RENDER_PROPERTY_INVALID: far must exceed near";
|
||||
return false;
|
||||
}
|
||||
if (properties.contains("projection")) {
|
||||
if (!properties["projection"].is_string()) {
|
||||
error = "RENDER_PROPERTY_INVALID: projection must be a string";
|
||||
return false;
|
||||
}
|
||||
const std::string projection = properties["projection"].get<std::string>();
|
||||
if (projection == "PERSPECTIVE") camera->type = CAM_PERSP;
|
||||
else if (projection == "ORTHOGRAPHIC") camera->type = CAM_ORTHO;
|
||||
else {
|
||||
error = "RENDER_PROPERTY_INVALID: projection is unsupported";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (properties.contains("sensorFit")) {
|
||||
if (!properties["sensorFit"].is_number_integer()) {
|
||||
error = "RENDER_PROPERTY_INVALID: sensorFit must be an integer";
|
||||
return false;
|
||||
}
|
||||
const int fit = properties["sensorFit"].get<int>();
|
||||
if (fit < CAMERA_SENSOR_FIT_AUTO || fit > CAMERA_SENSOR_FIT_VERT) {
|
||||
error = "RENDER_PROPERTY_INVALID: sensorFit is outside the bounded enum range";
|
||||
return false;
|
||||
}
|
||||
camera->sensor_fit = eCamera_SensorFit(fit);
|
||||
}
|
||||
if (properties.contains("shift")) {
|
||||
const json &shift = properties["shift"];
|
||||
if (!shift.is_array() || shift.size() != 2 || !shift[0].is_number() || !shift[1].is_number()) {
|
||||
error = "RENDER_PROPERTY_INVALID: shift must contain two numbers";
|
||||
return false;
|
||||
}
|
||||
const float x = shift[0].get<float>();
|
||||
const float y = shift[1].get<float>();
|
||||
if (!std::isfinite(x) || !std::isfinite(y) || x < -1000.0f || x > 1000.0f || y < -1000.0f || y > 1000.0f) {
|
||||
error = "RENDER_PROPERTY_INVALID: shift is outside the bounded range";
|
||||
return false;
|
||||
}
|
||||
camera->shiftx = x;
|
||||
camera->shifty = y;
|
||||
}
|
||||
if (properties.contains("depthOfField")) {
|
||||
const json &dof = properties["depthOfField"];
|
||||
if (!dof.is_object() || dof.empty()) {
|
||||
error = "RENDER_PROPERTY_INVALID: depthOfField must be a non-empty object";
|
||||
return false;
|
||||
}
|
||||
for (const auto &[key, value] : dof.items()) {
|
||||
(void)value;
|
||||
if (!std::unordered_set<std::string>{"enabled", "focusDistance", "apertureFStop",
|
||||
"apertureBlades", "apertureRotation",
|
||||
"apertureRatio"}.contains(key)) {
|
||||
error = "RENDER_PROPERTY_INVALID: unsupported depthOfField property " + key;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (dof.contains("enabled")) {
|
||||
if (!dof["enabled"].is_boolean()) {
|
||||
error = "RENDER_PROPERTY_INVALID: depthOfField.enabled must be boolean";
|
||||
return false;
|
||||
}
|
||||
if (dof["enabled"].get<bool>()) camera->dof.flag |= CAM_DOF_ENABLED;
|
||||
else camera->dof.flag &= ~CAM_DOF_ENABLED;
|
||||
}
|
||||
const auto dof_float = [&](const char *key, float minimum, float maximum, float &target) {
|
||||
if (!dof.contains(key)) return true;
|
||||
if (!dof[key].is_number()) return false;
|
||||
const float value = dof[key].get<float>();
|
||||
if (!std::isfinite(value) || value < minimum || value > maximum) return false;
|
||||
target = value;
|
||||
return true;
|
||||
};
|
||||
if (!dof_float("focusDistance", 0.0f, 1.0e9f, camera->dof.focus_distance) ||
|
||||
!dof_float("apertureFStop", 0.01f, 1000.0f, camera->dof.aperture_fstop) ||
|
||||
!dof_float("apertureRotation", -6.28318531f, 6.28318531f, camera->dof.aperture_rotation) ||
|
||||
!dof_float("apertureRatio", 0.01f, 100.0f, camera->dof.aperture_ratio)) {
|
||||
error = "RENDER_PROPERTY_INVALID: depthOfField numeric value is outside the bounded range";
|
||||
return false;
|
||||
}
|
||||
if (dof.contains("apertureBlades")) {
|
||||
if (!dof["apertureBlades"].is_number_integer()) {
|
||||
error = "RENDER_PROPERTY_INVALID: depthOfField.apertureBlades must be an integer";
|
||||
return false;
|
||||
}
|
||||
const int blades = dof["apertureBlades"].get<int>();
|
||||
if (blades < 0 || blades > 64) {
|
||||
error = "RENDER_PROPERTY_INVALID: depthOfField.apertureBlades is outside the bounded range";
|
||||
return false;
|
||||
}
|
||||
camera->dof.aperture_blades = blades;
|
||||
}
|
||||
}
|
||||
camera->id.recalc |= ID_RECALC_PARAMETERS;
|
||||
restore_view_settings();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strncmp(target_id, "light:", 6) == 0) {
|
||||
Light *light = find_light(state->main, target_id);
|
||||
if (light == nullptr || !ensure_single_user_data(state->main, &light->id, error)) return false;
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
## 当前声明
|
||||
|
||||
当前声明覆盖 Blender 5.2 `.blend` 中有限非 Mesh 数据的稳定识别、Main 权威写回、undo/redo、
|
||||
保存重开、真实 evaluated mesh 报告和 WNM 二进制分块。Curve cyclic/handle 目前只有有界
|
||||
属性读写,未声明完整 topology editor、handle/cyclic gizmo、VDB 体渲染、完整 USD loss
|
||||
fixture 或跨浏览器性能等价。
|
||||
保存重开、真实 evaluated mesh 报告和 WNM 二进制分块。Curve cyclic/handle 已有有界
|
||||
属性读写、多目标选择和单事务平移提交,未声明完整 topology editor、连续 gizmo preview、
|
||||
VDB 体渲染或完整 Volume USD loss fixture。
|
||||
|
||||
| 数据族 | Reader | Web 预览 | 当前门 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -45,14 +45,15 @@ fixture 或跨浏览器性能等价。
|
||||
Metaball element 写回,以及 bounded Poly Curve create、delete、1D Poly/Bezier/NURBS conversion、
|
||||
二维 Surface U/V dimensions/order/rational weight 网格替换和 Curve/Surface/Font/Metaball 数据块
|
||||
重命名;一维多 spline 创建/删除/重排和批量 handle/cyclic 已由单个原子 Main 事务覆盖。
|
||||
多 handle 选择拖拽/gizmo UI 仍阻断。单用户、undo/redo、save/reopen 由
|
||||
多 handle 选择、高亮和有界平移已由单个 Main transaction 覆盖,连续拖拽 preview 与
|
||||
handle 专用 gizmo UI 仍阻断。单用户、undo/redo、save/reopen 由
|
||||
`check-nonmesh-roundtrip.mjs` 验证。
|
||||
9. 主线程与 OffscreenCanvas 对 PointCloud/Curve 控制点和 Bezier handle 代理提供 bounded raycast,并对 Mesh 的
|
||||
VERT/EDGE/FACE 使用一致的三角顶点、边索引解析,回传既有 selection callback;完整
|
||||
gizmo、跨对象 selection history 与精确范围 patch 仍未声明;新增 local bounded selection
|
||||
history 仅用于 revision/stale-hit 门和撤销栈验收。Bezier 左/右 handle 现以全局控制点映射
|
||||
独立拾取,轴向 gizmo 通过精确 Main 命令写回并通过 undo/redo、save/reopen;多选拖拽、跨对象
|
||||
history 和 range patch 仍未声明。
|
||||
VERT/EDGE/FACE 使用一致的三角顶点、边索引解析,回传既有 selection callback。selection
|
||||
history schema 2 覆盖跨对象、多 data block、多 element kind、revision/stale-hit、undo/redo
|
||||
和有界 selected/unselected range patch,并兼容 schema 1。Bezier 左/右 handle 以全局控制点
|
||||
映射独立拾取;多 handle 选择、高亮和有界平移经精确 Main 命令写回并通过 undo/redo、
|
||||
save/reopen。完整连续 gizmo preview 仍未声明。
|
||||
10. `queryNonMeshCapability(dataId)` 返回 N-015 `READY/BLOCKED` 与机器可读错误码;GLB 对未求值
|
||||
非 Mesh 拒绝导出。Font/Metaball/二维 NURBS Surface 的 depsgraph 三角网格与 Curve 的
|
||||
evaluated edge line primitive 已通过 GLB 导出和 Blender 5.2 桌面回导几何校验;零三角且零边的结果仍返回
|
||||
@@ -68,12 +69,13 @@ fixture 或跨浏览器性能等价。
|
||||
## 后续分解
|
||||
|
||||
1. N-015-B3:接入真实 OpenVDB decoder 与体素纹理/射线步进;在此之前保持 Volume renderer 阻断。
|
||||
2. N-015-C1:多 handle 选择拖拽与 gizmo UI;多 spline 创建/删除/重排、批量 handle/cyclic
|
||||
editor transaction 和二维 Surface U/V topology/order/rational weight 网格替换已完成。
|
||||
2. N-015-C1:连续多 handle 拖拽 preview 与专用 gizmo UI;多 handle 选择、高亮、单事务平移、
|
||||
多 spline 创建/删除/重排、批量 handle/cyclic editor transaction 和二维 Surface U/V
|
||||
topology/order/rational weight 网格替换已完成。
|
||||
3. N-015-C2:任意外部路径的新字体导入与资源沙箱;已有/packed VFont 的四 style link Main
|
||||
写回、字符级样式、kern/material 与 textbox 已完成并通过 PFB desktop/WASM golden。
|
||||
4. N-015-D1:跨对象 selection history、多 handle 拖拽和 SceneDelta range patch;左右 handle
|
||||
identity raycast、单 handle 轴向 gizmo/Main 写回已完成。
|
||||
4. N-015-D1:跨对象 selection history、多目标 element identity、range patch、左右 handle
|
||||
raycast 和多 handle Main 写回已完成;连续拖拽 preview 归入 C1 阻断。
|
||||
5. N-015-D2:evaluated preview 与源控制笼分层显示、WebGPU 等价。
|
||||
6. N-015-E1:补齐 Volume 的完整 loss fixture;PointCloud/Curves/Hair、Curve line bake、真实
|
||||
4x4 NURBS Surface mesh、desktop geometry golden、7 对象 GLB 与 USDA desktop round-trip 已通过。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# N-016 Grease Pencil
|
||||
|
||||
状态:`BLOCKED`(协议、有限 reader 和有限 Main transaction 已落地;完整 2D/3D 编辑器、onion skin、modifier 语义和桌面 golden 仍阻断)
|
||||
状态:`BLOCKED`(协议、有限 reader/Main transaction、当前帧及相邻帧 onion preview 已落地;
|
||||
完整 2D/3D 编辑器、modifier 语义和桌面 golden 仍阻断)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -15,13 +16,14 @@
|
||||
`setGreasePencilStrokes` transaction(空 stroke 数组用于擦除)。命令要求单用户数据块,
|
||||
使用既有 authoritative Main、undo/redo 和 save/reopen 流程。
|
||||
4. N-016-C(部分):radius、opacity、vertex color、cyclic stroke 和 material index
|
||||
已读写;onion skin 仅输出 layer 状态,不声明时间邻帧渲染。
|
||||
已读写;开启 layer onion skin 时只取最近前一帧和后一帧,使用独立色调、透明材质和关闭
|
||||
depth write 的有界 preview,当前 drawing 不被替换。
|
||||
5. N-016-D(部分):共享 Three.js 适配器按当前 frame 选择每个可见 layer 最近的有效
|
||||
drawing,渲染真实 3D stroke/闭环;Chromium 主线程与 OffscreenCanvas 均通过非空像素门。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-016-C:material datablock 事务、onion skin preview 和完整 Grease Pencil modifier reader。
|
||||
- N-016-C:material datablock 事务、onion fade/range 完整语义和完整 Grease Pencil modifier reader。
|
||||
- N-016-D:2D editor、stroke/point selection、timeline/dope integration、gizmo 和 worker restart;
|
||||
3D current-frame stroke viewport 已完成。
|
||||
- N-016-E:desktop drawing hash、Chromium 像素 golden、GLB/USD loss report 和 OPFS。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# N-017 Paint 与权重
|
||||
|
||||
状态:`BLOCKED`(stroke/patch schema、Main 顶点色/权重 transaction 已落地;真实命中、texture paint 和 GPU/image 生命周期未实现)
|
||||
状态:`BLOCKED`(stroke/patch schema、真实 Three raycast 命中、Main 顶点色/权重 transaction
|
||||
已落地;PBVH brush、texture paint 和 GPU/image 生命周期未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -14,10 +15,14 @@
|
||||
写入/移除 `MDeformWeight`,支持逐顶点 normalize。
|
||||
5. 顶点色和权重都已通过 Main undo/redo 与 `.blend` save/reopen;`mirror:true` 在没有已验证
|
||||
对称拓扑映射时返回 `CAPABILITY_MISSING`,不会伪造镜像结果。
|
||||
6. `paintHitFromIntersection` 消费真实 Three `Raycaster` intersection,按 indexed/non-indexed
|
||||
triangle 解析顶点,输出对象/data stable ID、source face、局部 barycentric、世界法线、
|
||||
插值 UV 与 pressure;测试使用真实 BufferGeometry 射线,不注入伪命中。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-017-A:真实 PBVH/UV raycast 与 brush falloff 对桌面语义的对应。
|
||||
- N-017-A:PBVH 加速结构、遮挡/背面选择和 brush falloff 对桌面语义的对应;基础 Mesh/UV
|
||||
raycast hit 已完成。
|
||||
- N-017-B:limit/clean、已验证拓扑映射上的 mirror、桌面 brush/falloff 对照。
|
||||
- N-017-C:packed/UDIM tile transaction、色彩空间、dirty tile 和原子保存。
|
||||
- N-017-D/E:armature golden、seam bleed、mask/selection、GPU dispose、quota、坏图和 UI。
|
||||
|
||||
@@ -12,8 +12,10 @@ cache playback、WASM solver 和 bake job 未实现)
|
||||
有界设置摘要、依赖 stable ID 和 desktop bake cache;系统依赖环、重复依赖和超预算拒绝。
|
||||
3. N-018-C(门):cache 绑定 source blend/settings/input/cache SHA-256、帧范围和实际已缓存帧;
|
||||
`COMPLETE` 必须逐帧连续,frame seek 只返回精确命中帧,禁止复用相邻错误帧。
|
||||
4. 单 manifest 上限 4096 systems、每系统 1024 dependencies、256 settings/64 KiB、
|
||||
100k frames。
|
||||
4. Simulation cache 在 Worker restart 后重新验证整包;帧级读取在 OPFS 使用目标 range、
|
||||
IndexedDB 使用有界 slice,并再次校验该帧 SHA-256,越界帧返回 `SIMULATION_CACHE_MISSING`。
|
||||
5. 单 manifest 上限 4096 systems、每系统 1024 dependencies、256 settings/64 KiB、
|
||||
100k frames;单帧 512 MiB、整包 16 GiB,分配和 range 计算前检查安全整数及预算。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-019 灯光与渲染
|
||||
|
||||
状态:`BLOCKED`(Camera/Light/World/Scene metadata、Light/World 有界 Main 写回与
|
||||
Three exposure/shadow 映射已落地;完整 Camera/颜色管理 writer 和渲染等价未实现)
|
||||
状态:`BLOCKED`(Camera/Light/World/Scene metadata、Camera/Light/World 有界 Main 写回与
|
||||
Three exposure/shadow 映射已落地;Scene 颜色管理 writer 和渲染等价未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -15,11 +15,14 @@ Three exposure/shadow 映射已落地;完整 Camera/颜色管理 writer 和渲
|
||||
不再被 Three 强制打开。
|
||||
4. 白平衡读取有完整性门:Main Light/World 重写若导致 Blender 5.2 tint 序列化为异常近零值,
|
||||
不暴露垃圾数值而返回 `whiteBalanceStatus: BLOCKED`。
|
||||
5. Camera 严格白名单写回覆盖 Perspective/Orthographic、lens/sensor/sensor fit、shift、clip、
|
||||
ortho scale 和 DOF enable/focus distance/f-stop/blades/rotation/ratio;near/far 与范围在修改前
|
||||
拒绝,已通过 undo/redo 和 save/reopen。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-019-A:Camera 与 Scene color-management writer;直接 DNA 写入会破坏 white balance,
|
||||
必须改接 Blender/RNA 颜色管理 API 后才能开放。
|
||||
- N-019-A:Scene color-management writer;直接 DNA 写入会破坏 white balance,必须改接
|
||||
Blender/RNA 颜色管理 API 后才能开放。Camera writer 已完成。
|
||||
- N-019-B/C:AgX/Standard/Raw 的视觉等价、temperature 颜色、Area spread、Mist、DOF、
|
||||
transparent sorting、probe 和高级 shadow 参数。
|
||||
- N-019-D:Cycles/Freestyle/denoise 服务端 job 协议与结果 hash。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-020 Compositor
|
||||
|
||||
状态:`BLOCKED`(GraphIR 与有界 CPU executor 已落地;真实 Main graph、WebGPU、HDR golden
|
||||
和服务端执行未实现)
|
||||
状态:`BLOCKED`(GraphIR、有界 CPU executor 与真实 Main graph 结构 reader 已落地;完整节点
|
||||
参数映射、WebGPU、HDR golden 和服务端执行未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
16M pixels、256 MiB image 和 100M blur operations;分配前检查,支持 worker cancellation。
|
||||
4. N-020-D(门):Unsupported node 保留 Blender type metadata,并返回
|
||||
`COMPOSITOR_NODE_UNSUPPORTED`,不会删节点后继续运行。
|
||||
5. N-020-A(部分):Blender 5.2 Scene `compositing_node_group` 从真实 Main 快照读取稳定 node
|
||||
identifier、socket identifier 和 link;活动 Group Output/旧 Composite 映射为 Composite,
|
||||
Viewer 和常量 RGBA 映射到已验证节点,其他节点原样保留 `blenderType` 为 Unsupported。
|
||||
4096 nodes/16384 links、重复 destination、缺 output 或不可读 node tree 返回 blocked status。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-020-A:从真实 Main 读取/写回 compositor node tree、socket defaults、Render Layer pass。
|
||||
- N-020-A:Main graph 写回,以及 Transform/Invert/Exposure/Alpha Over/Blur/Mix/Image/Render Layer
|
||||
的逐节点参数与 resource/pass reader;基础真实图结构、常量色、Viewer/Composite 已完成。
|
||||
- N-020-B/C:WebGPU executor、tile/frame cache、增量 invalidation 和 GPU dispose。
|
||||
- N-020-D:服务端 Blender job、source hash 和结果提交。
|
||||
- N-020-E:desktop HDR/alpha/color-space golden、OOM/fault/device-loss。
|
||||
@@ -26,4 +31,5 @@
|
||||
|
||||
```bash
|
||||
WEB_TEST_PORT=5320 npm --prefix web run test:e2e -- --grep "N-020 CPU compositor"
|
||||
npm --prefix web run test:compositor-main-reader
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-021 Sequencer 与音频
|
||||
|
||||
状态:`BLOCKED`(strip schema、确定性时间编辑和 codec 能力门已落地;真实 Main
|
||||
写回、媒体解码/渲染、音频波形和服务端编码仍未实现)
|
||||
状态:`BLOCKED`(strip schema、真实 Main reader、确定性时间编辑和 codec 能力门已落地;
|
||||
Main 写回、媒体解码/渲染、音频波形和服务端编码仍未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -14,11 +14,15 @@
|
||||
映射,避免左右片段复用越界源帧。
|
||||
4. N-021-C/D(门):运行时只报告 WebCodecs/HTMLMedia 需要精确 probe;codec 必须
|
||||
出现在已验证 MIME 集合中才可放行,本地编码固定为 `BLOCKED`。
|
||||
5. N-021-A(部分):Blender 5.2 `Scene.ed/Editing.seqbase` 读取 Scene、Movie、Image、Sound、
|
||||
Meta 和协议支持的 Effect;显示范围由持久 `start/startofs/endofs/len` 计算,保留 channel、
|
||||
mute/lock、相对媒体路径、24/1.001 FPS 和 effect input stable ID。绝对路径、未知类型、
|
||||
坏依赖或超预算将 Scene sequencer 标为 `BLOCKED`。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-021-A/B:从真实 Main 读取并写回 scene sequence editor 的 strip、transition、
|
||||
modifier、undo/redo、save/reopen。
|
||||
- N-021-A/B:Main strip 写回、transition/modifier 完整参数、undo/redo 和 save/reopen;
|
||||
基础真实 reader 已完成。
|
||||
- N-021-C:WebCodecs 精确 seek/decode、音频 waveform、proxy 生成、A/V sync、丢帧和
|
||||
损坏媒体处理。
|
||||
- N-021-D:浏览器不支持的 codec、混音与最终编码的服务端 Blender job。
|
||||
@@ -28,4 +32,5 @@
|
||||
|
||||
```bash
|
||||
WEB_TEST_PORT=5321 npm --prefix web run test:e2e -- --grep "N-021 sequencer"
|
||||
npm --prefix web run test:sequencer-main-reader
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-022 Tracking 与 Mask
|
||||
|
||||
状态:`BLOCKED`(资源 schema、marker/mask 有界事务和 solve 能力门已落地;真实 Main、
|
||||
跟踪/相机求解、编辑器 overlay 与桌面 golden 未实现)
|
||||
状态:`BLOCKED`(资源 schema、真实 Mask Main reader、marker/mask 有界事务和 solve 能力门
|
||||
已落地;MovieClip Main、写回、跟踪/相机求解、编辑器 overlay 与桌面 golden 未实现)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
修改使用 revision 事务,锁定项和 stale revision 会拒绝,提交后再次完整解析。
|
||||
4. N-022-C(门):marker/mask schema edit 可用;browser tracking 只有显式 probe 成功后
|
||||
放行,camera solve 保持 `BLOCKED` 并要求受验证的服务端 Blender。
|
||||
5. N-022-A(部分):Blender 5.2 Mask Main reader 输出 layer、spline 和 Bezier point,保留
|
||||
cyclic/fill、viewport visibility、lock/hide-select、opacity、handle type/坐标、feather 与
|
||||
selection;1024 layer、100k spline、1M point 预算在分配前检查。MovieClip 不能从外部路径
|
||||
推导内容 SHA-256,继续阻断而不使用路径散列冒充源摘要。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- N-022-A/B:真实 Blender Main MovieClip/Mask 读取、写回、undo/redo、save/reopen。
|
||||
- N-022-A/B:真实 MovieClip reader、Mask/MovieClip 写回、undo/redo 和 save/reopen;Mask reader
|
||||
已完成。
|
||||
- N-022-C:浏览器 tracking 实现、完整 camera/plane solve 与服务端 job/hash 提交。
|
||||
- N-022-D:Clip/Mask editor、overlay、selection/raycast 和 compositor/scene 实际绑定。
|
||||
- N-022-E:desktop solve/error golden、媒体故障和 Chromium 测试。
|
||||
@@ -25,4 +30,5 @@
|
||||
|
||||
```bash
|
||||
WEB_TEST_PORT=5322 npm --prefix web run test:e2e -- --grep "N-022 tracking"
|
||||
npm --prefix web run test:mask-main-reader
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# N-026 全域发布门
|
||||
|
||||
状态:`BLOCKED`(machine-readable parity manifest、依赖/状态检查、证据聚合和确定性
|
||||
序列化已落地;跨浏览器、离线包、OPFS、性能/故障、SBOM 与发布审计证据未齐)
|
||||
序列化已落地;Chromium 完整套件、离线包、OPFS、性能/故障、SBOM 与发布审计证据未齐)
|
||||
|
||||
## 已验证切片
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
- 完整跨浏览器矩阵、跨 family 桌面 golden、离线/OPFS quota、1M/10M/长媒体基准和 fault fuzz 尚未有
|
||||
真实证据;上游 N-015 至 N-025 的阻断能力会传递到发布门。
|
||||
- Chromium 完整套件、跨 family 桌面 golden、离线/OPFS quota、1M/10M/长媒体基准和 fault fuzz
|
||||
尚未有真实证据;本项目当前不配置 Firefox/WebKit,上游 N-015 至 N-025 的阻断能力会传递到发布门。
|
||||
- 完整 release package 审计、许可证/source offer/SBOM、10M/长媒体性能和 OPFS quota 发布流程仍未完成;
|
||||
当前离线包确定性检查只覆盖已有二进制/源码归档门。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"name": "Non-mesh geometry",
|
||||
"status": "LOCAL_BOUNDED",
|
||||
"roadmapStatus": "in_progress",
|
||||
"completedSlices": ["A1", "A2-malformed-binary-partial", "A2-integer-overflow-fixtures", "A2-multichunk-attribute-completeness", "B2", "B3-metadata", "C1-topology-partial", "C1-handle-points-partial", "C1-handle-preview-raycast-partial", "C1-handle-identity-gizmo-main-roundtrip", "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-handle-identity-axis-gizmo", "D2-partial", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", "E1-glb-evaluated-nonmesh-roundtrip-partial", "E1-glb-curve-line-surface-mesh-evaluated", "E1-usda-four-object-desktop-roundtrip", "E1-pointcloud-curves-hair-glb-usd-loss-fixture", "E2-1M-chromium", "E2-chromium-worker-recovery", "E2-opfs-quota-chromium"],
|
||||
"blockedSlices": ["B3-vdb-renderer", "C1-multi-handle-selection-drag-gizmo", "C2-new-external-font-import", "D1-cross-object-history-range-patch", "E1-volume-loss-fixture"],
|
||||
"completedSlices": ["A1", "A2-malformed-binary-partial", "A2-integer-overflow-fixtures", "A2-multichunk-attribute-completeness", "B2", "B3-metadata", "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-rename-partial", "C1-create-delete-poly-partial", "C1-poly-bezier-nurbs-1d-conversion", "C1-multispline-create-delete-bulk-handle-cyclic-transaction", "C1-surface-2d-topology-transaction", "C2-font-geometry-partial", "C2-font-layout-partial", "C2-font-character-style-textbox-roundtrip", "C2-existing-packed-vfont-style-links", "C2-builtin-font-evaluation-exact", "C3-roundtrip", "D1-raycast-partial", "D1-offscreen-vert-edge-partial", "D1-selection-history-partial", "D1-cross-object-history-range-patch", "D1-handle-identity-axis-gizmo", "D2-partial", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", "E1-glb-evaluated-nonmesh-roundtrip-partial", "E1-glb-curve-line-surface-mesh-evaluated", "E1-usda-four-object-desktop-roundtrip", "E1-pointcloud-curves-hair-glb-usd-loss-fixture", "E2-1M-chromium", "E2-chromium-worker-recovery", "E2-opfs-quota-chromium"],
|
||||
"blockedSlices": ["B3-vdb-renderer", "C1-continuous-handle-gizmo-preview", "C2-new-external-font-import", "E1-volume-loss-fixture"],
|
||||
"acceptance": ["web:test:nonmesh-roundtrip", "web:test:nonmesh-desktop-golden", "web:test:nonmesh-glb-blender-roundtrip", "web:test:nonmesh-usd-serialization", "web:test:nonmesh-usd-blender-roundtrip", "web:test:nonmesh-binary", "web:test:vdb", "web:test:selection-history", "web:e2e:N-015|non-mesh", "web:e2e:real OPFS quota"],
|
||||
"dependencies": []
|
||||
},
|
||||
@@ -19,7 +19,7 @@
|
||||
"name": "Grease Pencil",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "D-current-frame-stroke-preview-main-offscreen-chromium"],
|
||||
"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"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:test:grease-pencil", "web:e2e:N-016 Grease Pencil"],
|
||||
"dependencies": ["N-015"]
|
||||
@@ -29,7 +29,7 @@
|
||||
"name": "Paint and weights",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial"],
|
||||
"completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:test:paint-roundtrip", "web:e2e:N-017 paint"],
|
||||
"dependencies": ["N-016"]
|
||||
@@ -39,7 +39,7 @@
|
||||
"name": "Physics and simulation",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-family-capability-inventory", "B-settings-dependency-cache-manifest-partial", "C-exact-frame-selection-gate"],
|
||||
"completedSlices": ["A-family-capability-inventory", "B-settings-dependency-cache-manifest-partial", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:e2e:N-018 physics", "web:test:simulation-cache"],
|
||||
"dependencies": ["N-017"]
|
||||
@@ -49,7 +49,7 @@
|
||||
"name": "Lighting and render",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-camera-light-world-scene-reader-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", "B-three-exposure-shadow-mapping-partial", "A-white-balance-integrity-gate"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:test:lighting-roundtrip", "web:e2e:N-019 Scene exposure"],
|
||||
"dependencies": ["N-018"]
|
||||
@@ -59,9 +59,9 @@
|
||||
"name": "Compositor",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-graph-resource-cycle-schema", "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", "B-bounded-cpu-executor-partial", "C-image-operation-budget-cancel-partial", "D-unsupported-node-preservation-gate"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:e2e:N-020 CPU compositor"],
|
||||
"acceptance": ["web:test:compositor-main-reader", "web:e2e:N-020 CPU compositor"],
|
||||
"dependencies": ["N-019"]
|
||||
},
|
||||
{
|
||||
@@ -69,9 +69,9 @@
|
||||
"name": "Sequencer and audio",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-strip-resource-schema", "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", "C-runtime-codec-probe-gate"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:e2e:N-021 sequencer"],
|
||||
"acceptance": ["web:test:sequencer-main-reader", "web:e2e:N-021 sequencer"],
|
||||
"dependencies": ["N-020"]
|
||||
},
|
||||
{
|
||||
@@ -79,9 +79,9 @@
|
||||
"name": "Tracking and masks",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-clip-track-plane-mask-schema", "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"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:e2e:N-022 tracking"],
|
||||
"acceptance": ["web:test:mask-main-reader", "web:e2e:N-022 tracking"],
|
||||
"dependencies": ["N-021"]
|
||||
},
|
||||
{
|
||||
|
||||
BIN
tests/files/web/compositor_scene.blend
Normal file
BIN
tests/files/web/compositor_scene.blend
Normal file
Binary file not shown.
@@ -149,6 +149,28 @@
|
||||
"objects": 2,
|
||||
"meshes": 2,
|
||||
"features": ["edge-split", "screw", "desktop-golden", "disabled-modifier"]
|
||||
},
|
||||
{
|
||||
"id": "compositor_scene",
|
||||
"path": "compositor_scene.blend",
|
||||
"objects": 0,
|
||||
"meshes": 0,
|
||||
"features": ["compositor-main-reader", "socket-links", "unsupported-node-preservation"]
|
||||
},
|
||||
{
|
||||
"id": "sequencer_scene",
|
||||
"path": "sequencer_scene.blend",
|
||||
"objects": 0,
|
||||
"meshes": 0,
|
||||
"frameRange": [1, 250],
|
||||
"features": ["sequencer-main-reader", "relative-media-paths", "effect-dependencies", "fractional-fps"]
|
||||
},
|
||||
{
|
||||
"id": "mask_scene",
|
||||
"path": "mask_scene.blend",
|
||||
"objects": 0,
|
||||
"meshes": 0,
|
||||
"features": ["mask-main-reader", "bezier-handles", "feather-selection"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
tests/files/web/mask_scene.blend
Normal file
BIN
tests/files/web/mask_scene.blend
Normal file
Binary file not shown.
BIN
tests/files/web/media/sequencer-frame.png
Normal file
BIN
tests/files/web/media/sequencer-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 B |
BIN
tests/files/web/media/sequencer-silence.wav
Normal file
BIN
tests/files/web/media/sequencer-silence.wav
Normal file
Binary file not shown.
BIN
tests/files/web/sequencer_scene.blend
Normal file
BIN
tests/files/web/sequencer_scene.blend
Normal file
Binary file not shown.
57
tools/web/check-compositor-main-reader.mjs
Normal file
57
tools/web/check-compositor-main-reader.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import factory from "../../web/app/src/vendor/blender/web_engine.js";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
|
||||
const fixture = fs.readFileSync(new URL("tests/files/web/compositor_scene.blend", root));
|
||||
|
||||
function open(engine, handle, bytes) {
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
try {
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
}
|
||||
finally { engine._free(pointer); }
|
||||
}
|
||||
|
||||
function snapshot(engine, handle) {
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
try {
|
||||
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const length = engine.HEAPU32[lengthOut >>> 2];
|
||||
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
|
||||
}
|
||||
finally {
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
}
|
||||
}
|
||||
|
||||
const engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||
const handle = engine._web_engine_create();
|
||||
open(engine, handle, fixture);
|
||||
const scene = snapshot(engine, handle).scenes.find((candidate) => candidate.name === "CompositorScene");
|
||||
assert.equal(scene?.compositorStatus, "AVAILABLE");
|
||||
const graph = scene.compositorGraph;
|
||||
assert.equal(graph.schemaVersion, 1);
|
||||
assert.equal(graph.nodes.length, 4);
|
||||
assert.equal(graph.links.length, 2);
|
||||
const color = graph.nodes.find((node) => node.name === "WebConstantColor");
|
||||
assert.equal(color.type, "CONSTANT_COLOR");
|
||||
assert.deepEqual(color.properties.color, [0.125, 0.25, 0.5, 0.75]);
|
||||
assert.equal(graph.nodes.find((node) => node.name === "WebViewer").type, "VIEWER");
|
||||
const composite = graph.nodes.find((node) => node.name === "WebComposite");
|
||||
assert.equal(composite.type, "COMPOSITE");
|
||||
assert.equal(graph.outputNodeId, composite.id);
|
||||
const unsupported = graph.nodes.find((node) => node.name === "PreservedUnsupportedGlare");
|
||||
assert.equal(unsupported.type, "UNSUPPORTED");
|
||||
assert.equal(unsupported.blenderType, "CompositorNodeGlare");
|
||||
assert.ok(graph.links.every((link) => graph.nodes.some((node) => node.id === link.fromNodeId) &&
|
||||
graph.nodes.some((node) => node.id === link.toNodeId)));
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write("compositor-main-reader-ok graph-structure=passed socket-links=passed unsupported-preserved=passed\n");
|
||||
@@ -93,8 +93,22 @@ function assertEdited(scene) {
|
||||
const light = scene.lights.find((item) => item.id === "light:Area");
|
||||
const world = scene.worlds.find((item) => item.id === "world:World");
|
||||
const definition = scene.scenes.find((item) => item.id === "scene:Scene");
|
||||
assert.equal(camera.projection, "PERSPECTIVE");
|
||||
assert.equal(camera.depthOfField.enabled, false);
|
||||
assert.equal(camera.projection, "ORTHOGRAPHIC");
|
||||
close(camera.lensMm, 35, "camera lens");
|
||||
close(camera.sensorWidthMm, 32, "camera sensor width");
|
||||
close(camera.sensorHeightMm, 18, "camera sensor height");
|
||||
assert.equal(camera.sensorFit, 1);
|
||||
close(camera.shift[0], 0.1, "camera shift x");
|
||||
close(camera.shift[1], -0.2, "camera shift y");
|
||||
close(camera.near, 0.2, "camera near");
|
||||
close(camera.far, 500, "camera far");
|
||||
close(camera.orthoScale, 8, "camera ortho scale");
|
||||
assert.equal(camera.depthOfField.enabled, true);
|
||||
close(camera.depthOfField.focusDistance, 4.5, "camera focus distance");
|
||||
close(camera.depthOfField.apertureFStop, 1.8, "camera aperture f-stop");
|
||||
assert.equal(camera.depthOfField.apertureBlades, 7);
|
||||
close(camera.depthOfField.apertureRotation, 0.25, "camera aperture rotation");
|
||||
close(camera.depthOfField.apertureRatio, 1.2, "camera aperture ratio");
|
||||
close(light.energy, 400, "light energy");
|
||||
close(light.exposure, 1, "light exposure");
|
||||
assert.equal(light.castsShadow, false);
|
||||
@@ -120,6 +134,21 @@ open(engine, handle, fixture);
|
||||
const before = assertLighting(snapshot(engine, handle));
|
||||
command(engine, handle, { type: "setObjectVisibility", objectId: "object:BasicCube", visible: true });
|
||||
assert.equal(snapshot(engine, handle).scenes.find((item) => item.id === before.definition.id).colorManagement.whiteBalanceStatus, "AVAILABLE");
|
||||
reject(engine, handle, { type: "setCameraProperties", dataId: before.camera.id, properties: { near: 10, far: 1 } }, "RENDER_PROPERTY_INVALID");
|
||||
assert.equal(snapshot(engine, handle).cameras.find((item) => item.id === before.camera.id).near, before.camera.near);
|
||||
command(engine, handle, { type: "setCameraProperties", dataId: before.camera.id, properties: {
|
||||
projection: "ORTHOGRAPHIC", lensMm: 35, sensorWidthMm: 32, sensorHeightMm: 18,
|
||||
sensorFit: 1, shift: [0.1, -0.2], near: 0.2, far: 500, orthoScale: 8,
|
||||
depthOfField: { enabled: true, focusDistance: 4.5, apertureFStop: 1.8, apertureBlades: 7, apertureRotation: 0.25, apertureRatio: 1.2 },
|
||||
} });
|
||||
let cameraEdited = snapshot(engine, handle);
|
||||
assert.equal(cameraEdited.scenes.find((item) => item.id === before.definition.id).colorManagement.whiteBalanceStatus, "BLOCKED");
|
||||
assert.equal(cameraEdited.cameras.find((item) => item.id === before.camera.id).projection, "ORTHOGRAPHIC");
|
||||
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
assert.equal(snapshot(engine, handle).cameras.find((item) => item.id === before.camera.id).projection, "PERSPECTIVE");
|
||||
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
cameraEdited = snapshot(engine, handle);
|
||||
assert.equal(cameraEdited.cameras.find((item) => item.id === before.camera.id).projection, "ORTHOGRAPHIC");
|
||||
command(engine, handle, { type: "setLightProperties", dataId: before.light.id, properties: {
|
||||
color: [0.25, 0.5, 0.75], energy: 400, exposure: 1, castsShadow: false,
|
||||
temperature: 5000, useTemperature: true,
|
||||
@@ -143,4 +172,4 @@ open(engine, reopened, saved);
|
||||
assertEdited(snapshot(engine, reopened));
|
||||
engine._web_engine_destroy(reopened);
|
||||
|
||||
process.stdout.write("lighting-roundtrip-ok camera-dof=passed light-shadow-exposure=passed world-mist=passed color-management-reader=passed white-balance-gate=passed undo-redo=passed save-reopen=passed\n");
|
||||
process.stdout.write("lighting-roundtrip-ok camera-writer-dof=passed light-shadow-exposure=passed world-mist=passed color-management-reader=passed white-balance-gate=passed undo-redo=passed save-reopen=passed\n");
|
||||
|
||||
64
tools/web/check-mask-main-reader.mjs
Normal file
64
tools/web/check-mask-main-reader.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import factory from "../../web/app/src/vendor/blender/web_engine.js";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
|
||||
const fixture = fs.readFileSync(new URL("tests/files/web/mask_scene.blend", root));
|
||||
|
||||
function open(engine, handle, bytes) {
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
try {
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
}
|
||||
finally { engine._free(pointer); }
|
||||
}
|
||||
|
||||
function snapshot(engine, handle) {
|
||||
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
|
||||
try {
|
||||
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
|
||||
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
|
||||
}
|
||||
finally { engine._free(dataOut); engine._free(lengthOut); }
|
||||
}
|
||||
|
||||
const close = (actual, expected, label) => assert.ok(Math.abs(actual - expected) < 1e-6,
|
||||
`${label}: ${actual} != ${expected}`);
|
||||
const engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||
const handle = engine._web_engine_create();
|
||||
open(engine, handle, fixture);
|
||||
const scene = snapshot(engine, handle);
|
||||
assert.equal(scene.trackingMaskStatus, "AVAILABLE");
|
||||
const project = scene.trackingMasks;
|
||||
assert.equal(project.schemaVersion, 1);
|
||||
assert.equal(project.clips.length, 0);
|
||||
assert.equal(project.bindings.length, 0);
|
||||
assert.equal(project.masks.length, 1);
|
||||
const mask = project.masks[0];
|
||||
assert.equal(mask.id, "mask:WebMask");
|
||||
assert.equal(mask.layers.length, 1);
|
||||
const layer = mask.layers[0];
|
||||
assert.equal(layer.name, "WebMaskLayer");
|
||||
assert.equal(layer.locked, true);
|
||||
close(layer.opacity, 0.625, "layer opacity");
|
||||
const spline = layer.splines[0];
|
||||
assert.equal(spline.cyclic, true);
|
||||
assert.equal(spline.fill, false);
|
||||
assert.equal(spline.points.length, 3);
|
||||
assert.deepEqual(spline.points.map((point) => point.handleType), ["ALIGNED", "VECTOR", "FREE"]);
|
||||
for (const [label, actual, expected] of [
|
||||
["point co", spline.points[0].co, [0.1, 0.2]],
|
||||
["left handle", spline.points[0].handleLeft, [0.2, 0.2]],
|
||||
["right handle", spline.points[0].handleRight, [0, 0.2]],
|
||||
]) {
|
||||
actual.forEach((value, index) => close(value, expected[index], `${label}[${index}]`));
|
||||
}
|
||||
close(spline.points[2].feather, 0.75, "point feather");
|
||||
assert.deepEqual(spline.points.map((point) => point.selected), [true, false, true]);
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write("mask-main-reader-ok layers-splines=passed handles-feather=passed selection=passed\n");
|
||||
54
tools/web/check-sequencer-main-reader.mjs
Normal file
54
tools/web/check-sequencer-main-reader.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import factory from "../../web/app/src/vendor/blender/web_engine.js";
|
||||
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
|
||||
const fixture = fs.readFileSync(new URL("tests/files/web/sequencer_scene.blend", root));
|
||||
|
||||
function open(engine, handle, bytes) {
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
try {
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
}
|
||||
finally { engine._free(pointer); }
|
||||
}
|
||||
|
||||
function snapshot(engine, handle) {
|
||||
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
|
||||
try {
|
||||
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
|
||||
engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
|
||||
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
|
||||
}
|
||||
finally { engine._free(dataOut); engine._free(lengthOut); }
|
||||
}
|
||||
|
||||
const engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||
const handle = engine._web_engine_create();
|
||||
open(engine, handle, fixture);
|
||||
const scene = snapshot(engine, handle).scenes.find((candidate) => candidate.name === "SequencerScene");
|
||||
assert.equal(scene?.sequencerStatus, "AVAILABLE");
|
||||
const timeline = scene.sequencerTimeline;
|
||||
assert.equal(timeline.schemaVersion, 1);
|
||||
assert.equal(timeline.fpsNumerator, 24000);
|
||||
assert.equal(timeline.fpsDenominator, 1001);
|
||||
assert.equal(timeline.strips.length, 4);
|
||||
const sound = timeline.strips.find((strip) => strip.name === "WebSound");
|
||||
assert.equal(sound.type, "SOUND");
|
||||
assert.equal(sound.sourcePath, "//media/sequencer-silence.wav");
|
||||
assert.equal(sound.muted, true);
|
||||
assert.equal(sound.locked, true);
|
||||
const image = timeline.strips.find((strip) => strip.name === "WebImage");
|
||||
assert.equal(image.type, "IMAGE");
|
||||
assert.equal(image.sourcePath, "//media/sequencer-frame.png");
|
||||
const cross = timeline.strips.find((strip) => strip.name === "WebCross");
|
||||
assert.equal(cross.type, "EFFECT");
|
||||
assert.equal(cross.effectType, "CROSS");
|
||||
assert.equal(cross.inputStripIds.length, 2);
|
||||
assert.ok(cross.inputStripIds.every((id) => timeline.strips.some((strip) => strip.id === id)));
|
||||
engine._web_engine_destroy(handle);
|
||||
process.stdout.write("sequencer-main-reader-ok media-paths=passed fps=passed effect-dependencies=passed\n");
|
||||
39
tools/web/generate-compositor-fixture.py
Normal file
39
tools/web/generate-compositor-fixture.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def main(output_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
scene.name = "CompositorScene"
|
||||
tree = bpy.data.node_groups.new("WebCompositorTree", "CompositorNodeTree")
|
||||
scene.compositing_node_group = tree
|
||||
tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor")
|
||||
|
||||
color = tree.nodes.new("CompositorNodeRGB")
|
||||
color.name = "WebConstantColor"
|
||||
color.outputs["Color"].default_value = (0.125, 0.25, 0.5, 0.75)
|
||||
|
||||
viewer = tree.nodes.new("CompositorNodeViewer")
|
||||
viewer.name = "WebViewer"
|
||||
composite = tree.nodes.new("NodeGroupOutput")
|
||||
composite.name = "WebComposite"
|
||||
glare = tree.nodes.new("CompositorNodeGlare")
|
||||
glare.name = "PreservedUnsupportedGlare"
|
||||
|
||||
tree.links.new(color.outputs["Color"], viewer.inputs["Image"])
|
||||
tree.links.new(color.outputs["Color"], composite.inputs["Image"])
|
||||
|
||||
path = pathlib.Path(output_path).resolve()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
|
||||
print(f"compositor-fixture-generated path={path} nodes={len(tree.nodes)} links={len(tree.links)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender -b --python generate-compositor-fixture.py -- output.blend")
|
||||
main(arguments[0])
|
||||
42
tools/web/generate-mask-fixture.py
Normal file
42
tools/web/generate-mask-fixture.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def main(output_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mask = bpy.data.masks.new("WebMask")
|
||||
layer = mask.layers.new(name="WebMaskLayer")
|
||||
layer.alpha = 0.625
|
||||
layer.hide_select = True
|
||||
spline = layer.splines.new()
|
||||
spline.use_cyclic = True
|
||||
spline.use_fill = False
|
||||
spline.points.add(2)
|
||||
|
||||
values = [
|
||||
((0.1, 0.2), (0.0, 0.2), (0.2, 0.2), "ALIGNED", 0.25, True),
|
||||
((0.5, 0.7), (0.4, 0.7), (0.6, 0.7), "VECTOR", 0.5, False),
|
||||
((0.8, 0.3), (0.7, 0.3), (0.9, 0.3), "FREE", 0.75, True),
|
||||
]
|
||||
for point, value in zip(spline.points, values):
|
||||
co, left, right, handle_type, weight, selected = value
|
||||
point.co = co
|
||||
point.handle_left = left
|
||||
point.handle_right = right
|
||||
point.handle_type = handle_type
|
||||
point.weight = weight
|
||||
point.select = selected
|
||||
|
||||
path = pathlib.Path(output_path).resolve()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
|
||||
print(f"mask-fixture-generated path={path} points={len(spline.points)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender -b --python generate-mask-fixture.py -- output.blend")
|
||||
main(arguments[0])
|
||||
65
tools/web/generate-sequencer-fixture.py
Normal file
65
tools/web/generate-sequencer-fixture.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import pathlib
|
||||
import sys
|
||||
import wave
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def write_silence(path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(path), "wb") as stream:
|
||||
stream.setnchannels(1)
|
||||
stream.setsampwidth(2)
|
||||
stream.setframerate(8000)
|
||||
stream.writeframes(b"\0\0" * 8000)
|
||||
|
||||
|
||||
def main(output_path):
|
||||
output = pathlib.Path(output_path).resolve()
|
||||
media = output.parent / "media"
|
||||
audio_path = media / "sequencer-silence.wav"
|
||||
image_path = media / "sequencer-frame.png"
|
||||
write_silence(audio_path)
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("SequencerFrame", width=8, height=8, alpha=True)
|
||||
image.generated_color = (0.2, 0.4, 0.8, 1.0)
|
||||
image.filepath_raw = str(image_path)
|
||||
image.file_format = "PNG"
|
||||
image.save()
|
||||
bpy.data.images.remove(image)
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.name = "SequencerScene"
|
||||
scene.render.fps = 24
|
||||
scene.render.fps_base = 1.001
|
||||
editor = scene.sequence_editor_create()
|
||||
|
||||
sound = editor.strips.new_sound("WebSound", str(audio_path), channel=1, frame_start=10)
|
||||
sound.mute = True
|
||||
sound.lock = True
|
||||
sound.sound.filepath = "//media/sequencer-silence.wav"
|
||||
|
||||
image_strip = editor.strips.new_image("WebImage", str(image_path), channel=2, frame_start=12)
|
||||
image_strip.directory = "//media/"
|
||||
image_strip.elements[0].filename = "sequencer-frame.png"
|
||||
image_strip.frame_final_duration = 20
|
||||
|
||||
second_image = editor.strips.new_image("WebImageB", str(image_path), channel=3, frame_start=12)
|
||||
second_image.directory = "//media/"
|
||||
second_image.elements[0].filename = "sequencer-frame.png"
|
||||
second_image.frame_final_duration = 20
|
||||
cross = editor.strips.new_effect(
|
||||
"WebCross", "CROSS", channel=4, frame_start=12, length=20, input1=image_strip, input2=second_image)
|
||||
cross.mute = True
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=False)
|
||||
print(f"sequencer-fixture-generated path={output} strips={len(editor.strips_all)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:]
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender -b --python generate-sequencer-fixture.py -- output.blend")
|
||||
main(arguments[0])
|
||||
@@ -13,7 +13,7 @@
|
||||
"id": "web-engine-bootstrap",
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/web_engine.wasm",
|
||||
"sha256": "e12c4f76e9b8db6ba726fcc88a39cbf6f47ffa0b3f4bd704c03544169ff2937c",
|
||||
"sha256": "acc2d6808dac4590aa3c3915495a6e24396a946b257a2d137f64ccf4c3ba6fbf",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
|
||||
2
web/app/public/vendor/blender/web_engine.js
vendored
2
web/app/public/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
BIN
web/app/public/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -50,6 +50,7 @@ interface MeshEditSelection {
|
||||
mode: MeshElementMode;
|
||||
indices: Set<number>;
|
||||
nonMeshKind?: NonMeshElementKind;
|
||||
nonMeshSelections?: Map<NonMeshElementKind, Set<number>>;
|
||||
}
|
||||
|
||||
function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onTransform }: {
|
||||
@@ -106,9 +107,12 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
|
||||
if (renderer && snapshot && lodLevels) {
|
||||
for (const [meshId, levels] of Object.entries(lodLevels)) renderer.installLODLevels(meshId, levels);
|
||||
}
|
||||
renderer?.setSelection(selectedObjectIds);
|
||||
const elementSelection = meshSelection.meshId && meshSelection.nonMeshSelections
|
||||
? new Map([[meshSelection.meshId, meshSelection.nonMeshSelections]])
|
||||
: undefined;
|
||||
renderer?.setSelection(selectedObjectIds, elementSelection);
|
||||
renderer?.setInteractionMode(editMode, meshSelection.mode);
|
||||
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode]);
|
||||
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections]);
|
||||
|
||||
useEffect(() => {
|
||||
rendererRef.current?.setTextureAssets(textureAssets);
|
||||
@@ -382,7 +386,7 @@ export function App() {
|
||||
return next;
|
||||
});
|
||||
setSnapshot((current) => current ? { ...current, activeObjectId: id } : current);
|
||||
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set() }));
|
||||
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set(), nonMeshSelections: undefined }));
|
||||
};
|
||||
const selectMeshElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => {
|
||||
const owner = snapshot?.nodes.find((node) => node.dataId === meshId);
|
||||
@@ -391,11 +395,20 @@ export function App() {
|
||||
setSnapshot((current) => current ? { ...current, activeObjectId: owner.id } : current);
|
||||
}
|
||||
setMeshSelection((current) => {
|
||||
const preserve = additive && current.meshId === meshId && current.mode === mode && current.nonMeshKind === nonMeshKind;
|
||||
const preserve = additive && current.meshId === meshId && current.mode === mode;
|
||||
const next = preserve ? new Set(current.indices) : new Set<number>();
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return { meshId, mode, indices: next, nonMeshKind };
|
||||
if (!nonMeshKind) return { meshId, mode, indices: next, nonMeshKind, nonMeshSelections: undefined };
|
||||
const selections = new Map<NonMeshElementKind, Set<number>>(preserve ? [...(current.nonMeshSelections ?? [])].map(([kind, values]) => [kind, new Set(values)]) : []);
|
||||
const kindIndices = selections.get(nonMeshKind) ?? new Set<number>();
|
||||
if (kindIndices.has(index)) kindIndices.delete(index);
|
||||
else kindIndices.add(index);
|
||||
if (kindIndices.size === 0) selections.delete(nonMeshKind);
|
||||
else selections.set(nonMeshKind, kindIndices);
|
||||
const combined = new Set<number>();
|
||||
for (const values of selections.values()) for (const value of values) combined.add(value);
|
||||
return { meshId, mode, indices: combined, nonMeshKind, nonMeshSelections: selections };
|
||||
});
|
||||
};
|
||||
const restoreCachedLODs = async (projectId: string, scene: SceneSnapshotIR): Promise<void> => {
|
||||
@@ -511,14 +524,14 @@ export function App() {
|
||||
};
|
||||
const setMeshSelectionMode = (mode: MeshElementMode): void => {
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set() });
|
||||
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set(), nonMeshSelections: undefined });
|
||||
};
|
||||
const selectAllMeshElements = (): void => {
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode?.dataId);
|
||||
if (!mesh) return;
|
||||
const count = meshSelection.mode === "VERT" ? mesh.vertexCount : meshSelection.mode === "EDGE" ? mesh.edgeCount : mesh.faceCount;
|
||||
setMeshSelection({ meshId: mesh.id, mode: meshSelection.mode, indices: new Set(Array.from({ length: count }, (_, index) => index)) });
|
||||
setMeshSelection({ meshId: mesh.id, mode: meshSelection.mode, indices: new Set(Array.from({ length: count }, (_, index) => index)), nonMeshSelections: undefined });
|
||||
};
|
||||
const runMeshEdit = (operation: MeshEditOperation): void => {
|
||||
if (!meshSelection.meshId || meshSelection.indices.size === 0) return;
|
||||
@@ -546,19 +559,19 @@ export function App() {
|
||||
if (!activeNode) return;
|
||||
if (uiState.context.mode === "Edit" && activeNode.dataId) {
|
||||
const nonMesh = snapshot?.nonMeshData?.find((candidate) => candidate.id === activeNode.dataId);
|
||||
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id &&
|
||||
(meshSelection.nonMeshKind === "HANDLE_LEFT" || meshSelection.nonMeshKind === "HANDLE_RIGHT") &&
|
||||
meshSelection.indices.size === 1 && nonMesh.handlePoints) {
|
||||
const pointIndex = [...meshSelection.indices][0];
|
||||
const packedPointIndex = nonMesh.handlePointIndices?.indexOf(pointIndex) ?? pointIndex;
|
||||
if (packedPointIndex < 0) return;
|
||||
const handleOffset = packedPointIndex * 6 + (meshSelection.nonMeshKind === "HANDLE_RIGHT" ? 3 : 0);
|
||||
const position: [number, number, number] = [
|
||||
nonMesh.handlePoints[handleOffset], nonMesh.handlePoints[handleOffset + 1], nonMesh.handlePoints[handleOffset + 2],
|
||||
];
|
||||
position[axis] += amount;
|
||||
void applyEditCommand({ type: "setCurveHandle", dataId: nonMesh.id, pointIndex,
|
||||
side: meshSelection.nonMeshKind === "HANDLE_RIGHT" ? "RIGHT" : "LEFT", position });
|
||||
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id && nonMesh.handlePoints && meshSelection.nonMeshSelections && meshSelection.nonMeshSelections.size > 0) {
|
||||
const handlePoints = nonMesh.handlePoints.slice();
|
||||
const pointIndices = nonMesh.handlePointIndices ?? Array.from({ length: handlePoints.length / 6 }, (_, index) => index);
|
||||
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;
|
||||
handlePoints[packedPointIndex * 6 + sideOffset + axis] += amount;
|
||||
}
|
||||
}
|
||||
void applyEditCommand({ type: "setCurveTopology", dataId: nonMesh.id, splineTypes: nonMesh.splineTypes, cyclicU: nonMesh.cyclicU, cyclicV: nonMesh.cyclicV, handleTypes: nonMesh.handleTypes, handlePoints });
|
||||
return;
|
||||
}
|
||||
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode.dataId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { LODCacheRecord } from "../../../protocol/lod";
|
||||
import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
|
||||
@@ -127,6 +127,10 @@ export class StorageClient {
|
||||
return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise<StorageSimulationCacheReadResult>;
|
||||
}
|
||||
|
||||
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }) as Promise<StorageSimulationCacheFrameReadResult>;
|
||||
}
|
||||
|
||||
listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
|
||||
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
|
||||
}
|
||||
|
||||
@@ -380,3 +380,26 @@ export async function readContentAsset(projectId: string, sha256: string, storag
|
||||
const file = await directory.getFileHandle(sha256);
|
||||
return (await file.getFile()).arrayBuffer();
|
||||
}
|
||||
|
||||
export async function readContentAssetRange(
|
||||
projectId: string,
|
||||
sha256: string,
|
||||
byteOffset: number,
|
||||
byteLength: number,
|
||||
expectedTotalBytes: number,
|
||||
storage?: StorageManager,
|
||||
): Promise<ArrayBuffer> {
|
||||
const layout = projectLayout(projectId);
|
||||
validateSha256(sha256);
|
||||
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0 || !Number.isSafeInteger(byteLength) || byteLength <= 0 ||
|
||||
!Number.isSafeInteger(expectedTotalBytes) || expectedTotalBytes <= 0 || byteOffset > expectedTotalBytes - byteLength) {
|
||||
throw new Error("Content-addressed asset range is invalid");
|
||||
}
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
|
||||
const root = await manager.getDirectory();
|
||||
const directory = await ensureDirectory(root, `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`);
|
||||
const file = await (await directory.getFileHandle(sha256)).getFile();
|
||||
if (file.size !== expectedTotalBytes) throw new Error("Content-addressed asset size mismatch");
|
||||
return file.slice(byteOffset, byteOffset + byteLength).arrayBuffer();
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
LineBasicMaterial,
|
||||
type Object3D,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { GreasePencilDataIR, GreasePencilFrameIR } from "../../../protocol/grease-pencil";
|
||||
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
|
||||
import type { SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
|
||||
interface DrawingPreview {
|
||||
drawing: GreasePencilDrawingIR;
|
||||
onion: "NONE" | "PREVIOUS" | "NEXT";
|
||||
}
|
||||
|
||||
function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined {
|
||||
let selected: GreasePencilFrameIR | undefined;
|
||||
for (const candidate of frames) {
|
||||
@@ -18,45 +23,75 @@ function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): Gre
|
||||
return selected;
|
||||
}
|
||||
|
||||
function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPreview[] {
|
||||
const current = activeFrame(layer.frames, frame);
|
||||
if (!current) return [];
|
||||
const result: DrawingPreview[] = [{ drawing: current.drawing, onion: "NONE" }];
|
||||
if (!layer.onionSkinning) return result;
|
||||
const sorted = [...layer.frames].sort((left, right) => left.frame - right.frame);
|
||||
const currentIndex = sorted.findIndex((candidate) => candidate.frame === current.frame);
|
||||
if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, onion: "PREVIOUS" });
|
||||
if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, onion: "NEXT" });
|
||||
return result;
|
||||
}
|
||||
|
||||
function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
|
||||
let count = 0;
|
||||
for (const stroke of preview.drawing.strokes) {
|
||||
if (!stroke.points || stroke.points.length < 2) continue;
|
||||
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
|
||||
const positions = new Float32Array(pointCount * 3);
|
||||
let red = 0;
|
||||
let green = 0;
|
||||
let blue = 0;
|
||||
let opacity = 0;
|
||||
for (let index = 0; index < pointCount; index++) {
|
||||
const point = stroke.points[index % stroke.points.length];
|
||||
positions[index * 3] = point.position[0];
|
||||
positions[index * 3 + 1] = point.position[2];
|
||||
positions[index * 3 + 2] = -point.position[1];
|
||||
}
|
||||
for (const point of stroke.points) {
|
||||
const color = point.vertexColor ?? [0.2, 0.2, 0.2, 1];
|
||||
red += color[0];
|
||||
green += color[1];
|
||||
blue += color[2];
|
||||
opacity += point.opacity * color[3];
|
||||
}
|
||||
const divisor = stroke.points.length;
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
const onionColor = preview.onion === "PREVIOUS" ? new Color(0x6aa8ff) : preview.onion === "NEXT" ? new Color(0xff8a63) : null;
|
||||
const material = new LineBasicMaterial({
|
||||
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
|
||||
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
|
||||
transparent: true,
|
||||
depthWrite: preview.onion === "NONE",
|
||||
});
|
||||
const line = new Line(geometry, material);
|
||||
line.userData.greasePencilOnion = preview.onion;
|
||||
line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0;
|
||||
group.add(line);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function createGreasePencilObject(data: GreasePencilDataIR, frame: number): Object3D | null {
|
||||
if (data.geometryStatus !== "available") return null;
|
||||
const group = new Group();
|
||||
let onionDrawingCount = 0;
|
||||
let currentDrawingCount = 0;
|
||||
for (const layer of data.layers) {
|
||||
if (!layer.visible || layer.opacity <= 0) continue;
|
||||
const drawing = activeFrame(layer.frames, frame)?.drawing;
|
||||
if (!drawing) continue;
|
||||
for (const stroke of drawing.strokes) {
|
||||
if (!stroke.points || stroke.points.length < 2) continue;
|
||||
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
|
||||
const positions = new Float32Array(pointCount * 3);
|
||||
let red = 0;
|
||||
let green = 0;
|
||||
let blue = 0;
|
||||
let opacity = 0;
|
||||
for (let index = 0; index < pointCount; index++) {
|
||||
const point = stroke.points[index % stroke.points.length];
|
||||
positions[index * 3] = point.position[0];
|
||||
positions[index * 3 + 1] = point.position[2];
|
||||
positions[index * 3 + 2] = -point.position[1];
|
||||
}
|
||||
for (const point of stroke.points) {
|
||||
const color = point.vertexColor ?? [0.2, 0.2, 0.2, 1];
|
||||
red += color[0];
|
||||
green += color[1];
|
||||
blue += color[2];
|
||||
opacity += point.opacity * color[3];
|
||||
}
|
||||
const divisor = stroke.points.length;
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
const material = new LineBasicMaterial({
|
||||
color: new Color(red / divisor, green / divisor, blue / divisor),
|
||||
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor)),
|
||||
transparent: true,
|
||||
});
|
||||
group.add(new Line(geometry, material));
|
||||
for (const preview of layerDrawings(layer, frame)) {
|
||||
const added = addDrawing(group, layer, preview);
|
||||
if (preview.onion === "NONE") currentDrawingCount += added;
|
||||
else onionDrawingCount += added;
|
||||
}
|
||||
}
|
||||
group.userData.greasePencilCurrentStrokeCount = currentDrawingCount;
|
||||
group.userData.greasePencilOnionStrokeCount = onionDrawingCount;
|
||||
return group.children.length > 0 ? group : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
|
||||
export type NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
|
||||
export type NonMeshElementSelection = ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>;
|
||||
|
||||
function blenderPosition(x: number, y: number, z: number): [number, number, number] {
|
||||
return [x, z, -y];
|
||||
@@ -80,7 +81,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
|
||||
controlPositions.set(blenderPosition(points[index], points[index + 1], points[index + 2]), index);
|
||||
}
|
||||
controlGeometry.setAttribute("position", new Float32BufferAttribute(controlPositions, 3));
|
||||
const controls = new Points(controlGeometry, new PointsMaterial({ color: new Color(0x67b7ff), size: 0.09, sizeAttenuation: true }));
|
||||
const controls = new Points(controlGeometry, new PointsMaterial({ color: new Color(0x67b7ff), size: 0.09, sizeAttenuation: true, vertexColors: true }));
|
||||
controls.userData.nonMeshDataId = data.id;
|
||||
controls.userData.nonMeshPointIndexMap = Array.from({ length: points.length / 3 }, (_, index) => index);
|
||||
controls.userData.nonMeshPointKindMap = Array.from({ length: points.length / 3 }, () => "CONTROL_POINT" as NonMeshElementKind);
|
||||
@@ -92,7 +93,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
|
||||
group.add(lines);
|
||||
const pointGeometry = new BufferGeometry();
|
||||
pointGeometry.setAttribute("position", new Float32BufferAttribute(handlePositions, 3));
|
||||
const handles = new Points(pointGeometry, new PointsMaterial({ color: new Color(0xd7b8ff), size: 0.1, sizeAttenuation: true }));
|
||||
const handles = new Points(pointGeometry, new PointsMaterial({ color: new Color(0xd7b8ff), size: 0.1, sizeAttenuation: true, vertexColors: true }));
|
||||
handles.userData.nonMeshDataId = data.id;
|
||||
handles.userData.nonMeshPointIndexMap = handleIndexMap;
|
||||
handles.userData.nonMeshPointKindMap = handleKindMap;
|
||||
@@ -172,3 +173,25 @@ export function applyNonMeshTransform(object: Object3D, node: SceneNodeIR): void
|
||||
child.userData.blenderId = node.id;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyNonMeshElementSelection(root: Object3D, selection: NonMeshElementSelection): void {
|
||||
root.traverse((object) => {
|
||||
const dataId = object.userData.nonMeshDataId;
|
||||
const indexMap = object.userData.nonMeshPointIndexMap as number[] | undefined;
|
||||
const kindMap = object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
|
||||
if (typeof dataId !== "string" || !indexMap || !kindMap || !(object instanceof Points) || !(object.material instanceof PointsMaterial)) return;
|
||||
const selectedByKind = selection.get(dataId);
|
||||
const colors = new Float32Array(indexMap.length * 3);
|
||||
for (let index = 0; index < indexMap.length; index++) {
|
||||
const selected = selectedByKind?.get(kindMap[index])?.has(indexMap[index]) ?? false;
|
||||
const color = selected ? [1, 0.4, 0.1] : kindMap[index] === "CONTROL_POINT" ? [0.4, 0.72, 1] : [0.6, 0.48, 1];
|
||||
colors[index * 3] = color[0];
|
||||
colors[index * 3 + 1] = color[1];
|
||||
colors[index * 3 + 2] = color[2];
|
||||
}
|
||||
object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));
|
||||
object.material.color.set(0xffffff);
|
||||
object.material.vertexColors = true;
|
||||
object.material.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export type OffscreenViewportRequest =
|
||||
| { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] }
|
||||
| { type: "textureAssets"; assets: GPUTextureAsset[] }
|
||||
| { type: "resize"; width: number; height: number; pixelRatio: number }
|
||||
| { type: "selection"; objectIds: string[] }
|
||||
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }> }
|
||||
| { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
|
||||
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
|
||||
| { type: "pick"; x: number; y: number; additive: boolean }
|
||||
@@ -18,7 +18,7 @@ export type OffscreenViewportRequest =
|
||||
export type OffscreenViewportResponse =
|
||||
| { type: "ready" }
|
||||
| { type: "frame"; visiblePixels: number }
|
||||
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: 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: "selected"; objectId: string; additive: boolean }
|
||||
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { NonMeshElementKind } from "./nonmesh";
|
||||
export interface ViewportBackend {
|
||||
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
|
||||
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
|
||||
setSelection(objectIds: ReadonlySet<string>): void;
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void;
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
|
||||
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
|
||||
dispose(): void;
|
||||
@@ -147,8 +147,9 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
|
||||
}
|
||||
|
||||
setSelection(objectIds: ReadonlySet<string>): void {
|
||||
this.worker.postMessage({ type: "selection", objectIds: [...objectIds] } satisfies OffscreenViewportRequest);
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
|
||||
const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index }))));
|
||||
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements } satisfies OffscreenViewportRequest);
|
||||
}
|
||||
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||
@@ -209,6 +210,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount);
|
||||
this.canvas.dataset.greasePencilCount = String(message.greasePencilCount);
|
||||
this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount);
|
||||
this.canvas.dataset.greasePencilOnionStrokeCount = String(message.greasePencilOnionStrokeCount);
|
||||
}
|
||||
else if (message.type === "textureStatus") {
|
||||
this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready";
|
||||
|
||||
62
web/app/src/three-adapter/paint-hit.ts
Normal file
62
web/app/src/three-adapter/paint-hit.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Matrix3,
|
||||
Mesh,
|
||||
Triangle,
|
||||
Vector2,
|
||||
Vector3,
|
||||
type Intersection,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { PaintHitIR } from "../../../protocol/paint";
|
||||
|
||||
export interface PaintRaycastHitIR extends PaintHitIR {
|
||||
objectId: string;
|
||||
dataId: string;
|
||||
}
|
||||
|
||||
function finiteTuple(values: readonly number[]): boolean {
|
||||
return values.every(Number.isFinite);
|
||||
}
|
||||
|
||||
export function paintHitFromIntersection(intersection: Intersection, pressure = 1): PaintRaycastHitIR | null {
|
||||
const faceIndex = intersection.faceIndex;
|
||||
if (!(intersection.object instanceof Mesh) || faceIndex === undefined || faceIndex === null || faceIndex < 0 || pressure < 0 || pressure > 1) return null;
|
||||
const object = intersection.object;
|
||||
const positionAttribute = object.geometry.getAttribute("position");
|
||||
const indexAttribute = object.geometry.getIndex();
|
||||
if (!positionAttribute) return null;
|
||||
const corner = faceIndex * 3;
|
||||
const vertexA = indexAttribute ? indexAttribute.getX(corner) : corner;
|
||||
const vertexB = indexAttribute ? indexAttribute.getX(corner + 1) : corner + 1;
|
||||
const vertexC = indexAttribute ? indexAttribute.getX(corner + 2) : corner + 2;
|
||||
if ([vertexA, vertexB, vertexC].some((vertex) => vertex < 0 || vertex >= positionAttribute.count)) return null;
|
||||
const a = new Vector3().fromBufferAttribute(positionAttribute, vertexA);
|
||||
const b = new Vector3().fromBufferAttribute(positionAttribute, vertexB);
|
||||
const c = new Vector3().fromBufferAttribute(positionAttribute, vertexC);
|
||||
const localPoint = object.worldToLocal(intersection.point.clone());
|
||||
const barycentric = Triangle.getBarycoord(localPoint, a, b, c, new Vector3());
|
||||
if (!barycentric || !finiteTuple(barycentric.toArray())) return null;
|
||||
const localNormal = intersection.face?.normal?.clone() ?? new Triangle(a, b, c).getNormal(new Vector3());
|
||||
const worldNormal = localNormal.applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld)).normalize();
|
||||
const sourceFaces = object.userData.triangleFaceIndices as number[] | undefined;
|
||||
const result: PaintRaycastHitIR = {
|
||||
objectId: String(object.userData.blenderId ?? ""),
|
||||
dataId: String(object.userData.meshId ?? ""),
|
||||
position: intersection.point.toArray(),
|
||||
normal: worldNormal.toArray(),
|
||||
faceIndex: sourceFaces?.[faceIndex] ?? faceIndex,
|
||||
barycentric: barycentric.toArray(),
|
||||
pressure,
|
||||
};
|
||||
if (!result.objectId || !result.dataId) return null;
|
||||
const uv = object.geometry.getAttribute("uv");
|
||||
if (uv) {
|
||||
const uvA = new Vector2().fromBufferAttribute(uv, vertexA);
|
||||
const uvB = new Vector2().fromBufferAttribute(uv, vertexB);
|
||||
const uvC = new Vector2().fromBufferAttribute(uv, vertexC);
|
||||
result.uv = [
|
||||
uvA.x * barycentric.x + uvB.x * barycentric.y + uvC.x * barycentric.z,
|
||||
uvA.y * barycentric.x + uvB.y * barycentric.y + uvC.y * barycentric.z,
|
||||
];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { GPUTextureStore } from "./texture-assets";
|
||||
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
||||
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
|
||||
import { applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
|
||||
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import { applyGreasePencilTransform, createGreasePencilObject } from "./grease-pencil";
|
||||
|
||||
@@ -279,6 +279,7 @@ export class ViewportRenderer {
|
||||
const dataById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
|
||||
let previewCount = 0;
|
||||
let blockedCount = 0;
|
||||
let onionStrokeCount = 0;
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
|
||||
const data = dataById.get(node.dataId);
|
||||
@@ -291,10 +292,12 @@ export class ViewportRenderer {
|
||||
applyGreasePencilTransform(object, node);
|
||||
this.importedRoot.add(object);
|
||||
this.objectByBlenderId.set(node.id, object);
|
||||
onionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
|
||||
previewCount++;
|
||||
}
|
||||
this.canvas.dataset.greasePencilCount = String(previewCount);
|
||||
this.canvas.dataset.greasePencilBlockedCount = String(blockedCount);
|
||||
this.canvas.dataset.greasePencilOnionStrokeCount = String(onionStrokeCount);
|
||||
}
|
||||
|
||||
setTextureAssets(assets: readonly GPUTextureAsset[]): void {
|
||||
@@ -350,7 +353,7 @@ export class ViewportRenderer {
|
||||
this.currentSnapshot = next;
|
||||
}
|
||||
|
||||
setSelection(objectIds: ReadonlySet<string>): void {
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
|
||||
const visitedInstances = new Set<InstancedMesh>();
|
||||
for (const [objectId, object] of this.objectByBlenderId) {
|
||||
if (object instanceof InstancedMesh) {
|
||||
@@ -370,6 +373,7 @@ export class ViewportRenderer {
|
||||
setPBRMaterialSelected(material, objectIds.has(objectId));
|
||||
}
|
||||
}
|
||||
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
|
||||
}
|
||||
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||
|
||||
2
web/app/src/vendor/blender/web_engine.js
vendored
2
web/app/src/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -1,15 +1,65 @@
|
||||
import { gateSelectionInteraction, parseRaycastSelectionHit, recordSelection, SELECTION_HISTORY_BUDGET, stepSelectionHistory } from "../../../protocol/selection-history";
|
||||
import {
|
||||
gateSelectionInteraction,
|
||||
parseRaycastSelectionHit,
|
||||
parseSelectionHistory,
|
||||
patchSelectionRanges,
|
||||
recordSelection,
|
||||
SELECTION_HISTORY_BUDGET,
|
||||
stepSelectionHistory,
|
||||
} from "../../../protocol/selection-history";
|
||||
|
||||
const empty = { activeObjectId: null, objectIds: [], targets: [] };
|
||||
const selected = {
|
||||
activeObjectId: "object:1",
|
||||
objectIds: ["object:1", "object:2"],
|
||||
targets: [
|
||||
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", indices: [3, 1] },
|
||||
{ objectId: "object:2", dataId: "curve:2", mode: "VERT", nonMeshKind: "HANDLE_RIGHT", indices: [2] },
|
||||
],
|
||||
};
|
||||
const base = { schemaVersion: 2, revision: 0, cursor: 0, entries: [empty] };
|
||||
|
||||
const empty = { activeObjectId: null, objectIds: [], meshId: null, elementMode: "FACE", elementIndices: [] };
|
||||
const selected = { activeObjectId: "object:1", objectIds: ["object:1"], meshId: "mesh:1", elementMode: "VERT", elementIndices: [3, 1] };
|
||||
const base = { schemaVersion: 1, revision: 0, cursor: 0, entries: [empty] };
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try { const next = recordSelection(base, 0, { ...selected, nonMeshKind: "HANDLE_LEFT" }); const undone = stepSelectionHistory(next, 1, "UNDO"); const redone = stepSelectionHistory(undone, 2, "REDO"); result.history = [redone.revision, redone.cursor, redone.entries[redone.cursor].elementIndices, redone.entries[redone.cursor].nonMeshKind]; } catch (error) { result.history = error instanceof Error ? error.message : String(error); }
|
||||
try { recordSelection(base, 4, selected); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
|
||||
try { recordSelection(base, 0, { ...selected, elementIndices: new Array(SELECTION_HISTORY_BUDGET.maxElements + 1).fill(1) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
try { parseRaycastSelectionHit({ sourceRevision: 2, dataId: "mesh:1", mode: "VERT", index: 0, distance: 1, point: [0, 0, 0] }, 3); } catch (error) { result.raycast = error instanceof Error ? error.message : String(error); }
|
||||
try { result.handleHit = parseRaycastSelectionHit({ sourceRevision: 3, dataId: "curve:1", mode: "VERT", index: 2, distance: 1, point: [0, 0, 0], nonMeshKind: "HANDLE_RIGHT" }, 3).nonMeshKind; } catch (error) { result.handleHit = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const next = recordSelection(base, 0, selected);
|
||||
const undone = stepSelectionHistory(next, 1, "UNDO");
|
||||
const redone = stepSelectionHistory(undone, 2, "REDO");
|
||||
result.history = [redone.revision, redone.cursor, redone.entries[redone.cursor].targets.map((target) => [target.dataId, target.indices, target.nonMeshKind])];
|
||||
}
|
||||
catch (error) { result.history = error instanceof Error ? error.message : String(error); }
|
||||
try { recordSelection(base, 4, selected); }
|
||||
catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
recordSelection(base, 0, { ...selected, targets: [{ ...selected.targets[0], indices: new Array(SELECTION_HISTORY_BUDGET.maxElements + 1).fill(1) }] });
|
||||
}
|
||||
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
try { parseRaycastSelectionHit({ sourceRevision: 2, dataId: "mesh:1", mode: "VERT", index: 0, distance: 1, point: [0, 0, 0] }, 3); }
|
||||
catch (error) { result.raycast = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const hit = parseRaycastSelectionHit({ sourceRevision: 3, objectId: "object:1", dataId: "curve:1", mode: "VERT", index: 2, distance: 1, point: [0, 0, 0], nonMeshKind: "HANDLE_RIGHT" }, 3);
|
||||
result.handleHit = [hit.objectId, hit.nonMeshKind];
|
||||
}
|
||||
catch (error) { result.handleHit = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const patched = patchSelectionRanges(selected, [
|
||||
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", start: 2, end: 4, selected: true },
|
||||
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", start: 3, end: 3, selected: false },
|
||||
{ objectId: "object:2", dataId: "curve:2", mode: "VERT", nonMeshKind: "HANDLE_RIGHT", start: 2, end: 2, selected: false },
|
||||
]);
|
||||
result.rangePatch = patched.targets.map((target) => [target.dataId, target.indices]);
|
||||
}
|
||||
catch (error) { result.rangePatch = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const migrated = parseSelectionHistory({
|
||||
schemaVersion: 1,
|
||||
revision: 0,
|
||||
cursor: 0,
|
||||
entries: [{ activeObjectId: "object:1", objectIds: ["object:1"], meshId: "mesh:1", elementMode: "FACE", elementIndices: [4] }],
|
||||
});
|
||||
result.migrated = [migrated.schemaVersion, migrated.entries[0].targets[0].dataId];
|
||||
}
|
||||
catch (error) { result.migrated = error instanceof Error ? error.message : String(error); }
|
||||
result.gates = [gateSelectionInteraction("RAYCAST").status, gateSelectionInteraction("HISTORY").status, gateSelectionInteraction("GIZMO").status];
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
|
||||
import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod";
|
||||
import { parseSimulationCacheManifest, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import { parseSimulationCacheManifest, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, verifySimulationCacheFrame, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import { STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION, STORAGE_STORES, upgradeStorageSchema } from "../storage/migrations";
|
||||
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
|
||||
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
|
||||
|
||||
const scope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<StorageRequest>) => void) | null;
|
||||
@@ -724,6 +724,36 @@ async function readSimulationCache(projectId: string, cacheKey: string): Promise
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data };
|
||||
}
|
||||
|
||||
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
projectLayout(projectId);
|
||||
const row = await readSimulationRow(projectId, cacheKey);
|
||||
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
|
||||
const manifest = parseSimulationCacheManifest(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
|
||||
}
|
||||
const selected = selectSimulationCacheFrame(manifest, frame);
|
||||
const asset = await readAssetRow(projectId, manifest.cacheSha256);
|
||||
if (!asset || asset.bytes !== manifest.byteLength) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
|
||||
}
|
||||
const data = asset.buffer ?
|
||||
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
|
||||
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
|
||||
await verifySimulationCacheFrame(manifest, frame, data);
|
||||
return {
|
||||
projectId,
|
||||
cacheKey,
|
||||
persisted: true,
|
||||
manifest,
|
||||
path: row.path,
|
||||
frame,
|
||||
byteOffset: selected.byteOffset,
|
||||
byteLength: selected.byteLength,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
|
||||
projectLayout(projectId);
|
||||
const db = await openDatabase();
|
||||
@@ -773,6 +803,7 @@ scope.onmessage = async (event) => {
|
||||
else if (command.type === "pruneLOD") result = await pruneLOD(command.projectId, command.maxBytes);
|
||||
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data));
|
||||
else if (command.type === "readSimulationCache") result = await readSimulationCache(command.projectId, command.cacheKey);
|
||||
else if (command.type === "readSimulationCacheFrame") result = await readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame);
|
||||
else if (command.type === "listSimulationCaches") result = await listSimulationCaches(command.projectId);
|
||||
else throw new Error("Unknown storage command");
|
||||
if (result && "data" in result && result.data instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.data]);
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
setPBRMaterialSelected,
|
||||
} from "../three-adapter/pbr";
|
||||
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
||||
import { applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import { applyGreasePencilTransform, createGreasePencilObject } from "../three-adapter/grease-pencil";
|
||||
|
||||
const workerScope = self as unknown as {
|
||||
@@ -276,6 +276,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
const greasePencilsById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
|
||||
let greasePencilCount = 0;
|
||||
let greasePencilBlockedCount = 0;
|
||||
let greasePencilOnionStrokeCount = 0;
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
|
||||
const data = greasePencilsById.get(node.dataId);
|
||||
@@ -288,9 +289,10 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
applyGreasePencilTransform(object, node);
|
||||
root.add(object);
|
||||
objectById.set(node.id, object);
|
||||
greasePencilOnionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
|
||||
greasePencilCount++;
|
||||
}
|
||||
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount });
|
||||
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount });
|
||||
const world = snapshot.worlds.find((candidate) => candidate.id === snapshot.scenes[0]?.worldId) ?? snapshot.worlds[0];
|
||||
const sceneDefinition = snapshot.scenes.find((candidate) => candidate.id === snapshot.sceneId) ?? snapshot.scenes[0];
|
||||
scene.background = world ? new Color().setRGB(...world.color) : new Color(0x25272b);
|
||||
@@ -311,7 +313,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
render();
|
||||
}
|
||||
|
||||
function setSelection(ids: string[]): void {
|
||||
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>): void {
|
||||
const selected = new Set(ids);
|
||||
const visited = new Set<Object3D>();
|
||||
for (const [id, object] of objectById) {
|
||||
@@ -329,6 +331,15 @@ function setSelection(ids: string[]): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
const selection = new Map<string, Map<NonMeshElementKind, Set<number>>>();
|
||||
for (const element of elements) {
|
||||
const kinds = selection.get(element.dataId) ?? new Map<NonMeshElementKind, Set<number>>();
|
||||
const indices = kinds.get(element.kind) ?? new Set<number>();
|
||||
indices.add(element.index);
|
||||
kinds.set(element.kind, indices);
|
||||
selection.set(element.dataId, kinds);
|
||||
}
|
||||
if (root) applyNonMeshElementSelection(root, selection);
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -411,7 +422,7 @@ workerScope.onmessage = (event): void => {
|
||||
else if (message.type === "snapshot") setSnapshot(message.snapshot, message.geometryBuffers, message.nonMeshGeometryBuffers);
|
||||
else if (message.type === "textureAssets") applyTextureAssets(message.assets);
|
||||
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
|
||||
else if (message.type === "selection") setSelection(message.objectIds);
|
||||
else if (message.type === "selection") setSelection(message.objectIds, message.elements);
|
||||
else if (message.type === "interaction") {
|
||||
editMode = message.editMode;
|
||||
selectionMode = message.selectionMode;
|
||||
|
||||
@@ -366,6 +366,30 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
for (const field of ["useTemperature", "castsShadow"] as const) if (properties[field] !== undefined && typeof properties[field] !== "boolean") throw report("RENDER_PROPERTY_INVALID", `Light ${field} must be boolean`);
|
||||
return;
|
||||
}
|
||||
case "setCameraProperties": {
|
||||
const camera = currentSnapshot?.cameras.find((candidate) => candidate.id === payload.dataId);
|
||||
const properties = payload.properties;
|
||||
if (!camera || !validPropertyKeys(properties, ["projection", "lensMm", "sensorWidthMm", "sensorHeightMm", "sensorFit", "shift", "near", "far", "orthoScale", "depthOfField"])) throw report("RENDER_PROPERTY_INVALID", "Camera target or properties are invalid");
|
||||
if (properties.projection !== undefined && !["PERSPECTIVE", "ORTHOGRAPHIC"].includes(properties.projection)) throw report("RENDER_PROPERTY_INVALID", "Camera projection is invalid");
|
||||
for (const [field, minimum, maximum] of [["lensMm", 0.1, 10_000], ["sensorWidthMm", 0.1, 10_000], ["sensorHeightMm", 0.1, 10_000], ["near", 0.0001, 1e9], ["far", 0.0002, 1e12], ["orthoScale", 0.0001, 1e9]] as const) {
|
||||
const value = properties[field];
|
||||
if (value !== undefined && !boundedNumber(value, minimum, maximum)) throw report("RENDER_PROPERTY_INVALID", `Camera ${field} is outside the bounded range`);
|
||||
}
|
||||
if (properties.near !== undefined && properties.far !== undefined && properties.far <= properties.near) throw report("RENDER_PROPERTY_INVALID", "Camera far clip must exceed near clip");
|
||||
if (properties.sensorFit !== undefined && ![0, 1, 2].includes(properties.sensorFit)) throw report("RENDER_PROPERTY_INVALID", "Camera sensorFit is invalid");
|
||||
if (properties.shift !== undefined && (!Array.isArray(properties.shift) || properties.shift.length !== 2 || properties.shift.some((value) => !boundedNumber(value, -1000, 1000)))) throw report("RENDER_PROPERTY_INVALID", "Camera shift is invalid");
|
||||
if (properties.depthOfField !== undefined) {
|
||||
const dof = properties.depthOfField;
|
||||
if (!validPropertyKeys(dof, ["enabled", "focusDistance", "apertureFStop", "apertureBlades", "apertureRotation", "apertureRatio"])) throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField is invalid");
|
||||
if (dof.enabled !== undefined && typeof dof.enabled !== "boolean") throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField.enabled must be boolean");
|
||||
for (const [field, minimum, maximum] of [["focusDistance", 0, 1e9], ["apertureFStop", 0.01, 1000], ["apertureRotation", -Math.PI * 2, Math.PI * 2], ["apertureRatio", 0.01, 100]] as const) {
|
||||
const value = dof[field];
|
||||
if (value !== undefined && !boundedNumber(value, minimum, maximum)) throw report("RENDER_PROPERTY_INVALID", `Camera depthOfField.${field} is outside the bounded range`);
|
||||
}
|
||||
if (dof.apertureBlades !== undefined && (!Number.isSafeInteger(dof.apertureBlades) || dof.apertureBlades < 0 || dof.apertureBlades > 64)) throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField.apertureBlades is outside the bounded range");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setWorldProperties": {
|
||||
const world = currentSnapshot?.worlds.find((candidate) => candidate.id === payload.dataId);
|
||||
const properties = payload.properties;
|
||||
|
||||
@@ -30,8 +30,11 @@
|
||||
"test:grease-pencil": "node ../tools/web/check-grease-pencil-roundtrip.mjs",
|
||||
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",
|
||||
"test:lighting-roundtrip": "node ../tools/web/check-lighting-roundtrip.mjs",
|
||||
"test:compositor-main-reader": "node ../tools/web/check-compositor-main-reader.mjs",
|
||||
"test:sequencer": "playwright test --config playwright.config.ts -g \"N-021 sequencer\"",
|
||||
"test:sequencer-main-reader": "node ../tools/web/check-sequencer-main-reader.mjs",
|
||||
"test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"",
|
||||
"test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs",
|
||||
"test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"",
|
||||
"test:editor-workflow": "playwright test --config playwright.config.ts -g \"N-024 editor\"",
|
||||
"test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { parseNlaTracks, type NlaTrackIR } from "./nla";
|
||||
import { parseGreasePencilData, type GreasePencilDataIR } from "./grease-pencil";
|
||||
import { parseCompositorGraph, type CompositorGraphIR } from "./compositor";
|
||||
import { parseSequencerTimeline, type SequencerTimelineIR } from "./sequencer";
|
||||
import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking-mask";
|
||||
|
||||
export type SceneNodeType =
|
||||
| "EMPTY"
|
||||
@@ -434,6 +437,10 @@ export interface SceneIR {
|
||||
tint?: number;
|
||||
whiteBalanceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
};
|
||||
compositorGraph?: CompositorGraphIR;
|
||||
compositorStatus?: "AVAILABLE" | "BLOCKED";
|
||||
sequencerTimeline?: SequencerTimelineIR;
|
||||
sequencerStatus?: "AVAILABLE" | "BLOCKED";
|
||||
}
|
||||
|
||||
export interface SceneSnapshotIR {
|
||||
@@ -465,6 +472,8 @@ export interface SceneSnapshotIR {
|
||||
nonMeshData?: NonMeshDataIR[];
|
||||
vfonts?: VFontResourceIR[];
|
||||
greasePencils?: GreasePencilDataIR[];
|
||||
trackingMasks?: TrackingMaskProjectIR;
|
||||
trackingMaskStatus?: "AVAILABLE" | "BLOCKED";
|
||||
libraries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1001,6 +1010,20 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
for (const field of ["temperature", "tint"] as const) if (scene.colorManagement[field] !== undefined) requireNumber(scene.colorManagement[field], `scenes[${index}].colorManagement.${field}`);
|
||||
if (scene.colorManagement.whiteBalanceStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.colorManagement.whiteBalanceStatus as string)) throw new Error(`scenes[${index}].colorManagement.whiteBalanceStatus is invalid`);
|
||||
}
|
||||
if (scene.compositorStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.compositorStatus as string)) {
|
||||
throw new Error(`scenes[${index}].compositorStatus is invalid`);
|
||||
}
|
||||
if (scene.compositorGraph !== undefined) {
|
||||
parseCompositorGraph(scene.compositorGraph);
|
||||
if (scene.compositorStatus !== "AVAILABLE") throw new Error(`scenes[${index}].compositorStatus must be AVAILABLE when a graph is present`);
|
||||
}
|
||||
if (scene.sequencerStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.sequencerStatus as string)) {
|
||||
throw new Error(`scenes[${index}].sequencerStatus is invalid`);
|
||||
}
|
||||
if (scene.sequencerTimeline !== undefined) {
|
||||
parseSequencerTimeline(scene.sequencerTimeline);
|
||||
if (scene.sequencerStatus !== "AVAILABLE") throw new Error(`scenes[${index}].sequencerStatus must be AVAILABLE when a timeline is present`);
|
||||
}
|
||||
}
|
||||
for (const [index, animation] of (value.animations as unknown[]).entries()) {
|
||||
if (!isRecord(animation)) throw new Error(`SceneIR.animations[${index}] must be an object`);
|
||||
@@ -1025,5 +1048,12 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
}
|
||||
if (value.nlaTracks !== undefined) parseNlaTracks(value.nlaTracks);
|
||||
if (value.trackingMaskStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.trackingMaskStatus as string)) {
|
||||
throw new Error("SceneIR.trackingMaskStatus is invalid");
|
||||
}
|
||||
if (value.trackingMasks !== undefined) {
|
||||
parseTrackingMaskProject(value.trackingMasks);
|
||||
if (value.trackingMaskStatus !== "AVAILABLE") throw new Error("SceneIR.trackingMaskStatus must be AVAILABLE when trackingMasks is present");
|
||||
}
|
||||
return value as unknown as SceneSnapshotIR;
|
||||
}
|
||||
|
||||
@@ -1,63 +1,296 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const SELECTION_HISTORY_SCHEMA = 1 as const;
|
||||
export const SELECTION_HISTORY_BUDGET = { maxEntries: 256, maxObjects: 100_000, maxElements: 1_000_000 } as const;
|
||||
export const SELECTION_HISTORY_SCHEMA = 2 as const;
|
||||
export const SELECTION_HISTORY_BUDGET = {
|
||||
maxEntries: 256,
|
||||
maxObjects: 100_000,
|
||||
maxTargets: 100_000,
|
||||
maxElements: 1_000_000,
|
||||
maxRangePatches: 4096,
|
||||
} as const;
|
||||
|
||||
export type SelectionElementMode = "VERT" | "EDGE" | "FACE";
|
||||
export type NonMeshSelectionKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
|
||||
export interface SelectionStateIR { activeObjectId: string | null; objectIds: string[]; meshId: string | null; elementMode: SelectionElementMode; elementIndices: number[]; nonMeshKind?: NonMeshSelectionKind }
|
||||
export interface SelectionHistoryIR { schemaVersion: typeof SELECTION_HISTORY_SCHEMA; revision: number; cursor: number; entries: SelectionStateIR[] }
|
||||
export interface RaycastSelectionHitIR { sourceRevision: number; dataId: string; mode: SelectionElementMode; index: number; distance: number; point: [number, number, number]; nonMeshKind?: NonMeshSelectionKind }
|
||||
|
||||
export interface SelectionElementTargetIR {
|
||||
objectId: string;
|
||||
dataId: string;
|
||||
mode: SelectionElementMode;
|
||||
indices: number[];
|
||||
nonMeshKind?: NonMeshSelectionKind;
|
||||
}
|
||||
|
||||
export interface SelectionStateIR {
|
||||
activeObjectId: string | null;
|
||||
objectIds: string[];
|
||||
targets: SelectionElementTargetIR[];
|
||||
}
|
||||
|
||||
export interface SelectionHistoryIR {
|
||||
schemaVersion: typeof SELECTION_HISTORY_SCHEMA;
|
||||
revision: number;
|
||||
cursor: number;
|
||||
entries: SelectionStateIR[];
|
||||
}
|
||||
|
||||
export interface SelectionRangePatchIR {
|
||||
objectId: string;
|
||||
dataId: string;
|
||||
mode: SelectionElementMode;
|
||||
start: number;
|
||||
end: number;
|
||||
selected: boolean;
|
||||
nonMeshKind?: NonMeshSelectionKind;
|
||||
}
|
||||
|
||||
export interface RaycastSelectionHitIR {
|
||||
sourceRevision: number;
|
||||
objectId?: string;
|
||||
dataId: string;
|
||||
mode: SelectionElementMode;
|
||||
index: number;
|
||||
distance: number;
|
||||
point: [number, number, number];
|
||||
nonMeshKind?: NonMeshSelectionKind;
|
||||
}
|
||||
|
||||
export class SelectionHistoryValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "SelectionHistoryValidationError"; this.code = code; }
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "SelectionHistoryValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
||||
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is outside the bounded range`); return value; }
|
||||
function ids(value: unknown, name: string, maximum: number): string[] { if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256) || new Set(value).size !== value.length) throw new SelectionHistoryValidationError(value instanceof Array && value.length > maximum ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", `${name} is invalid`); return [...value] as string[]; }
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function integer(value: unknown, name: string, minimum: number, maximum: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is outside the bounded range`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function id(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ids(value: unknown, name: string, maximum: number): string[] {
|
||||
if (!Array.isArray(value) || value.length > maximum || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 256) || new Set(value).size !== value.length) {
|
||||
throw new SelectionHistoryValidationError(
|
||||
value instanceof Array && value.length > maximum ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID",
|
||||
`${name} is invalid`,
|
||||
);
|
||||
}
|
||||
return [...value] as string[];
|
||||
}
|
||||
|
||||
function mode(value: unknown, name: string): SelectionElementMode {
|
||||
if (!["VERT", "EDGE", "FACE"].includes(value as string)) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${name} is invalid`);
|
||||
}
|
||||
return value as SelectionElementMode;
|
||||
}
|
||||
|
||||
function kind(value: unknown, code: "SELECTION_HISTORY_INVALID" | "RAYCAST_HIT_INVALID"): NonMeshSelectionKind | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value as string)) {
|
||||
throw new SelectionHistoryValidationError(code, "Non-mesh selection identity is invalid");
|
||||
}
|
||||
return value as NonMeshSelectionKind;
|
||||
}
|
||||
|
||||
function indices(value: unknown, name: string): number[] {
|
||||
if (!Array.isArray(value) || value.length > SELECTION_HISTORY_BUDGET.maxElements || value.some((item) => !Number.isSafeInteger(item) || item < 0) || new Set(value).size !== value.length) {
|
||||
throw new SelectionHistoryValidationError(
|
||||
value instanceof Array && value.length > SELECTION_HISTORY_BUDGET.maxElements ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID",
|
||||
`${name} is invalid`,
|
||||
);
|
||||
}
|
||||
return [...value].sort((left, right) => left - right) as number[];
|
||||
}
|
||||
|
||||
function targetKey(target: Pick<SelectionElementTargetIR, "objectId" | "dataId" | "mode" | "nonMeshKind">): string {
|
||||
return `${target.objectId}\0${target.dataId}\0${target.mode}\0${target.nonMeshKind ?? "MESH"}`;
|
||||
}
|
||||
|
||||
function parseTarget(value: unknown, path: string): SelectionElementTargetIR {
|
||||
if (!record(value)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `${path} is invalid`);
|
||||
const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID");
|
||||
return {
|
||||
objectId: id(value.objectId, `${path}.objectId`),
|
||||
dataId: id(value.dataId, `${path}.dataId`),
|
||||
mode: mode(value.mode, `${path}.mode`),
|
||||
indices: indices(value.indices, `${path}.indices`),
|
||||
...(parsedKind ? { nonMeshKind: parsedKind } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateLegacyTarget(value: Record<string, unknown>, objectIds: string[], activeObjectId: string | null): SelectionElementTargetIR[] {
|
||||
const legacyIndices = value.elementIndices === undefined ? [] : indices(value.elementIndices, "elementIndices");
|
||||
if (legacyIndices.length === 0) return [];
|
||||
const dataId = value.meshId === null ? null : id(value.meshId, "meshId");
|
||||
if (!dataId) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires dataId");
|
||||
const objectId = activeObjectId ?? objectIds[0];
|
||||
if (!objectId) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires an owning object");
|
||||
const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID");
|
||||
return [{
|
||||
objectId,
|
||||
dataId,
|
||||
mode: mode(value.elementMode, "elementMode"),
|
||||
indices: legacyIndices,
|
||||
...(parsedKind ? { nonMeshKind: parsedKind } : {}),
|
||||
}];
|
||||
}
|
||||
|
||||
export function parseSelectionState(value: unknown): SelectionStateIR {
|
||||
if (!record(value) || !["VERT", "EDGE", "FACE"].includes(value.elementMode as string)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection state is invalid");
|
||||
if (!record(value)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection state is invalid");
|
||||
const objectIds = ids(value.objectIds, "objectIds", SELECTION_HISTORY_BUDGET.maxObjects);
|
||||
const activeObjectId = value.activeObjectId === null ? null : typeof value.activeObjectId === "string" ? value.activeObjectId : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "activeObjectId is invalid"); })();
|
||||
if (activeObjectId !== null && !objectIds.includes(activeObjectId)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Active object must be selected");
|
||||
if (!Array.isArray(value.elementIndices) || value.elementIndices.length > SELECTION_HISTORY_BUDGET.maxElements || value.elementIndices.some((item) => !Number.isSafeInteger(item) || item < 0) || new Set(value.elementIndices).size !== value.elementIndices.length) throw new SelectionHistoryValidationError(value.elementIndices instanceof Array && value.elementIndices.length > SELECTION_HISTORY_BUDGET.maxElements ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID", "elementIndices are invalid");
|
||||
const meshId = value.meshId === null ? null : typeof value.meshId === "string" && value.meshId.length > 0 ? value.meshId : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "meshId is invalid"); })();
|
||||
if (meshId === null && value.elementIndices.length > 0) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element selection requires meshId");
|
||||
const nonMeshKind = value.nonMeshKind === undefined ? undefined : ["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value.nonMeshKind as string) ? value.nonMeshKind as NonMeshSelectionKind : (() => { throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "nonMeshKind is invalid"); })();
|
||||
return { activeObjectId, objectIds, meshId, elementMode: value.elementMode as SelectionElementMode, elementIndices: [...value.elementIndices].sort((a, b) => a - b) as number[], ...(nonMeshKind ? { nonMeshKind } : {}) };
|
||||
const activeObjectId = value.activeObjectId === null ? null : id(value.activeObjectId, "activeObjectId");
|
||||
if (activeObjectId !== null && !objectIds.includes(activeObjectId)) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Active object must be selected");
|
||||
}
|
||||
const targets = Array.isArray(value.targets)
|
||||
? value.targets.map((target, index) => parseTarget(target, `targets[${index}]`))
|
||||
: migrateLegacyTarget(value, objectIds, activeObjectId);
|
||||
if (targets.length > SELECTION_HISTORY_BUDGET.maxTargets) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection target count exceeds the budget");
|
||||
}
|
||||
let elementCount = 0;
|
||||
const keys = new Set<string>();
|
||||
for (const target of targets) {
|
||||
if (!objectIds.includes(target.objectId)) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Element target owner must be selected");
|
||||
}
|
||||
const key = targetKey(target);
|
||||
if (keys.has(key)) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection targets must be unique");
|
||||
keys.add(key);
|
||||
elementCount += target.indices.length;
|
||||
if (!Number.isSafeInteger(elementCount) || elementCount > SELECTION_HISTORY_BUDGET.maxElements) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selected element count exceeds the budget");
|
||||
}
|
||||
}
|
||||
targets.sort((left, right) => targetKey(left).localeCompare(targetKey(right)));
|
||||
return { activeObjectId, objectIds: [...objectIds].sort(), targets };
|
||||
}
|
||||
|
||||
export function parseSelectionHistory(value: unknown): SelectionHistoryIR {
|
||||
if (!record(value) || value.schemaVersion !== SELECTION_HISTORY_SCHEMA || !Array.isArray(value.entries)) throw new SelectionHistoryValidationError("PROTOCOL_MISMATCH", "Unsupported selection history schema");
|
||||
if (value.entries.length === 0 || value.entries.length > SELECTION_HISTORY_BUDGET.maxEntries) throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection history entry count exceeds the budget");
|
||||
return { schemaVersion: SELECTION_HISTORY_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), cursor: integer(value.cursor, "cursor", 0, value.entries.length - 1), entries: value.entries.map(parseSelectionState) };
|
||||
if (!record(value) || ![1, SELECTION_HISTORY_SCHEMA].includes(value.schemaVersion as number) || !Array.isArray(value.entries)) {
|
||||
throw new SelectionHistoryValidationError("PROTOCOL_MISMATCH", "Unsupported selection history schema");
|
||||
}
|
||||
if (value.entries.length === 0 || value.entries.length > SELECTION_HISTORY_BUDGET.maxEntries) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection history entry count exceeds the budget");
|
||||
}
|
||||
return {
|
||||
schemaVersion: SELECTION_HISTORY_SCHEMA,
|
||||
revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER),
|
||||
cursor: integer(value.cursor, "cursor", 0, value.entries.length - 1),
|
||||
entries: value.entries.map(parseSelectionState),
|
||||
};
|
||||
}
|
||||
|
||||
function equalState(a: SelectionStateIR, b: SelectionStateIR): boolean { return a.activeObjectId === b.activeObjectId && a.meshId === b.meshId && a.elementMode === b.elementMode && a.nonMeshKind === b.nonMeshKind && a.objectIds.join("\0") === b.objectIds.join("\0") && a.elementIndices.join(",") === b.elementIndices.join(","); }
|
||||
function equalState(left: SelectionStateIR, right: SelectionStateIR): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function recordSelection(value: unknown, revision: number, stateValue: unknown): SelectionHistoryIR {
|
||||
const history = parseSelectionHistory(value); if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale"); const state = parseSelectionState(stateValue);
|
||||
const history = parseSelectionHistory(value);
|
||||
if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale");
|
||||
const state = parseSelectionState(stateValue);
|
||||
if (equalState(history.entries[history.cursor], state)) return history;
|
||||
const entries = history.entries.slice(0, history.cursor + 1); entries.push(state); if (entries.length > SELECTION_HISTORY_BUDGET.maxEntries) entries.shift();
|
||||
const entries = history.entries.slice(0, history.cursor + 1);
|
||||
entries.push(state);
|
||||
if (entries.length > SELECTION_HISTORY_BUDGET.maxEntries) entries.shift();
|
||||
return parseSelectionHistory({ schemaVersion: SELECTION_HISTORY_SCHEMA, revision: history.revision + 1, cursor: entries.length - 1, entries });
|
||||
}
|
||||
|
||||
export function stepSelectionHistory(value: unknown, revision: number, direction: "UNDO" | "REDO"): SelectionHistoryIR {
|
||||
const history = parseSelectionHistory(value); if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale"); const cursor = history.cursor + (direction === "UNDO" ? -1 : 1);
|
||||
if (cursor < 0 || cursor >= history.entries.length) throw new SelectionHistoryValidationError("SELECTION_UNDO_UNAVAILABLE", `${direction} has no selection entry`);
|
||||
const history = parseSelectionHistory(value);
|
||||
if (revision !== history.revision) throw new SelectionHistoryValidationError("REVISION_CONFLICT", "Selection history revision is stale");
|
||||
const cursor = history.cursor + (direction === "UNDO" ? -1 : 1);
|
||||
if (cursor < 0 || cursor >= history.entries.length) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_UNDO_UNAVAILABLE", `${direction} has no selection entry`);
|
||||
}
|
||||
return { ...history, revision: history.revision + 1, cursor };
|
||||
}
|
||||
|
||||
export function patchSelectionRanges(stateValue: unknown, patchesValue: unknown): SelectionStateIR {
|
||||
const state = parseSelectionState(stateValue);
|
||||
if (!Array.isArray(patchesValue) || patchesValue.length === 0 || patchesValue.length > SELECTION_HISTORY_BUDGET.maxRangePatches) {
|
||||
throw new SelectionHistoryValidationError(
|
||||
Array.isArray(patchesValue) && patchesValue.length > SELECTION_HISTORY_BUDGET.maxRangePatches ? "SELECTION_HISTORY_BUDGET_EXCEEDED" : "SELECTION_HISTORY_INVALID",
|
||||
"Selection range patches are invalid",
|
||||
);
|
||||
}
|
||||
const targets = new Map(state.targets.map((target) => [targetKey(target), { ...target, indices: new Set(target.indices) }]));
|
||||
let touched = 0;
|
||||
for (let patchIndex = 0; patchIndex < patchesValue.length; patchIndex++) {
|
||||
const value = patchesValue[patchIndex];
|
||||
if (!record(value) || typeof value.selected !== "boolean") {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `patches[${patchIndex}] is invalid`);
|
||||
}
|
||||
const parsedKind = kind(value.nonMeshKind, "SELECTION_HISTORY_INVALID");
|
||||
const patch: SelectionRangePatchIR = {
|
||||
objectId: id(value.objectId, `patches[${patchIndex}].objectId`),
|
||||
dataId: id(value.dataId, `patches[${patchIndex}].dataId`),
|
||||
mode: mode(value.mode, `patches[${patchIndex}].mode`),
|
||||
start: integer(value.start, `patches[${patchIndex}].start`, 0, Number.MAX_SAFE_INTEGER),
|
||||
end: integer(value.end, `patches[${patchIndex}].end`, 0, Number.MAX_SAFE_INTEGER),
|
||||
selected: value.selected,
|
||||
...(parsedKind ? { nonMeshKind: parsedKind } : {}),
|
||||
};
|
||||
if (patch.end < patch.start) throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", `patches[${patchIndex}] range is reversed`);
|
||||
const length = patch.end - patch.start + 1;
|
||||
touched += length;
|
||||
if (!Number.isSafeInteger(touched) || touched > SELECTION_HISTORY_BUDGET.maxElements) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_BUDGET_EXCEEDED", "Selection range patch span exceeds the budget");
|
||||
}
|
||||
if (!state.objectIds.includes(patch.objectId)) {
|
||||
throw new SelectionHistoryValidationError("SELECTION_HISTORY_INVALID", "Selection range patch owner must be selected");
|
||||
}
|
||||
const key = targetKey(patch);
|
||||
const target = targets.get(key) ?? { objectId: patch.objectId, dataId: patch.dataId, mode: patch.mode, indices: new Set<number>(), ...(patch.nonMeshKind ? { nonMeshKind: patch.nonMeshKind } : {}) };
|
||||
for (let index = patch.start; index <= patch.end; index++) {
|
||||
if (patch.selected) target.indices.add(index);
|
||||
else target.indices.delete(index);
|
||||
}
|
||||
if (target.indices.size === 0) targets.delete(key);
|
||||
else targets.set(key, target);
|
||||
}
|
||||
return parseSelectionState({
|
||||
activeObjectId: state.activeObjectId,
|
||||
objectIds: state.objectIds,
|
||||
targets: [...targets.values()].map((target) => ({ ...target, indices: [...target.indices] })),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseRaycastSelectionHit(value: unknown, expectedRevision: number): RaycastSelectionHitIR {
|
||||
if (!record(value) || value.sourceRevision !== expectedRevision || typeof value.dataId !== "string" || !value.dataId || !["VERT", "EDGE", "FACE"].includes(value.mode as string) || !Number.isSafeInteger(value.index) || (value.index as number) < 0 || typeof value.distance !== "number" || !Number.isFinite(value.distance) || value.distance < 0 || !Array.isArray(value.point) || value.point.length !== 3 || value.point.some((item) => typeof item !== "number" || !Number.isFinite(item))) throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast hit is stale or invalid");
|
||||
const nonMeshKind = value.nonMeshKind === undefined ? undefined : ["CONTROL_POINT", "HANDLE_LEFT", "HANDLE_RIGHT"].includes(value.nonMeshKind as string) ? value.nonMeshKind as NonMeshSelectionKind : (() => { throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast non-mesh identity is invalid"); })();
|
||||
return { sourceRevision: value.sourceRevision as number, dataId: value.dataId, mode: value.mode as SelectionElementMode, index: value.index as number, distance: value.distance, point: value.point as [number, number, number], ...(nonMeshKind ? { nonMeshKind } : {}) };
|
||||
if (!record(value) || value.sourceRevision !== expectedRevision || typeof value.dataId !== "string" || !value.dataId || !["VERT", "EDGE", "FACE"].includes(value.mode as string) || !Number.isSafeInteger(value.index) || (value.index as number) < 0 || typeof value.distance !== "number" || !Number.isFinite(value.distance) || value.distance < 0 || !Array.isArray(value.point) || value.point.length !== 3 || value.point.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
|
||||
throw new SelectionHistoryValidationError("RAYCAST_HIT_INVALID", "Raycast hit is stale or invalid");
|
||||
}
|
||||
const parsedKind = kind(value.nonMeshKind, "RAYCAST_HIT_INVALID");
|
||||
const objectId = value.objectId === undefined ? undefined : id(value.objectId, "objectId");
|
||||
return {
|
||||
sourceRevision: value.sourceRevision as number,
|
||||
...(objectId ? { objectId } : {}),
|
||||
dataId: value.dataId,
|
||||
mode: value.mode as SelectionElementMode,
|
||||
index: value.index as number,
|
||||
distance: value.distance,
|
||||
point: value.point as [number, number, number],
|
||||
...(parsedKind ? { nonMeshKind: parsedKind } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function gateSelectionInteraction(operation: "RAYCAST" | "HISTORY" | "GIZMO"): CapabilityGateResult {
|
||||
if (operation !== "GIZMO") return readyGate("N-015", operation);
|
||||
return blockedGate("N-015", operation, [capabilityIssue("CAPABILITY_MISSING", "Curve/non-mesh gizmo interaction is not implemented")]);
|
||||
return blockedGate("N-015", operation, [capabilityIssue("CAPABILITY_MISSING", "Curve gizmo preview remains unavailable; bounded multi-handle commit is supported")]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { ErrorCode } from "./error";
|
||||
|
||||
export const SIMULATION_CACHE_SCHEMA = 1 as const;
|
||||
export const SIMULATION_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const;
|
||||
export const SIMULATION_CACHE_BUDGET = {
|
||||
maxCacheBytes: 16 * 1024 * 1024 * 1024,
|
||||
maxFrameBytes: 512 * 1024 * 1024,
|
||||
maxFrames: 100_000,
|
||||
} as const;
|
||||
|
||||
export interface SimulationCacheFrameIR {
|
||||
frame: number;
|
||||
@@ -69,7 +74,10 @@ export function parseSimulationCacheManifest(value: unknown): SimulationCacheMan
|
||||
const frameStart = integer(value.frameStart, "frameStart", -1_000_000);
|
||||
const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000);
|
||||
const byteLength = integer(value.byteLength, "byteLength", 1);
|
||||
if (frameEnd < frameStart || frameEnd - frameStart > 100_000 || !Array.isArray(value.frames)) {
|
||||
if (byteLength > SIMULATION_CACHE_BUDGET.maxCacheBytes) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache exceeds the byte budget");
|
||||
}
|
||||
if (frameEnd < frameStart || frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames || !Array.isArray(value.frames)) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame range is invalid");
|
||||
}
|
||||
if (value.frames.length !== frameEnd - frameStart + 1) {
|
||||
@@ -81,7 +89,10 @@ export function parseSimulationCacheManifest(value: unknown): SimulationCacheMan
|
||||
const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000);
|
||||
const byteOffset = integer(item.byteOffset, `frames[${index}].byteOffset`);
|
||||
const frameByteLength = integer(item.byteLength, `frames[${index}].byteLength`, 1);
|
||||
if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset + frameByteLength > byteLength) {
|
||||
if (frameByteLength > SIMULATION_CACHE_BUDGET.maxFrameBytes) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] exceeds the byte budget`);
|
||||
}
|
||||
if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is not contiguous or ordered`);
|
||||
}
|
||||
nextOffset += frameByteLength;
|
||||
@@ -122,6 +133,30 @@ export async function verifySimulationCache(manifestValue: unknown, data: ArrayB
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function selectSimulationCacheFrame(manifestValue: unknown, frame: number): SimulationCacheFrameIR {
|
||||
const manifest = parseSimulationCacheManifest(manifestValue);
|
||||
if (!Number.isSafeInteger(frame) || frame < manifest.frameStart || frame > manifest.frameEnd) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", `Simulation cache has no frame ${frame}`);
|
||||
}
|
||||
const selected = manifest.frames[frame - manifest.frameStart];
|
||||
if (!selected || selected.frame !== frame) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", `Simulation cache has no frame ${frame}`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export async function verifySimulationCacheFrame(
|
||||
manifestValue: unknown,
|
||||
frame: number,
|
||||
data: ArrayBuffer,
|
||||
): Promise<SimulationCacheFrameIR> {
|
||||
const selected = selectSimulationCacheFrame(manifestValue, frame);
|
||||
if (data.byteLength !== selected.byteLength || await sha256(data) !== selected.sha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame} failed SHA-256 verification`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function simulationCacheKey(manifest: SimulationCacheManifestIR): string {
|
||||
return `${manifest.graphHash.slice(0, 16)}-${manifest.sourceBlendSha256.slice(0, 16)}-${manifest.inputHash.slice(0, 16)}-${manifest.frameStart}-${manifest.frameEnd}`;
|
||||
}
|
||||
|
||||
@@ -169,6 +169,13 @@ export interface StorageSimulationCacheReadResult extends StorageSimulationCache
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface StorageSimulationCacheFrameReadResult extends StorageSimulationCacheResult {
|
||||
frame: number;
|
||||
byteOffset: number;
|
||||
byteLength: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface StorageSimulationCacheListResult {
|
||||
projectId: string;
|
||||
caches: Array<{
|
||||
@@ -206,13 +213,14 @@ export interface StorageRequest {
|
||||
| { type: "pruneLOD"; projectId: string; maxBytes: number }
|
||||
| { type: "putSimulationCache"; projectId: string; manifest: SimulationCacheManifestIR; data: ArrayBuffer }
|
||||
| { type: "readSimulationCache"; projectId: string; cacheKey: string }
|
||||
| { type: "readSimulationCacheFrame"; projectId: string; cacheKey: string; frame: number }
|
||||
| { type: "listSimulationCaches"; projectId: string };
|
||||
}
|
||||
|
||||
export interface StorageResponse {
|
||||
requestId: string;
|
||||
ok: boolean;
|
||||
result?: StorageSmokeResult | StorageInfoResult | StorageProjectResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheListResult;
|
||||
result?: StorageSmokeResult | StorageInfoResult | StorageProjectResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheFrameReadResult | StorageSimulationCacheListResult;
|
||||
error?: string;
|
||||
errorCode?: ErrorCode;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ export type WebEngineEditCommand =
|
||||
| { type: "setVertexColors"; meshId: string; attributeName: string; domain: "POINT" | "CORNER"; indices: number[]; colors: number[] }
|
||||
| { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean }
|
||||
| { type: "setLightProperties"; dataId: string; properties: { color?: [number, number, number]; energy?: number; exposure?: number; temperature?: number; useTemperature?: boolean; castsShadow?: boolean; radius?: number; spotAngle?: number; spotBlend?: number; areaSize?: number; areaSizeY?: number; areaSpread?: number; sunAngle?: number } }
|
||||
| { type: "setCameraProperties"; dataId: string; properties: { projection?: "PERSPECTIVE" | "ORTHOGRAPHIC"; lensMm?: number; sensorWidthMm?: number; sensorHeightMm?: number; sensorFit?: 0 | 1 | 2; shift?: [number, number]; near?: number; far?: number; orthoScale?: number; depthOfField?: { enabled?: boolean; focusDistance?: number; apertureFStop?: number; apertureBlades?: number; apertureRotation?: number; apertureRatio?: number } } }
|
||||
| { type: "setWorldProperties"; dataId: string; properties: { color?: [number, number, number]; exposure?: number; mist?: { enabled?: boolean; type?: "QUADRATIC" | "LINEAR" | "INVERSE_QUADRATIC"; start?: number; depth?: number; intensity?: number; height?: number } } }
|
||||
| { type: "setMetaballElements"; dataId: string; elements: Array<{ type: number; position: [number, number, number]; radius: number; scale: [number, number, number] }> }
|
||||
| { type: "sculptStroke"; stroke: SculptStrokeIR }
|
||||
|
||||
@@ -425,6 +425,37 @@ test("enforces the N-016 Grease Pencil layer/frame/drawing/stroke/point schema b
|
||||
expect(result.budget).toBe("accepted-blocked-budget");
|
||||
});
|
||||
|
||||
test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createGreasePencilObject } = await import("/src/three-adapter/grease-pencil.ts");
|
||||
const point = (x: number) => ({ position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] });
|
||||
const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ cyclic: false, pointCount: 2, materialIndex: 0, points: [point(x), point(x + 1)] }] });
|
||||
const object = createGreasePencilObject({
|
||||
id: "grease-pencil:onion",
|
||||
name: "Onion",
|
||||
geometryStatus: "available",
|
||||
layerCount: 1,
|
||||
frameCount: 3,
|
||||
strokeCount: 3,
|
||||
pointCount: 6,
|
||||
layers: [{ id: "layer:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [
|
||||
{ frame: 1, drawing: drawing("drawing:1", -2) },
|
||||
{ frame: 5, drawing: drawing("drawing:5", 0) },
|
||||
{ frame: 9, drawing: drawing("drawing:9", 2) },
|
||||
] }],
|
||||
}, 5);
|
||||
if (!object) return null;
|
||||
return {
|
||||
onion: object.userData.greasePencilOnionStrokeCount,
|
||||
current: object.userData.greasePencilCurrentStrokeCount,
|
||||
kinds: object.children.map((child) => child.userData.greasePencilOnion),
|
||||
opacities: object.children.map((child) => "material" in child && !Array.isArray(child.material) ? (child.material as { opacity?: number }).opacity : undefined),
|
||||
};
|
||||
});
|
||||
expect(result).toEqual({ onion: 2, current: 1, kinds: ["PREVIOUS", "NONE", "NEXT"], opacities: [0.28, 1, 0.28] });
|
||||
});
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`renders the N-016 Grease Pencil current-frame strokes in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => {
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
@@ -465,6 +496,33 @@ test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({
|
||||
expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED");
|
||||
});
|
||||
|
||||
test("derives an N-017 source-face, barycentric and UV paint hit from a real raycast", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const three = await import("/src/vendor/three/three.module.js");
|
||||
const { paintHitFromIntersection } = await import("/src/three-adapter/paint-hit.ts");
|
||||
const geometry = new three.BufferGeometry();
|
||||
geometry.setAttribute("position", new three.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3));
|
||||
geometry.setAttribute("uv", new three.Float32BufferAttribute([0, 0, 1, 0, 0, 1], 2));
|
||||
geometry.setIndex([0, 1, 2]);
|
||||
const mesh = new three.Mesh(geometry, new three.MeshBasicMaterial());
|
||||
mesh.userData.blenderId = "object:paint";
|
||||
mesh.userData.meshId = "mesh:paint";
|
||||
mesh.userData.triangleFaceIndices = [17];
|
||||
mesh.updateMatrixWorld(true);
|
||||
const raycaster = new three.Raycaster(new three.Vector3(0.25, 0.25, 1), new three.Vector3(0, 0, -1));
|
||||
const intersection = raycaster.intersectObject(mesh)[0];
|
||||
return intersection ? paintHitFromIntersection(intersection, 0.75) : null;
|
||||
});
|
||||
expect(result?.objectId).toBe("object:paint");
|
||||
expect(result?.dataId).toBe("mesh:paint");
|
||||
expect(result?.faceIndex).toBe(17);
|
||||
expect(result?.pressure).toBe(0.75);
|
||||
expect(result?.barycentric).toEqual(expect.arrayContaining([0.5, 0.25, 0.25]));
|
||||
expect(result?.uv).toEqual([0.25, 0.25]);
|
||||
expect(result?.normal).toEqual([0, 0, 1]);
|
||||
});
|
||||
|
||||
test("validates the N-018 physics capability and cache manifests without claiming solvers", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
@@ -642,11 +700,16 @@ test("keeps N-015 selection history bounded and rejects stale raycast hits", asy
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.history).toEqual([3, 1, [1, 3], "HANDLE_LEFT"]);
|
||||
expect(result.history).toEqual([3, 1, [
|
||||
["curve:1", [1, 3], "HANDLE_LEFT"],
|
||||
["curve:2", [2], "HANDLE_RIGHT"],
|
||||
]]);
|
||||
expect(result.revision).toContain("REVISION_CONFLICT");
|
||||
expect(result.budget).toContain("SELECTION_HISTORY_BUDGET_EXCEEDED");
|
||||
expect(result.raycast).toContain("RAYCAST_HIT_INVALID");
|
||||
expect(result.handleHit).toBe("HANDLE_RIGHT");
|
||||
expect(result.handleHit).toEqual(["object:1", "HANDLE_RIGHT"]);
|
||||
expect(result.rangePatch).toEqual([["curve:1", [1, 2, 4]]]);
|
||||
expect(result.migrated).toEqual([2, "mesh:1"]);
|
||||
expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]);
|
||||
});
|
||||
|
||||
@@ -895,6 +958,14 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
const restarted = new StorageClient();
|
||||
const listed = await restarted.listSimulationCaches(projectId);
|
||||
const read = await restarted.readSimulationCache(projectId, stored.cacheKey);
|
||||
const frameRead = await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 2);
|
||||
let missingFrameCode = "";
|
||||
try {
|
||||
await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 3);
|
||||
}
|
||||
catch (error) {
|
||||
missingFrameCode = String((error as Error & { code?: string }).code ?? "");
|
||||
}
|
||||
let corruptCode = "";
|
||||
try {
|
||||
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, source.buffer.slice(0));
|
||||
@@ -908,6 +979,10 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
path: stored.path,
|
||||
listed: listed.caches.map((cache) => cache.cacheKey),
|
||||
bytes: Array.from(new Uint8Array(read.data)),
|
||||
frame: frameRead.frame,
|
||||
frameOffset: frameRead.byteOffset,
|
||||
frameBytes: Array.from(new Uint8Array(frameRead.data)),
|
||||
missingFrameCode,
|
||||
corruptCode,
|
||||
};
|
||||
});
|
||||
@@ -915,6 +990,10 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
|
||||
expect(result.listed).toContain(result.cacheKey);
|
||||
expect(result.bytes).toEqual([11, 12, 13, 21, 22, 23, 24]);
|
||||
expect(result.frame).toBe(2);
|
||||
expect(result.frameOffset).toBe(3);
|
||||
expect(result.frameBytes).toEqual([21, 22, 23, 24]);
|
||||
expect(result.missingFrameCode).toBe("SIMULATION_CACHE_MISSING");
|
||||
expect(result.corruptCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user