Advance N-015 through N-018 bounded workflows
This commit is contained in:
@@ -829,6 +829,13 @@ bool refresh_scene_from_main(EngineState &engine,
|
||||
return false;
|
||||
}
|
||||
snapshot["greasePencils"] = json::parse(grease_pencils_json);
|
||||
std::string physics_json;
|
||||
if (!web_engine_blend_main_physics_simulation_json(
|
||||
engine.authoritative_main, physics_json, error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
snapshot["physicsSimulation"] = json::parse(physics_json);
|
||||
if (!active_object_id.empty()) snapshot["activeObjectId"] = active_object_id;
|
||||
engine.blend_bytes.assign(reinterpret_cast<const char *>(bytes.data()), bytes.size());
|
||||
engine.packed_assets = parsed.packed_assets;
|
||||
@@ -1125,6 +1132,15 @@ EMSCRIPTEN_KEEPALIVE int web_engine_open_blend(const int handle,
|
||||
return last_error_code;
|
||||
}
|
||||
opened_snapshot["greasePencils"] = json::parse(grease_pencils_json);
|
||||
std::string physics_json;
|
||||
if (!web_engine_blend_main_physics_simulation_json(
|
||||
authoritative_main, physics_json, main_error))
|
||||
{
|
||||
web_engine_blend_main_free(authoritative_main);
|
||||
set_error(WEB_ENGINE_BLEND_READ_FAILED, main_error.c_str());
|
||||
return last_error_code;
|
||||
}
|
||||
opened_snapshot["physicsSimulation"] = json::parse(physics_json);
|
||||
}
|
||||
catch (const std::exception &exception) {
|
||||
web_engine_blend_main_free(authoritative_main);
|
||||
@@ -1173,6 +1189,13 @@ EMSCRIPTEN_KEEPALIVE int web_engine_apply_command(const int handle,
|
||||
const json command = json::parse(reinterpret_cast<const char *>(data),
|
||||
reinterpret_cast<const char *>(data) + length);
|
||||
const std::string type = command.value("type", "");
|
||||
if (command.contains("baseRevision") &&
|
||||
(!command["baseRevision"].is_number_unsigned() ||
|
||||
command["baseRevision"].get<uint64_t>() != engine->revision))
|
||||
{
|
||||
set_error(WEB_ENGINE_INVALID_ARGUMENT, "REVISION_CONFLICT: command base revision does not match the current SceneIR");
|
||||
return last_error_code;
|
||||
}
|
||||
const std::string previous_snapshot = engine->scene_snapshot.empty() ?
|
||||
std::string(empty_scene_snapshot_json()) :
|
||||
engine->scene_snapshot;
|
||||
|
||||
@@ -3913,6 +3913,11 @@ json scene_ir_from_blend(const ParsedBlend &blend,
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string web_engine_sha256_hex(const std::string &value)
|
||||
{
|
||||
return sha256_hex(value);
|
||||
}
|
||||
|
||||
WebBlendReadResult web_engine_read_blend_scene_ir(const uint8_t *data,
|
||||
const uint32_t length,
|
||||
const uint64_t revision)
|
||||
|
||||
@@ -16,3 +16,5 @@ struct WebBlendReadResult {
|
||||
WebBlendReadResult web_engine_read_blend_scene_ir(const uint8_t *data,
|
||||
uint32_t length,
|
||||
uint64_t revision);
|
||||
|
||||
std::string web_engine_sha256_hex(const std::string &value);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "web_engine_main_state.h"
|
||||
#include "web_engine_blend_reader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
@@ -60,7 +61,10 @@
|
||||
#include "DNA_meshdata_types.h"
|
||||
#include "DNA_modifier_types.h"
|
||||
#include "DNA_node_types.h"
|
||||
#include "DNA_object_force_types.h"
|
||||
#include "DNA_object_types.h"
|
||||
#include "DNA_particle_types.h"
|
||||
#include "DNA_rigidbody_types.h"
|
||||
#include "DNA_scene_types.h"
|
||||
#include "DNA_world_types.h"
|
||||
#include "DNA_userdef_enums.h"
|
||||
@@ -3641,6 +3645,122 @@ bool web_engine_blend_main_grease_pencils_json(WebBlendMainState *state,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_physics_simulation_json(WebBlendMainState *state,
|
||||
std::string &physics_json,
|
||||
std::string &error)
|
||||
{
|
||||
if (state == nullptr || state->main == nullptr) {
|
||||
error = "PHYSICS_MANIFEST_INVALID: authoritative Main is not open";
|
||||
return false;
|
||||
}
|
||||
constexpr size_t max_systems = 4096;
|
||||
auto canonical_number = [](const float value) -> json {
|
||||
return std::isfinite(value) && std::trunc(value) == value ? json(int64_t(value)) : json(value);
|
||||
};
|
||||
json systems = json::array();
|
||||
json collider_ids = json::array();
|
||||
for (const Object &object : state->main->objects) {
|
||||
for (const ModifierData *modifier = static_cast<const ModifierData *>(object.modifiers.first);
|
||||
modifier != nullptr;
|
||||
modifier = modifier->next)
|
||||
{
|
||||
if (modifier->type == eModifierType_Collision) {
|
||||
collider_ids.push_back("object:" + id_name(object.id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
auto append_system = [&](const std::string &id,
|
||||
const char *family,
|
||||
const Object &object,
|
||||
json settings,
|
||||
json dependencies = json::array()) -> bool {
|
||||
if (systems.size() >= max_systems) {
|
||||
error = "PHYSICS_BUDGET_EXCEEDED: Physics system count exceeds the 4096 system budget";
|
||||
return false;
|
||||
}
|
||||
systems.push_back({{"id", id},
|
||||
{"family", family},
|
||||
{"ownerObjectId", "object:" + id_name(object.id)},
|
||||
{"settingsHash", web_engine_sha256_hex(settings.dump())},
|
||||
{"settings", std::move(settings)},
|
||||
{"dependencyIds", std::move(dependencies)}});
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const Object &object : state->main->objects) {
|
||||
const std::string object_name = id_name(object.id);
|
||||
if (object.rigidbody_object != nullptr) {
|
||||
const RigidBodyOb &body = *object.rigidbody_object;
|
||||
if (!append_system("physics:rigid-body:" + object_name,
|
||||
"RIGID_BODY",
|
||||
object,
|
||||
{{"type", int(body.type)},
|
||||
{"shape", int(body.shape)},
|
||||
{"flags", int(body.flag)},
|
||||
{"mass", canonical_number(body.mass)},
|
||||
{"friction", canonical_number(body.friction)},
|
||||
{"restitution", canonical_number(body.restitution)},
|
||||
{"margin", canonical_number(body.margin)},
|
||||
{"linearDamping", canonical_number(body.lin_damping)},
|
||||
{"angularDamping", canonical_number(body.ang_damping)}})) return false;
|
||||
}
|
||||
if (object.soft != nullptr) {
|
||||
const SoftBody &soft = *object.soft;
|
||||
if (!append_system("physics:soft-body:" + object_name,
|
||||
"SOFT_BODY",
|
||||
object,
|
||||
{{"nodeMass", canonical_number(soft.nodemass)},
|
||||
{"gravity", canonical_number(soft.grav)},
|
||||
{"mediaFriction", canonical_number(soft.mediafrict)},
|
||||
{"physicsSpeed", canonical_number(soft.physics_speed)},
|
||||
{"goalSpring", canonical_number(soft.goalspring)},
|
||||
{"goalFriction", canonical_number(soft.goalfrict)},
|
||||
{"solverFlags", int(soft.solverflags)}},
|
||||
collider_ids)) return false;
|
||||
}
|
||||
for (const ModifierData *modifier = static_cast<const ModifierData *>(object.modifiers.first);
|
||||
modifier != nullptr;
|
||||
modifier = modifier->next)
|
||||
{
|
||||
const std::string suffix = object_name + ":" + std::to_string(modifier->persistent_uid);
|
||||
json settings = {{"name", modifier->name},
|
||||
{"persistentUid", modifier->persistent_uid},
|
||||
{"enabled", bool(modifier->mode & eModifierMode_Realtime)},
|
||||
{"showRender", bool(modifier->mode & eModifierMode_Render)}};
|
||||
if (modifier->type == eModifierType_Cloth) {
|
||||
if (!append_system("physics:cloth:" + suffix, "CLOTH", object, std::move(settings), collider_ids)) return false;
|
||||
}
|
||||
else if (modifier->type == eModifierType_Fluid) {
|
||||
const FluidModifierData &fluid = *reinterpret_cast<const FluidModifierData *>(modifier);
|
||||
settings["modifierType"] = int(fluid.type);
|
||||
if (!append_system("physics:fluid:" + suffix, "FLUID", object, std::move(settings))) return false;
|
||||
}
|
||||
else if (modifier->type == eModifierType_DynamicPaint) {
|
||||
const DynamicPaintModifierData &paint = *reinterpret_cast<const DynamicPaintModifierData *>(modifier);
|
||||
settings["modifierType"] = int(paint.type);
|
||||
if (!append_system("physics:dynamic-paint:" + suffix, "DYNAMIC_PAINT", object, std::move(settings))) return false;
|
||||
}
|
||||
else if (modifier->type == eModifierType_ParticleSystem) {
|
||||
const ParticleSystemModifierData &particle = *reinterpret_cast<const ParticleSystemModifierData *>(modifier);
|
||||
const bool hair = particle.psys != nullptr && particle.psys->part != nullptr && particle.psys->part->type == PART_HAIR;
|
||||
if (particle.psys != nullptr) {
|
||||
settings["seed"] = particle.psys->seed;
|
||||
settings["particleCount"] = particle.psys->totpart;
|
||||
if (particle.psys->part != nullptr) {
|
||||
settings["particleType"] = int(particle.psys->part->type);
|
||||
settings["physicsType"] = int(particle.psys->part->phystype);
|
||||
}
|
||||
}
|
||||
if (!append_system("physics:" + std::string(hair ? "hair:" : "particle:") + suffix,
|
||||
hair ? "HAIR" : "PARTICLE", object, std::move(settings))) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
physics_json = json({{"schemaVersion", 1}, {"systems", std::move(systems)}}).dump();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_engine_blend_main_create_grease_pencil_layer(WebBlendMainState *state,
|
||||
const char *data_id,
|
||||
const char *name,
|
||||
|
||||
@@ -348,6 +348,9 @@ bool web_engine_blend_main_set_metaball_elements(WebBlendMainState *state,
|
||||
bool web_engine_blend_main_grease_pencils_json(WebBlendMainState *state,
|
||||
std::string &grease_pencils_json,
|
||||
std::string &error);
|
||||
bool web_engine_blend_main_physics_simulation_json(WebBlendMainState *state,
|
||||
std::string &physics_json,
|
||||
std::string &error);
|
||||
bool web_engine_blend_main_create_grease_pencil_layer(WebBlendMainState *state,
|
||||
const char *data_id,
|
||||
const char *name,
|
||||
|
||||
@@ -65,6 +65,13 @@ VDB 体渲染或完整 Volume USD loss fixture。
|
||||
Volume loss fixture 仍由 VDB 资源/renderer 阻断。
|
||||
11. Blender 5.2 fixture 已包含真实 PointCloud/Curves 属性;Chromium 主线程/Offscreen、真实
|
||||
depsgraph、WNM 1M 性能测试均接入。
|
||||
12. `CurveGizmoDragIR` 为 PREVIEW/COMMIT 请求绑定 data ID、base revision、轴向 delta 和最多
|
||||
256 个去重 handle identity;现有 UI gizmo 在单次 pointer commit 时先执行 stale revision、
|
||||
重复 handle、有限坐标和预算校验,再合并为一次 `setCurveTopology` Main 事务。连续 preview
|
||||
仍未开放。
|
||||
13. `test:vdb-availability` 确认当前 WASM build 为 `WITH_OPENVDB=OFF`、
|
||||
`WITH_NANOVDB=ON` metadata-only,项目和本机资源库没有可用 `.vdb`。因此真实 decoder、
|
||||
renderer 和 Volume loss fixture 继续 `BLOCKED`。
|
||||
|
||||
## 后续分解
|
||||
|
||||
@@ -109,6 +116,8 @@ npm --prefix web run test:nonmesh-usd-serialization
|
||||
BLENDER_BIN=/path/to/usd-enabled/blender \
|
||||
npm --prefix web run test:nonmesh-usd-blender-roundtrip
|
||||
npm --prefix web run test:nonmesh-binary
|
||||
npm --prefix web run test:nonmesh-interaction
|
||||
npm --prefix web run test:vdb-availability
|
||||
npm --prefix web run test:vdb
|
||||
WEB_TEST_PORT=5349 npm --prefix web run test:e2e -- -g "real OPFS quota|reports quota exhaustion"
|
||||
WEB_TEST_PORT=5201 npm --prefix web run test:e2e -- -g "N-015|non-mesh"
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
depth write 的有界 preview,当前 drawing 不被替换。
|
||||
5. N-016-D(部分):共享 Three.js 适配器按当前 frame 选择每个可见 layer 最近的有效
|
||||
drawing,渲染真实 3D stroke/闭环;Chromium 主线程与 OffscreenCanvas 均通过非空像素门。
|
||||
6. N-016-D(editor 部分):editor context schema 绑定 data/layer/frame、onion 开关、stroke/
|
||||
point selection 和 revision,拒绝 stale/重复/超 1M selection;Properties 面板已连接真实 Main
|
||||
layer create/remove、当前 frame insert/remove 和整帧 clear transaction。完整 2D stroke/point
|
||||
画布选择和 dope sheet integration 仍阻断。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
@@ -34,6 +38,7 @@
|
||||
npm --prefix web run typecheck
|
||||
npm --prefix web run lint
|
||||
npm --prefix web run test:grease-pencil
|
||||
WEB_TEST_PORT=5205 npm --prefix web run test:grease-pencil-editor
|
||||
WEB_TEST_PORT=5202 npm --prefix web run test:e2e -- --grep "N-016 Grease Pencil"
|
||||
```
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
6. `paintHitFromIntersection` 消费真实 Three `Raycaster` intersection,按 indexed/non-indexed
|
||||
triangle 解析顶点,输出对象/data stable ID、source face、局部 barycentric、世界法线、
|
||||
插值 UV 与 pressure;测试使用真实 BufferGeometry 射线,不注入伪命中。
|
||||
7. Properties paint 面板消费 viewport 的真实 VERT selection,可向选中顶点提交 `POINT`
|
||||
`WebPaintColor` 和 vertex group weight;每次操作只发一个既有 Main transaction,继续使用
|
||||
revision、undo/redo、save/reopen 边界。它不是 PBVH brush 或 texture paint。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ cache playback、WASM solver 和 bake job 未实现)
|
||||
IndexedDB 使用有界 slice,并再次校验该帧 SHA-256,越界帧返回 `SIMULATION_CACHE_MISSING`。
|
||||
5. 单 manifest 上限 4096 systems、每系统 1024 dependencies、256 settings/64 KiB、
|
||||
100k frames;单帧 512 MiB、整包 16 GiB,分配和 range 计算前检查安全整数及预算。
|
||||
6. authoritative Main reader 扫描真实 Rigid Body、Soft Body、Cloth、Fluid、Dynamic Paint、
|
||||
Particle/Hair 数据;只输出实际存在的 system,绑定 owner stable ID、有界 settings、collision
|
||||
dependency 和浏览器可复算 SHA-256。当前 fixture 实证覆盖禁用 Cloth、Soft Body 和 Collision
|
||||
依赖,save/reopen 保持一致;未用合成 system 填充其余 family。
|
||||
|
||||
## 仍然阻断
|
||||
|
||||
@@ -29,4 +33,9 @@ cache playback、WASM solver 和 bake job 未实现)
|
||||
```bash
|
||||
WEB_TEST_PORT=5204 npm --prefix web run test:e2e -- --grep "N-018 physics"
|
||||
npm --prefix web run test:simulation-cache
|
||||
npm --prefix web run test:physics-main-reader
|
||||
```
|
||||
|
||||
本轮在 Chromium 隔离端口复验 content-addressed cache 的 Worker restart、目标帧 range read 与
|
||||
帧 SHA-256 门通过;没有 desktop bake family decoder 或 solver,因此 playback/solver/bake 状态
|
||||
不变,继续为 `BLOCKED`。
|
||||
|
||||
@@ -17,7 +17,8 @@ Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据
|
||||
当前缺证据聚合为 `BLOCKED`,没有虚报发布通过。
|
||||
4. 当前 Chromium smoke 已通过引擎启动/Main 编辑与内容寻址资产恢复,并通过真实 64 KiB
|
||||
OPFS quota 下的失败保持旧 revision、Worker 重启恢复门。这只计为 partial evidence,不等于
|
||||
完整 suite 或其余 family 的桌面 golden。发布门 schema 2 仅接受 Chromium 浏览器证据。
|
||||
完整 suite 或其余 family 的桌面 golden。发布门 schema 3 仅接受 Chromium 浏览器证据,
|
||||
Firefox/WebKit 不在本项目当前测试配置内。
|
||||
5. N-015 Curve/Surface/Font/Metaball 已通过 Blender 5.2 desktop geometry golden;这四类及
|
||||
PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 仍无 renderer
|
||||
与 loss fixture,这些证据也不替代其余 family 的 desktop golden。
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"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-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"],
|
||||
"completedSlices": ["A1", "A2-malformed-binary-partial", "A2-integer-overflow-fixtures", "A2-multichunk-attribute-completeness", "B2", "B3-metadata", "B3-vdb-local-availability-assessment", "C1-topology-partial", "C1-handle-points-partial", "C1-handle-preview-raycast-partial", "C1-handle-identity-gizmo-main-roundtrip", "C1-multi-handle-selection-highlight-bounded-commit", "C1-gizmo-revision-delta-transaction-boundary", "C1-rename-partial", "C1-create-delete-poly-partial", "C1-poly-bezier-nurbs-1d-conversion", "C1-multispline-create-delete-bulk-handle-cyclic-transaction", "C1-surface-2d-topology-transaction", "C2-font-geometry-partial", "C2-font-layout-partial", "C2-font-character-style-textbox-roundtrip", "C2-existing-packed-vfont-style-links", "C2-builtin-font-evaluation-exact", "C3-roundtrip", "D1-raycast-partial", "D1-offscreen-vert-edge-partial", "D1-selection-history-partial", "D1-cross-object-history-range-patch", "D1-handle-identity-axis-gizmo", "D2-partial", "E1-partial", "E1-desktop-geometry-golden", "E1-true-2d-surface-desktop-golden", "E1-glb-evaluated-nonmesh-roundtrip-partial", "E1-glb-curve-line-surface-mesh-evaluated", "E1-usda-four-object-desktop-roundtrip", "E1-pointcloud-curves-hair-glb-usd-loss-fixture", "E2-1M-chromium", "E2-chromium-worker-recovery", "E2-opfs-quota-chromium"],
|
||||
"blockedSlices": ["B3-vdb-renderer", "C1-continuous-handle-gizmo-preview", "C2-new-external-font-import", "E1-volume-loss-fixture"],
|
||||
"acceptance": ["web:test:nonmesh-roundtrip", "web:test:nonmesh-desktop-golden", "web:test:nonmesh-glb-blender-roundtrip", "web:test:nonmesh-usd-serialization", "web:test:nonmesh-usd-blender-roundtrip", "web:test:nonmesh-binary", "web:test:vdb", "web:test:selection-history", "web:e2e:N-015|non-mesh", "web:e2e:real OPFS quota"],
|
||||
"acceptance": ["web:test:nonmesh-roundtrip", "web:test:nonmesh-desktop-golden", "web:test:nonmesh-glb-blender-roundtrip", "web:test:nonmesh-usd-serialization", "web:test:nonmesh-usd-blender-roundtrip", "web:test:nonmesh-binary", "web:test:vdb", "web:test:vdb-availability", "web:test:selection-history", "web:test:nonmesh-interaction", "web:e2e:N-015|non-mesh", "web:e2e:real OPFS quota"],
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
@@ -19,9 +19,9 @@
|
||||
"name": "Grease Pencil",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"acceptance": ["web:test:grease-pencil", "web:e2e:N-016 Grease Pencil"],
|
||||
"completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", "D-editor-main-layer-frame-panel-partial"],
|
||||
"blockedSlices": ["C-material-modifier-full-semantics", "D-full-2d-stroke-point-editor-dope-gizmo-restart", "E-desktop-browser-golden-export-opfs"],
|
||||
"acceptance": ["web:test:grease-pencil", "web:test:grease-pencil-editor", "web:e2e:N-016 Grease Pencil"],
|
||||
"dependencies": ["N-015"]
|
||||
},
|
||||
{
|
||||
@@ -29,8 +29,8 @@
|
||||
"name": "Paint and weights",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial"],
|
||||
"blockedSlices": ["A", "B", "C", "D", "E"],
|
||||
"completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", "B-selected-vertex-color-weight-ui-transaction-partial"],
|
||||
"blockedSlices": ["A-pbvh-occlusion-falloff", "B-clean-mirror-desktop-brush", "C-packed-udim-color-dirty-atomic", "D-E-mask-selection-gpu-quota-golden-ui"],
|
||||
"acceptance": ["web:test:paint-roundtrip", "web:e2e:N-017 paint"],
|
||||
"dependencies": ["N-016"]
|
||||
},
|
||||
@@ -39,9 +39,9 @@
|
||||
"name": "Physics and simulation",
|
||||
"status": "BLOCKED",
|
||||
"roadmapStatus": "planned",
|
||||
"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"],
|
||||
"completedSlices": ["A-family-capability-inventory", "A-authoritative-main-physics-reader-partial", "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate"],
|
||||
"blockedSlices": ["A-B-full-family-settings-effectors-cache-binding", "C-desktop-bake-decoder-playback-100-frame-golden", "D-wasm-solvers", "E-bake-server-recovery-progress-ui"],
|
||||
"acceptance": ["web:e2e:N-018 physics", "web:test:simulation-cache", "web:test:physics-main-reader"],
|
||||
"dependencies": ["N-017"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"source": "docs/status/parity-ledger.json",
|
||||
"sourceSha256": "fbaba7086a985176b2100f066326d48b203776ea0706783fa0fd0e61712d0795",
|
||||
"generatedAt": "2026-08-12T19:18:35.501Z",
|
||||
"sourceSha256": "086c3882eeabfa9f5bb746aca8da6294bff3415299a91106cc5cb40167907156",
|
||||
"generatedAt": "2026-08-12T21:11:21.310Z",
|
||||
"families": [
|
||||
{
|
||||
"id": "N-015",
|
||||
@@ -16,11 +16,13 @@
|
||||
"A2-multichunk-attribute-completeness",
|
||||
"B2",
|
||||
"B3-metadata",
|
||||
"B3-vdb-local-availability-assessment",
|
||||
"C1-topology-partial",
|
||||
"C1-handle-points-partial",
|
||||
"C1-handle-preview-raycast-partial",
|
||||
"C1-handle-identity-gizmo-main-roundtrip",
|
||||
"C1-multi-handle-selection-highlight-bounded-commit",
|
||||
"C1-gizmo-revision-delta-transaction-boundary",
|
||||
"C1-rename-partial",
|
||||
"C1-create-delete-poly-partial",
|
||||
"C1-poly-bezier-nurbs-1d-conversion",
|
||||
@@ -63,7 +65,9 @@
|
||||
"web:test:nonmesh-usd-blender-roundtrip",
|
||||
"web:test:nonmesh-binary",
|
||||
"web:test:vdb",
|
||||
"web:test:vdb-availability",
|
||||
"web:test:selection-history",
|
||||
"web:test:nonmesh-interaction",
|
||||
"web:e2e:N-015|non-mesh",
|
||||
"web:e2e:real OPFS quota"
|
||||
],
|
||||
@@ -80,17 +84,18 @@
|
||||
"B-layer-frame-stroke-transaction-partial",
|
||||
"C-point-radius-opacity-color-cyclic-material-partial",
|
||||
"C-bounded-previous-next-onion-preview",
|
||||
"D-current-frame-stroke-preview-main-offscreen-chromium"
|
||||
"D-current-frame-stroke-preview-main-offscreen-chromium",
|
||||
"D-editor-layer-frame-selection-context-partial",
|
||||
"D-editor-main-layer-frame-panel-partial"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
"C-material-modifier-full-semantics",
|
||||
"D-full-2d-stroke-point-editor-dope-gizmo-restart",
|
||||
"E-desktop-browser-golden-export-opfs"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:grease-pencil",
|
||||
"web:test:grease-pencil-editor",
|
||||
"web:e2e:N-016 Grease Pencil"
|
||||
],
|
||||
"dependencies": [
|
||||
@@ -106,14 +111,14 @@
|
||||
"A-stroke-hit-weight-schema-budget-partial",
|
||||
"A-three-raycast-source-face-barycentric-uv",
|
||||
"B-main-vertex-color-partial",
|
||||
"B-main-vertex-weight-normalize-partial"
|
||||
"B-main-vertex-weight-normalize-partial",
|
||||
"B-selected-vertex-color-weight-ui-transaction-partial"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
"A-pbvh-occlusion-falloff",
|
||||
"B-clean-mirror-desktop-brush",
|
||||
"C-packed-udim-color-dirty-atomic",
|
||||
"D-E-mask-selection-gpu-quota-golden-ui"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:test:paint-roundtrip",
|
||||
@@ -130,20 +135,22 @@
|
||||
"roadmapStatus": "planned",
|
||||
"completedSlices": [
|
||||
"A-family-capability-inventory",
|
||||
"A-authoritative-main-physics-reader-partial",
|
||||
"B-settings-dependency-cache-manifest-partial",
|
||||
"B-cloth-softbody-collision-settings-sha256-save-reopen",
|
||||
"C-exact-frame-selection-gate",
|
||||
"C-content-addressed-frame-range-read-hash-gate"
|
||||
],
|
||||
"blockedSlices": [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E"
|
||||
"A-B-full-family-settings-effectors-cache-binding",
|
||||
"C-desktop-bake-decoder-playback-100-frame-golden",
|
||||
"D-wasm-solvers",
|
||||
"E-bake-server-recovery-progress-ui"
|
||||
],
|
||||
"acceptance": [
|
||||
"web:e2e:N-018 physics",
|
||||
"web:test:simulation-cache"
|
||||
"web:test:simulation-cache",
|
||||
"web:test:physics-main-reader"
|
||||
],
|
||||
"dependencies": [
|
||||
"N-017"
|
||||
@@ -428,7 +435,7 @@
|
||||
],
|
||||
"command": "npm --prefix web run release:sbom",
|
||||
"exitCode": 0,
|
||||
"durationMs": 364,
|
||||
"durationMs": 352,
|
||||
"output": "> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||
"artifactSha256": [
|
||||
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||
@@ -446,10 +453,10 @@
|
||||
],
|
||||
"command": "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser",
|
||||
"exitCode": 0,
|
||||
"durationMs": 135891,
|
||||
"output": "d LOD profiles at the protocol boundary (1.0s)\n ✓ 67 tests/e2e/smoke.spec.ts:1765:1 › normalizes skin influences and rejects shape-key loss explicitly (1.0s)\n ✓ 68 tests/e2e/smoke.spec.ts:1790:1 › remaps skin weights and shape keys through the native Collapse worker path (1.3s)\n ✓ 69 tests/e2e/smoke.spec.ts:1846:1 › reports GLB export blockers before any binary export (1.1s)\n ✓ 70 tests/e2e/smoke.spec.ts:1858:1 › blocks Shader graphs that cannot be mapped to glTF PBR (1.0s)\n ✓ 71 tests/e2e/smoke.spec.ts:1885:1 › maps bounded RGB and Value Shader constants to glTF PBR factors (1.0s)\n ✓ 72 tests/e2e/smoke.spec.ts:1918:1 › exports a local SceneIR mesh as a standards-shaped GLB (1.0s)\n ✓ 73 tests/e2e/smoke.spec.ts:1951:1 › keeps per-vertex UV and color attributes in GLB output (995ms)\n ✓ 74 tests/e2e/smoke.spec.ts:1978:1 › evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.0s)\n ✓ 75 tests/e2e/smoke.spec.ts:1993:1 › embeds local textures and exports glTF skin and animation records (1.0s)\n ✓ 76 tests/e2e/smoke.spec.ts:2043:1 › reports lightweight budget violations without altering usage (1.0s)\n ✓ 77 tests/e2e/smoke.spec.ts:2061:1 › aggregates project, collection, object and LOD budgets without double counting LOD (991ms)\n ✓ 78 tests/e2e/smoke.spec.ts:2087:1 › round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2111:1 › blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 80 tests/e2e/smoke.spec.ts:2154:1 › extracts Blender packed image bytes through the local asset request API (1.4s)\n ✓ 81 tests/e2e/smoke.spec.ts:2199:1 › matches Blender Depsgraph deformation golden within the declared error budget (1.3s)\n ✓ 82 tests/e2e/smoke.spec.ts:2238:1 › evaluates the full Blender Depsgraph or reports its safe capability gate (1.3s)\n ✓ 83 tests/e2e/smoke.spec.ts:2298:1 › exports layered Action keyframes through SceneIR (1.3s)\n ✓ 84 tests/e2e/smoke.spec.ts:2339:1 › patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 85 tests/e2e/smoke.spec.ts:2363:1 › renders through the capability-gated OffscreenCanvas worker (1.2s)\n ✓ 86 tests/e2e/smoke.spec.ts:2376:1 › coalesces linked mesh objects into a raycastable instance group (2.0s)\n ✓ 87 tests/e2e/smoke.spec.ts:2384:1 › returns structured gates for the undeclared capability protocols (1.1s)\n ✓ 88 tests/e2e/smoke.spec.ts:2443:1 › exposes PBR-007 to PBR-012 renderer security gates (1.0s)\n ✓ 89 tests/e2e/smoke.spec.ts:2477:1 › transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 90 tests/e2e/smoke.spec.ts:2486:1 › uses the same packed texture payload in the OffscreenCanvas renderer (1.8s)\n\n 90 passed (2.1m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] › tests/e2e/cross-browser.spec.ts:6:1 › boots the offline engine, renders SceneIR and performs a Main edit (2.8s)\n ✓ 2 [chromium] › tests/e2e/cross-browser.spec.ts:32:1 › keeps content-addressed asset recovery available in Chromium (1.2s)\n ✓ 3 [chromium] › tests/e2e/cross-browser.spec.ts:51:1 › keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.8s)\n\n[WebServer] (node:1250790) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1250802) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255027) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255039) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
||||
"durationMs": 137892,
|
||||
"output": "D profiles at the protocol boundary (1.0s)\n ✓ 71 tests/e2e/smoke.spec.ts:1826:1 › normalizes skin influences and rejects shape-key loss explicitly (1.0s)\n ✓ 72 tests/e2e/smoke.spec.ts:1851:1 › remaps skin weights and shape keys through the native Collapse worker path (1.3s)\n ✓ 73 tests/e2e/smoke.spec.ts:1907:1 › reports GLB export blockers before any binary export (1.0s)\n ✓ 74 tests/e2e/smoke.spec.ts:1919:1 › blocks Shader graphs that cannot be mapped to glTF PBR (978ms)\n ✓ 75 tests/e2e/smoke.spec.ts:1946:1 › maps bounded RGB and Value Shader constants to glTF PBR factors (981ms)\n ✓ 76 tests/e2e/smoke.spec.ts:1979:1 › exports a local SceneIR mesh as a standards-shaped GLB (992ms)\n ✓ 77 tests/e2e/smoke.spec.ts:2012:1 › keeps per-vertex UV and color attributes in GLB output (978ms)\n ✓ 78 tests/e2e/smoke.spec.ts:2039:1 › evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2054:1 › embeds local textures and exports glTF skin and animation records (999ms)\n ✓ 80 tests/e2e/smoke.spec.ts:2104:1 › reports lightweight budget violations without altering usage (1.0s)\n ✓ 81 tests/e2e/smoke.spec.ts:2122:1 › aggregates project, collection, object and LOD budgets without double counting LOD (979ms)\n ✓ 82 tests/e2e/smoke.spec.ts:2148:1 › round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 83 tests/e2e/smoke.spec.ts:2172:1 › blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 84 tests/e2e/smoke.spec.ts:2215:1 › extracts Blender packed image bytes through the local asset request API (1.3s)\n ✓ 85 tests/e2e/smoke.spec.ts:2260:1 › matches Blender Depsgraph deformation golden within the declared error budget (1.3s)\n ✓ 86 tests/e2e/smoke.spec.ts:2299:1 › evaluates the full Blender Depsgraph or reports its safe capability gate (1.4s)\n ✓ 87 tests/e2e/smoke.spec.ts:2359:1 › exports layered Action keyframes through SceneIR (1.3s)\n ✓ 88 tests/e2e/smoke.spec.ts:2400:1 › patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 89 tests/e2e/smoke.spec.ts:2424:1 › renders through the capability-gated OffscreenCanvas worker (1.3s)\n ✓ 90 tests/e2e/smoke.spec.ts:2437:1 › coalesces linked mesh objects into a raycastable instance group (1.9s)\n ✓ 91 tests/e2e/smoke.spec.ts:2445:1 › returns structured gates for the undeclared capability protocols (1.2s)\n ✓ 92 tests/e2e/smoke.spec.ts:2504:1 › exposes PBR-007 to PBR-012 renderer security gates (1.0s)\n ✓ 93 tests/e2e/smoke.spec.ts:2538:1 › transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 94 tests/e2e/smoke.spec.ts:2547:1 › uses the same packed texture payload in the OffscreenCanvas renderer (1.5s)\n\n 94 passed (2.2m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] › tests/e2e/cross-browser.spec.ts:6:1 › boots the offline engine, renders SceneIR and performs a Main edit (2.6s)\n ✓ 2 [chromium] › tests/e2e/cross-browser.spec.ts:32:1 › keeps content-addressed asset recovery available in Chromium (1.1s)\n ✓ 3 [chromium] › tests/e2e/cross-browser.spec.ts:51:1 › keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.5s)\n\n[WebServer] (node:1291271) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1291283) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1295654) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1295666) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
||||
"artifactSha256": [
|
||||
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
|
||||
"c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -459,10 +466,10 @@
|
||||
],
|
||||
"command": "npm --prefix web run test:release-performance",
|
||||
"exitCode": 0,
|
||||
"durationMs": 90534,
|
||||
"output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"outputTriangles\":89999,\"elapsedMs\":89462,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":531,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.22819400001026224 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.10745999999926426 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.06875900000159163 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 1.2809850000048755 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.4198880000039935 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4606950000015786 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.2892889999930048 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.3983549999975367 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.341115000002901 msecs. Success: true",
|
||||
"durationMs": 86656,
|
||||
"output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"outputTriangles\":89999,\"elapsedMs\":85598,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":519,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.2532260000007227 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.07346400000096764 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.06714100000681356 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 0.033462000006693415 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.5294449999928474 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4542700000019977 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.41858699999284 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.3971560000063619 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.3646670000016456 msecs. Success: true",
|
||||
"artifactSha256": [
|
||||
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
|
||||
"c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -472,7 +479,7 @@
|
||||
],
|
||||
"command": "npm --prefix web run test:malicious-blends",
|
||||
"exitCode": 0,
|
||||
"durationMs": 533,
|
||||
"durationMs": 509,
|
||||
"output": "> blender-web-editor@0.1.0 test:malicious-blends\n> node ../tools/web/check-malicious-blends.mjs\n\nmalicious-blends-ok rejected=5",
|
||||
"artifactSha256": [
|
||||
"6fcc55bda74da8c95da96ba068b60339bf60ca44147ff8000fbb95d296db2a73"
|
||||
@@ -485,8 +492,8 @@
|
||||
],
|
||||
"command": "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4077,
|
||||
"output": "> blender-web-editor@0.1.0 test:asset-library\n> playwright test --config playwright.config.ts -g \"N-023 asset\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:625:1 › validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.2s)\n\n[WebServer] (node:1255677) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255689) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
||||
"durationMs": 3993,
|
||||
"output": "> blender-web-editor@0.1.0 test:asset-library\n> playwright test --config playwright.config.ts -g \"N-023 asset\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:672:1 › validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.2s)\n\n[WebServer] (node:1296131) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1296143) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
|
||||
"artifactSha256": [
|
||||
"6169748c5bf78a8e101196e964db67b8a65c2ba95bb3b4a54e052721be01a7bf"
|
||||
]
|
||||
@@ -496,11 +503,11 @@
|
||||
"fields": [],
|
||||
"command": "npm --prefix web run test:release-package",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4783,
|
||||
"output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 43 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-DR5p4R2R.js 238.07 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-CFVvLQ_x.wasm 15,003.09 kB │ gzip: 3,546.97 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CPm-S3KG.js 879.04 kB │ gzip: 236.82 kB\n\n✓ built in 549ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=31858159\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
|
||||
"durationMs": 4738,
|
||||
"output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 44 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-awSIju8U.js 242.93 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CALwrj_3.js 884.99 kB │ gzip: 238.44 kB\n\n✓ built in 598ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=31928384\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
|
||||
"artifactSha256": [
|
||||
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
|
||||
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
|
||||
"c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -511,12 +518,12 @@
|
||||
],
|
||||
"command": "npm --prefix web run release:offline",
|
||||
"exitCode": 0,
|
||||
"durationMs": 36219,
|
||||
"output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 43 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-DR5p4R2R.js 238.07 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-CFVvLQ_x.wasm 15,003.09 kB │ gzip: 3,546.97 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CPm-S3KG.js 879.04 kB │ gzip: 236.82 kB\n\n✓ built in 548ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7521936 source=205665275 sha256=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7521936 source=205665275 sha256=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7\noffline-reproducibility-ok binary=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7 source=ca6d3fc1df2d7885a3602beddd427a8a2ac05440a11d250f323cfe33e4fddcff\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
|
||||
"durationMs": 35842,
|
||||
"output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 44 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-awSIju8U.js 242.93 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CALwrj_3.js 884.99 kB │ gzip: 238.44 kB\n\n✓ built in 582ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7537432 source=205687508 sha256=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7537432 source=205687508 sha256=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac\noffline-reproducibility-ok binary=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac source=8fd6799861efbee5d8dcc1702adc97ff5612f5da646776f727bc97573db13585\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
|
||||
"artifactSha256": [
|
||||
"7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7",
|
||||
"ca6d3fc1df2d7885a3602beddd427a8a2ac05440a11d250f323cfe33e4fddcff",
|
||||
"c8f6b3287a67760855c145ef239fa8cc696cfb7a7d558d3284982df897caf4ed"
|
||||
"b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac",
|
||||
"8fd6799861efbee5d8dcc1702adc97ff5612f5da646776f727bc97573db13585",
|
||||
"46c691c2ec192486527ac5b3b94091f9f0e53c1a2185bf18ec3276001167f948"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -44,6 +44,17 @@ function command(engine, handle, payload) {
|
||||
finally { engine._free(pointer); }
|
||||
}
|
||||
|
||||
function reject(engine, handle, payload, code) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
try {
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
assert.notEqual(engine._web_engine_apply_command(handle, pointer, bytes.byteLength), 0, `${payload.type} unexpectedly succeeded`);
|
||||
assert.match(engine.UTF8ToString(engine._web_engine_last_error_message()), new RegExp(code));
|
||||
}
|
||||
finally { engine._free(pointer); }
|
||||
}
|
||||
|
||||
function snapshot(engine, handle) {
|
||||
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
|
||||
}
|
||||
@@ -198,7 +209,8 @@ const curveCyclic = curve.cyclicU.map((value, index) => index === 0 ? !value : v
|
||||
const curveHandlePoints = curve.handlePoints.slice();
|
||||
curveHandlePoints[0] += 0.25;
|
||||
curveHandlePoints[15] -= 0.2;
|
||||
command(engine, handle, { type: "setCurveTopology", dataId: curveId, splineTypes: curve.splineTypes, cyclicU: curveCyclic, cyclicV: curve.cyclicV, handleTypes: curve.handleTypes, handlePoints: curveHandlePoints });
|
||||
reject(engine, handle, { type: "setCurveTopology", baseRevision: snapshot(engine, handle).revision - 1, dataId: curveId, splineTypes: curve.splineTypes, cyclicU: curveCyclic, cyclicV: curve.cyclicV, handleTypes: curve.handleTypes, handlePoints: curveHandlePoints }, "REVISION_CONFLICT");
|
||||
command(engine, handle, { type: "setCurveTopology", baseRevision: snapshot(engine, handle).revision, dataId: curveId, splineTypes: curve.splineTypes, cyclicU: curveCyclic, cyclicV: curve.cyclicV, handleTypes: curve.handleTypes, handlePoints: curveHandlePoints });
|
||||
const preciseHandlePosition = curveHandlePoints.slice(12, 15);
|
||||
preciseHandlePosition[2] += 0.125;
|
||||
command(engine, handle, { type: "setCurveHandle", dataId: curveId, pointIndex: curve.handlePointIndices[2], side: "LEFT", position: preciseHandlePosition });
|
||||
|
||||
69
tools/web/check-physics-main-reader.mjs
Normal file
69
tools/web/check-physics-main-reader.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
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/modifier_physics_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 output(engine, handle, fn, owned = false) {
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
try {
|
||||
assert.equal(fn(handle, dataOut, lengthOut), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const length = engine.HEAPU32[lengthOut >>> 2];
|
||||
const bytes = engine.HEAPU8.slice(pointer, pointer + length);
|
||||
if (owned) engine._web_engine_free_buffer(pointer);
|
||||
return bytes;
|
||||
}
|
||||
finally { engine._free(dataOut); engine._free(lengthOut); }
|
||||
}
|
||||
|
||||
function snapshot(engine, handle) {
|
||||
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
|
||||
}
|
||||
|
||||
function assertManifest(scene) {
|
||||
const manifest = scene.physicsSimulation;
|
||||
assert.equal(manifest?.schemaVersion, 1);
|
||||
assert.deepEqual(manifest.systems.map((system) => system.family).sort(), ["CLOTH", "SOFT_BODY"]);
|
||||
for (const system of manifest.systems) {
|
||||
assert.match(system.id, /^physics:/);
|
||||
assert.match(system.ownerObjectId, /^object:/);
|
||||
assert.deepEqual(system.settingsHash, crypto.createHash("sha256").update(JSON.stringify(system.settings)).digest("hex"));
|
||||
assert.equal(system.cache, undefined);
|
||||
}
|
||||
const collisionId = "object:PhysicsCollisionDisabled";
|
||||
assert.ok(manifest.systems.every((system) => system.dependencyIds.includes(collisionId)));
|
||||
const cloth = manifest.systems.find((system) => system.family === "CLOTH");
|
||||
const soft = manifest.systems.find((system) => system.family === "SOFT_BODY");
|
||||
assert.equal(cloth.settings.name, "Cloth Disabled");
|
||||
assert.equal(cloth.settings.enabled, false);
|
||||
assert.equal(soft.ownerObjectId, "object:PhysicsSoftBodyDisabled");
|
||||
assert.equal(typeof soft.settings.nodeMass, "number");
|
||||
}
|
||||
|
||||
const engine = await factory({ wasmBinary: wasmBinary.slice() });
|
||||
const handle = engine._web_engine_create();
|
||||
open(engine, handle, fixture);
|
||||
assertManifest(snapshot(engine, handle));
|
||||
const saved = output(engine, handle, engine._web_engine_save_blend, true);
|
||||
engine._web_engine_destroy(handle);
|
||||
|
||||
const reopened = engine._web_engine_create();
|
||||
open(engine, reopened, saved);
|
||||
assertManifest(snapshot(engine, reopened));
|
||||
engine._web_engine_destroy(reopened);
|
||||
process.stdout.write("physics-main-reader-ok families=CLOTH,SOFT_BODY dependencies=COLLISION settings-sha256=passed save-reopen=passed playback=BLOCKED solver=BLOCKED\n");
|
||||
23
tools/web/check-vdb-availability.mjs
Normal file
23
tools/web/check-vdb-availability.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(new URL("../..", import.meta.url).pathname);
|
||||
const cachePath = path.join(root, "build_web_blender6", "CMakeCache.txt");
|
||||
const cache = fs.readFileSync(cachePath, "utf8");
|
||||
assert.match(cache, /^WITH_OPENVDB:BOOL=OFF$/m, "OpenVDB must remain disabled until a WASM decoder is linked");
|
||||
assert.match(cache, /^WITH_NANOVDB:BOOL=ON$/m, "NanoVDB metadata support is expected");
|
||||
|
||||
const roots = [path.join(root, "resource-library"), path.join(root, "tests"), "/home/mes123456/resource-library"];
|
||||
const vdbFiles = [];
|
||||
function scan(directory) {
|
||||
if (!fs.existsSync(directory)) return;
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) scan(fullPath);
|
||||
else if (/\.vdb(?:\.gz)?$/i.test(entry.name)) vdbFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
for (const directory of roots) scan(directory);
|
||||
assert.equal(vdbFiles.length, 0, `unexpected local VDB resource(s): ${vdbFiles.join(", ")}`);
|
||||
process.stdout.write("vdb-availability-ok status=BLOCKED openvdb=disabled nanovdb=metadata-only local-vdb=0 renderer=blocked decoder=blocked\n");
|
||||
@@ -13,7 +13,7 @@
|
||||
"id": "web-engine-bootstrap",
|
||||
"fileName": "web_engine.wasm",
|
||||
"url": "/vendor/blender/web_engine.wasm",
|
||||
"sha256": "7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3",
|
||||
"sha256": "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c",
|
||||
"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.
@@ -20,6 +20,7 @@ import { modifierStackHash } from "../../../protocol/modifier";
|
||||
import { exportGLB } from "../../../protocol/glb-export";
|
||||
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export";
|
||||
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
|
||||
import { applyCurveGizmoDelta } from "../../../protocol/nonmesh-interaction";
|
||||
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
|
||||
import "./app-shell.css";
|
||||
|
||||
@@ -179,9 +180,10 @@ function Outliner({ snapshot, onSelect, onToggleVisibility }: {
|
||||
);
|
||||
}
|
||||
|
||||
function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
|
||||
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
|
||||
snapshot: SceneSnapshotIR | null;
|
||||
selectedFaceIndices: number[];
|
||||
selectedVertexIndices: number[];
|
||||
onCommand: (command: WebEngineEditCommand) => void;
|
||||
onImportImage: (file: File) => void;
|
||||
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
|
||||
@@ -211,8 +213,14 @@ function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, o
|
||||
const [materialCoatRoughness, setMaterialCoatRoughness] = useState(0.03);
|
||||
const [materialEmissionStrength, setMaterialEmissionStrength] = useState(1);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [paintColor, setPaintColor] = useState("#cc6633");
|
||||
const [paintWeight, setPaintWeight] = useState(1);
|
||||
const [paintGroup, setPaintGroup] = useState("WebPaint");
|
||||
const [greasePencilLayerId, setGreasePencilLayerId] = useState("");
|
||||
const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer");
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined;
|
||||
const activeGreasePencil = activeNode?.dataId ? snapshot?.greasePencils?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
const activeMaterial = snapshot?.materials.find((material) => material.id === activeMesh?.materialSlotIds?.[0]);
|
||||
useEffect(() => {
|
||||
if (!activeMaterial) return;
|
||||
@@ -227,6 +235,10 @@ function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, o
|
||||
setMaterialEmissionStrength(activeMaterial.emissionStrength ?? 1);
|
||||
}, [activeMaterial]);
|
||||
useEffect(() => setRenameValue(activeNode?.name ?? ""), [activeNode?.id]);
|
||||
useEffect(() => {
|
||||
if (!activeGreasePencil) setGreasePencilLayerId("");
|
||||
else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? "");
|
||||
}, [activeGreasePencil, greasePencilLayerId]);
|
||||
const toggleDelimit = (value: SimplifyDelimit): void => {
|
||||
setDelimit((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value]);
|
||||
};
|
||||
@@ -268,6 +280,8 @@ function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, o
|
||||
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label>名称 <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}>重命名</button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}>应用变换</button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}>原点到几何体</button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">选择...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
|
||||
<div className="property-section"><h3>Viewport Display</h3><label>显示颜色 <span className="swatch" /></label><label>可见性 <input type="checkbox" defaultChecked /></label></div>
|
||||
{activeMesh ? <div className="property-section"><h3>UV Maps</h3><label>活动 UV <select aria-label="活动 UV Map" value={activeMesh.activeUVMap ?? ""} onChange={(event) => event.target.value && onCommand({ type: "setActiveUVMap", meshId: activeMesh.id, name: event.target.value })}><option value="">None</option>{activeMesh.uvLayers?.map((layer) => <option key={layer.name} value={layer.name}>{layer.name}</option>)}</select></label><div className="property-actions"><button type="button" onClick={() => onCommand({ type: "createUVMap", meshId: activeMesh.id, name: `UVMap.${(activeMesh.uvLayers?.length ?? 0) + 1}` })}>新增 UV</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "PLANAR" })}>Planar</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "CUBE" })}>Cube</button></div></div> : null}
|
||||
{activeGreasePencil ? <div className="property-section" data-testid="grease-pencil-editor"><h3>Grease Pencil</h3><label>Layer <select aria-label="Grease Pencil layer" value={greasePencilLayerId} onChange={(event) => setGreasePencilLayerId(event.target.value)}>{activeGreasePencil.layers.map((layer) => <option key={layer.id} value={layer.id}>{layer.name}</option>)}</select></label><label>New layer <input aria-label="Grease Pencil new layer name" value={greasePencilLayerName} onChange={(event) => setGreasePencilLayerName(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand({ type: "createGreasePencilLayer", dataId: activeGreasePencil.id, name: greasePencilLayerName })}>Add Layer</button><button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand({ type: "removeGreasePencilLayer", dataId: activeGreasePencil.id, layerId: greasePencilLayerId })}>Remove Layer</button><button type="button" disabled={!greasePencilLayerId} onClick={() => onCommand({ type: "insertGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Add Frame</button><button type="button" disabled={!activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.some((entry) => entry.frame === (snapshot?.frame.current ?? 1))} onClick={() => onCommand({ type: "removeGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Remove Frame</button><button type="button" disabled={!activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.some((entry) => entry.frame === (snapshot?.frame.current ?? 1))} onClick={() => onCommand({ type: "setGreasePencilStrokes", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1, strokes: [] })}>Clear Drawing</button></div><output>{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes</output></div> : null}
|
||||
{activeMesh && activeNode ? <div className="property-section" data-testid="paint-editor"><h3>Paint</h3><label>Vertex color <input aria-label="Paint vertex color" type="color" value={paintColor} onChange={(event) => setPaintColor(event.target.value)} /></label><label>Vertex group <input aria-label="Paint vertex group" value={paintGroup} onChange={(event) => setPaintGroup(event.target.value)} /></label><label>Weight <input aria-label="Paint vertex weight" type="range" min="0" max="1" step="0.01" value={paintWeight} onChange={(event) => setPaintWeight(Number(event.target.value))} /><output>{paintWeight.toFixed(2)}</output></label><div className="property-actions"><button type="button" disabled={selectedVertexIndices.length === 0} onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: selectedVertexIndices, colors: selectedVertexIndices.flatMap(() => [...rgb, 1]) }); }}>Apply Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup} onClick={() => onCommand({ type: "setVertexWeights", objectId: activeNode.id, vertexGroup: paintGroup, indices: selectedVertexIndices, values: selectedVertexIndices.map(() => paintWeight), normalize: true })}>Apply Weight</button></div><output>{selectedVertexIndices.length} selected vertices</output></div> : null}
|
||||
{activeMesh ? <div className="property-section">
|
||||
<h3>Material Slots</h3>
|
||||
<div className="material-slots">{activeMesh.materialSlotIds?.map((id, index) => <span key={`${id}:${index}`}>{index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"}</span>)}</div>
|
||||
@@ -556,22 +570,40 @@ export function App() {
|
||||
};
|
||||
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0): void => {
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
if (!activeNode) return;
|
||||
if (!snapshot || !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 && 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);
|
||||
const handles: Array<{ pointIndex: number; side: "LEFT" | "RIGHT"; position: [number, number, number] }> = [];
|
||||
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;
|
||||
handles.push({ pointIndex, side: kind === "HANDLE_RIGHT" ? "RIGHT" : "LEFT", position: handlePoints.slice(packedPointIndex * 6 + sideOffset, packedPointIndex * 6 + sideOffset + 3) as [number, number, number] });
|
||||
}
|
||||
}
|
||||
void applyEditCommand({ type: "setCurveTopology", dataId: nonMesh.id, splineTypes: nonMesh.splineTypes, cyclicU: nonMesh.cyclicU, cyclicV: nonMesh.cyclicV, handleTypes: nonMesh.handleTypes, handlePoints });
|
||||
if (handles.length === 0) return;
|
||||
let applied;
|
||||
try {
|
||||
const delta: [number, number, number] = [0, 0, 0];
|
||||
delta[axis] = amount;
|
||||
applied = applyCurveGizmoDelta({ schemaVersion: 1, dataId: nonMesh.id, baseRevision: snapshot.revision, phase: "COMMIT", axis, delta, handles }, snapshot.revision);
|
||||
}
|
||||
catch (error) {
|
||||
setEngineStatus(`Curve gizmo rejected${error instanceof Error ? ` (${error.message})` : ""}`);
|
||||
return;
|
||||
}
|
||||
for (const handle of applied.handles) {
|
||||
const packedPointIndex = pointIndices.indexOf(handle.pointIndex);
|
||||
if (packedPointIndex < 0) continue;
|
||||
const sideOffset = handle.side === "RIGHT" ? 3 : 0;
|
||||
handlePoints.splice(packedPointIndex * 6 + sideOffset, 3, ...handle.position);
|
||||
}
|
||||
void applyEditCommand({ type: "setCurveTopology", dataId: nonMesh.id, baseRevision: snapshot.revision, splineTypes: nonMesh.splineTypes, cyclicU: nonMesh.cyclicU, cyclicV: nonMesh.cyclicV, handleTypes: nonMesh.handleTypes, handlePoints });
|
||||
return;
|
||||
}
|
||||
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode.dataId);
|
||||
@@ -970,7 +1002,7 @@ export function App() {
|
||||
<div className="workspace-grid">
|
||||
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onTransform={transformActive} /></Area>
|
||||
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
|
||||
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
|
||||
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
|
||||
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
|
||||
</div>
|
||||
{uiState.operatorSearchOpen ? <OperatorSearch onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
|
||||
|
||||
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.
15
web/app/src/workers/grease-pencil-editor-test.worker.ts
Normal file
15
web/app/src/workers/grease-pencil-editor-test.worker.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { applyGreasePencilEditorEdit, parseGreasePencilEditor } from "../../../protocol/grease-pencil-editor";
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
const base = { schemaVersion: 1, revision: 0, dataId: "grease-pencil:Data", layerId: "layer:Lines", frame: 1, onionSkinning: true, selectedStrokeIndices: [], selectedPoints: [] };
|
||||
try {
|
||||
const frame = applyGreasePencilEditorEdit(base, { type: "SET_FRAME", revision: 0, frame: 5 });
|
||||
const selection = applyGreasePencilEditorEdit(frame, { type: "SET_SELECTION", revision: 1, strokeIndices: [2, 0], points: [{ strokeIndex: 2, pointIndex: 1 }] });
|
||||
result.edit = [selection.revision, selection.frame, selection.selectedStrokeIndices, selection.selectedPoints];
|
||||
} catch (error) { result.edit = error instanceof Error ? error.message : String(error); }
|
||||
try { applyGreasePencilEditorEdit(base, { type: "SET_LAYER", revision: 7, layerId: "layer:Other" }); } catch (error) { result.stale = error instanceof Error ? error.message : String(error); }
|
||||
try { parseGreasePencilEditor({ ...base, selectedPoints: [{ strokeIndex: 1, pointIndex: 1 }, { strokeIndex: 1, pointIndex: 1 }] }); } catch (error) { result.duplicate = error instanceof Error ? error.message : String(error); }
|
||||
try { parseGreasePencilEditor({ ...base, selectedStrokeIndices: [-1] }); } catch (error) { result.negative = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
11
web/app/src/workers/nonmesh-interaction-test.worker.ts
Normal file
11
web/app/src/workers/nonmesh-interaction-test.worker.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { applyCurveGizmoDelta } from "../../../protocol/nonmesh-interaction";
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
const base = { schemaVersion: 1, dataId: "curve:1", baseRevision: 3, phase: "COMMIT", axis: 0, delta: [0.25, 0, 0], handles: [{ pointIndex: 2, side: "LEFT", position: [1, 2, 3] }] };
|
||||
try { const applied = applyCurveGizmoDelta(base, 3); result.commit = [applied.revision, applied.handles[0].position]; } catch (error) { result.commit = error instanceof Error ? error.message : String(error); }
|
||||
try { applyCurveGizmoDelta(base, 4); } catch (error) { result.stale = error instanceof Error ? error.message : String(error); }
|
||||
try { applyCurveGizmoDelta({ ...base, handles: [{ ...base.handles[0] }, { ...base.handles[0] }] }, 3); } catch (error) { result.duplicate = error instanceof Error ? error.message : String(error); }
|
||||
try { applyCurveGizmoDelta({ ...base, delta: [0.25, 0.25, 0] }, 3); } catch (error) { result.axis = error instanceof Error ? error.message : String(error); }
|
||||
self.postMessage(result);
|
||||
};
|
||||
@@ -223,6 +223,7 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
case "setCurveTopology": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || (data.type !== "CURVE" && data.type !== "SURFACE")) throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 curve data block is unavailable: ${payload.dataId}`);
|
||||
if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Curve topology base revision does not match the current SceneIR");
|
||||
const splineCount = data.splineCount;
|
||||
if (payload.splineTypes !== undefined && (payload.splineTypes.length !== splineCount || payload.splineTypes.some((value) => !["POLY", "BEZIER", "NURBS"].includes(value)))) throw report("NON_MESH_PROPERTY_INVALID", "Curve spline types must match the existing topology");
|
||||
for (const values of [payload.cyclicU, payload.cyclicV]) if (values !== undefined && values.length !== splineCount) throw report("NON_MESH_PROPERTY_INVALID", "Curve cyclic flags must match the spline count");
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test:e2e": "playwright test --config playwright.config.ts",
|
||||
"test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"",
|
||||
"test:simulation-cache": "playwright test --config playwright.config.ts -g \"Simulation caches\"",
|
||||
"test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs",
|
||||
"test:browser": "playwright test --config playwright.release.config.ts",
|
||||
"test:cross-browser": "npm run test:browser",
|
||||
"test:golden": "node ../tools/web/run-blender-golden.mjs",
|
||||
@@ -27,7 +28,10 @@
|
||||
"test:nonmesh-roundtrip": "node ../tools/web/check-nonmesh-roundtrip.mjs",
|
||||
"test:nonmesh-desktop-golden": "node ../tools/web/check-nonmesh-desktop-golden.mjs",
|
||||
"test:selection-history": "playwright test --config playwright.config.ts -g \"N-015 selection history\"",
|
||||
"test:nonmesh-interaction": "playwright test --config playwright.config.ts -g \"N-015 curve gizmo interaction\"",
|
||||
"test:vdb-availability": "node ../tools/web/check-vdb-availability.mjs",
|
||||
"test:grease-pencil": "node ../tools/web/check-grease-pencil-roundtrip.mjs",
|
||||
"test:grease-pencil-editor": "playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"",
|
||||
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",
|
||||
"test:lighting-roundtrip": "node ../tools/web/check-lighting-roundtrip.mjs",
|
||||
"test:compositor-main-reader": "node ../tools/web/check-compositor-main-reader.mjs",
|
||||
|
||||
92
web/protocol/grease-pencil-editor.ts
Normal file
92
web/protocol/grease-pencil-editor.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export const GREASE_PENCIL_EDITOR_SCHEMA = 1 as const;
|
||||
export const GREASE_PENCIL_EDITOR_BUDGET = { maxSelection: 1_000_000, maxIdLength: 256 } as const;
|
||||
|
||||
export interface GreasePencilPointSelectionIR { strokeIndex: number; pointIndex: number }
|
||||
export interface GreasePencilEditorIR {
|
||||
schemaVersion: typeof GREASE_PENCIL_EDITOR_SCHEMA;
|
||||
revision: number;
|
||||
dataId: string;
|
||||
layerId: string;
|
||||
frame: number;
|
||||
onionSkinning: boolean;
|
||||
selectedStrokeIndices: number[];
|
||||
selectedPoints: GreasePencilPointSelectionIR[];
|
||||
}
|
||||
|
||||
export type GreasePencilEditorEditIR =
|
||||
| { type: "SET_FRAME"; revision: number; frame: number }
|
||||
| { type: "SET_LAYER"; revision: number; layerId: string }
|
||||
| { type: "SET_ONION"; revision: number; enabled: boolean }
|
||||
| { type: "SET_SELECTION"; revision: number; strokeIndices: number[]; points: GreasePencilPointSelectionIR[] };
|
||||
|
||||
function fail(path: string, message: string): never { throw new Error(`GREASE_PENCIL_EDITOR_INVALID: ${path} ${message}`); }
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function id(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxIdLength) fail(path, "is invalid");
|
||||
return value;
|
||||
}
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) fail(path, "is outside the supported frame/index range");
|
||||
return value;
|
||||
}
|
||||
function nonnegativeIndex(value: unknown, path: string): number {
|
||||
const result = integer(value, path);
|
||||
if (result < 0) fail(path, "must be non-negative");
|
||||
return result;
|
||||
}
|
||||
function uniqueIndices(value: unknown, path: string): number[] {
|
||||
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail(path, "exceeds the selection budget");
|
||||
const result = value.map((item, itemIndex) => nonnegativeIndex(item, `${path}[${itemIndex}]`));
|
||||
if (new Set(result).size !== result.length) fail(path, "contains duplicates");
|
||||
return result.sort((left, right) => left - right);
|
||||
}
|
||||
function points(value: unknown, path: string): GreasePencilPointSelectionIR[] {
|
||||
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail(path, "exceeds the selection budget");
|
||||
const seen = new Set<string>();
|
||||
const result = value.map((item, index) => {
|
||||
const point = record(item, `${path}[${index}]`);
|
||||
const parsed = { strokeIndex: nonnegativeIndex(point.strokeIndex, `${path}[${index}].strokeIndex`), pointIndex: nonnegativeIndex(point.pointIndex, `${path}[${index}].pointIndex`) };
|
||||
const key = `${parsed.strokeIndex}:${parsed.pointIndex}`;
|
||||
if (seen.has(key)) fail(`${path}[${index}]`, "contains duplicates");
|
||||
seen.add(key);
|
||||
return parsed;
|
||||
});
|
||||
return result.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex);
|
||||
}
|
||||
|
||||
export function parseGreasePencilEditor(value: unknown): GreasePencilEditorIR {
|
||||
const editor = record(value, "editor");
|
||||
if (editor.schemaVersion !== GREASE_PENCIL_EDITOR_SCHEMA) fail("schemaVersion", "is unsupported");
|
||||
if (typeof editor.revision !== "number" || !Number.isSafeInteger(editor.revision) || editor.revision < 0) fail("revision", "is invalid");
|
||||
if (typeof editor.frame !== "number" || !Number.isSafeInteger(editor.frame) || editor.frame < -1_000_000 || editor.frame > 1_000_000) fail("frame", "is invalid");
|
||||
if (typeof editor.onionSkinning !== "boolean") fail("onionSkinning", "must be boolean");
|
||||
return {
|
||||
schemaVersion: GREASE_PENCIL_EDITOR_SCHEMA,
|
||||
revision: editor.revision,
|
||||
dataId: id(editor.dataId, "dataId"),
|
||||
layerId: id(editor.layerId, "layerId"),
|
||||
frame: editor.frame,
|
||||
onionSkinning: editor.onionSkinning,
|
||||
selectedStrokeIndices: uniqueIndices(editor.selectedStrokeIndices, "selectedStrokeIndices"),
|
||||
selectedPoints: points(editor.selectedPoints, "selectedPoints"),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyGreasePencilEditorEdit(value: unknown, editValue: unknown): GreasePencilEditorIR {
|
||||
const editor = parseGreasePencilEditor(value);
|
||||
const edit = record(editValue, "edit");
|
||||
if (edit.revision !== editor.revision) throw new Error("REVISION_CONFLICT: Grease Pencil editor state is stale");
|
||||
const revision = editor.revision + 1;
|
||||
switch (edit.type) {
|
||||
case "SET_FRAME": return parseGreasePencilEditor({ ...editor, revision, frame: integer(edit.frame, "edit.frame") });
|
||||
case "SET_LAYER": return parseGreasePencilEditor({ ...editor, revision, layerId: id(edit.layerId, "edit.layerId") });
|
||||
case "SET_ONION":
|
||||
if (typeof edit.enabled !== "boolean") fail("edit.enabled", "must be boolean");
|
||||
return parseGreasePencilEditor({ ...editor, revision, onionSkinning: edit.enabled });
|
||||
case "SET_SELECTION": return parseGreasePencilEditor({ ...editor, revision, selectedStrokeIndices: uniqueIndices(edit.strokeIndices, "edit.strokeIndices"), selectedPoints: points(edit.points, "edit.points") });
|
||||
default: fail("edit.type", "is unsupported");
|
||||
}
|
||||
}
|
||||
93
web/protocol/nonmesh-interaction.ts
Normal file
93
web/protocol/nonmesh-interaction.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export const CURVE_GIZMO_SCHEMA = 1 as const;
|
||||
|
||||
export const CURVE_GIZMO_BUDGET = {
|
||||
maxHandles: 256,
|
||||
maxIdLength: 256,
|
||||
maxCoordinate: 1_000_000,
|
||||
} as const;
|
||||
|
||||
export type CurveGizmoHandleSide = "LEFT" | "RIGHT" | "CONTROL";
|
||||
export type CurveGizmoPhase = "PREVIEW" | "COMMIT";
|
||||
|
||||
export interface CurveGizmoHandleIR {
|
||||
pointIndex: number;
|
||||
side: CurveGizmoHandleSide;
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
export interface CurveGizmoDragIR {
|
||||
schemaVersion: typeof CURVE_GIZMO_SCHEMA;
|
||||
dataId: string;
|
||||
baseRevision: number;
|
||||
phase: CurveGizmoPhase;
|
||||
axis: 0 | 1 | 2;
|
||||
delta: [number, number, number];
|
||||
handles: CurveGizmoHandleIR[];
|
||||
}
|
||||
|
||||
export interface AppliedCurveGizmoDragIR {
|
||||
dataId: string;
|
||||
phase: CurveGizmoPhase;
|
||||
axis: 0 | 1 | 2;
|
||||
revision: number;
|
||||
handles: CurveGizmoHandleIR[];
|
||||
}
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
throw new Error(`CURVE_GIZMO_INVALID: ${path} ${message}`);
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(path, "must be a bounded integer");
|
||||
return value;
|
||||
}
|
||||
|
||||
function finite(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > CURVE_GIZMO_BUDGET.maxCoordinate) fail(path, "is outside the finite coordinate budget");
|
||||
return value;
|
||||
}
|
||||
|
||||
function vector(value: unknown, path: string): [number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 3) fail(path, "must contain three coordinates");
|
||||
return [finite(value[0], `${path}[0]`), finite(value[1], `${path}[1]`), finite(value[2], `${path}[2]` )];
|
||||
}
|
||||
|
||||
export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number): CurveGizmoDragIR {
|
||||
const drag = record(value, "drag");
|
||||
if (drag.schemaVersion !== CURVE_GIZMO_SCHEMA) fail("schemaVersion", "is unsupported");
|
||||
if (typeof drag.dataId !== "string" || drag.dataId.length === 0 || drag.dataId.length > CURVE_GIZMO_BUDGET.maxIdLength) fail("dataId", "is invalid");
|
||||
const baseRevision = integer(drag.baseRevision, "baseRevision");
|
||||
if (expectedRevision !== undefined && baseRevision !== expectedRevision) throw new Error("REVISION_CONFLICT: Curve gizmo request is stale");
|
||||
if (drag.phase !== "PREVIEW" && drag.phase !== "COMMIT") fail("phase", "is invalid");
|
||||
if (drag.axis !== 0 && drag.axis !== 1 && drag.axis !== 2) fail("axis", "must be X, Y or Z");
|
||||
const delta = vector(drag.delta, "delta");
|
||||
if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis");
|
||||
if (!Array.isArray(drag.handles) || drag.handles.length === 0 || drag.handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget");
|
||||
const seen = new Set<string>();
|
||||
const handles = drag.handles.map((item, index) => {
|
||||
const handle = record(item, `handles[${index}]`);
|
||||
const pointIndex = integer(handle.pointIndex, `handles[${index}].pointIndex`);
|
||||
if (handle.side !== "LEFT" && handle.side !== "RIGHT" && handle.side !== "CONTROL") fail(`handles[${index}].side`, "is invalid");
|
||||
const side = handle.side as CurveGizmoHandleSide;
|
||||
const key = `${pointIndex}:${side}`;
|
||||
if (seen.has(key)) fail(`handles[${index}]`, "duplicates a selected handle");
|
||||
seen.add(key);
|
||||
return { pointIndex, side, position: vector(handle.position, `handles[${index}].position`) };
|
||||
});
|
||||
return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, delta, handles };
|
||||
}
|
||||
|
||||
export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number): AppliedCurveGizmoDragIR {
|
||||
const drag = parseCurveGizmoDrag(value, expectedRevision);
|
||||
const handles = drag.handles.map((handle) => ({
|
||||
...handle,
|
||||
position: [handle.position[0] + drag.delta[0], handle.position[1] + drag.delta[1], handle.position[2] + drag.delta[2]] as [number, number, number],
|
||||
}));
|
||||
handles.forEach((handle, index) => vector(handle.position, `handles[${index}].position`));
|
||||
return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking
|
||||
import { normalizeProjectAssetPath } from "./asset-path";
|
||||
import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow";
|
||||
import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform";
|
||||
import { parsePhysicsSimulationManifest, type PhysicsSimulationManifestIR } from "./physics-simulation";
|
||||
|
||||
export type SceneNodeType =
|
||||
| "EMPTY"
|
||||
@@ -482,6 +483,7 @@ export interface SceneSnapshotIR {
|
||||
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
|
||||
scriptSources?: ScriptSourceInventoryIR;
|
||||
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
physicsSimulation?: PhysicsSimulationManifestIR;
|
||||
libraries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1132,5 +1134,6 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
parseScriptSourceInventory(value.scriptSources);
|
||||
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
|
||||
}
|
||||
if (value.physicsSimulation !== undefined) parsePhysicsSimulationManifest(value.physicsSimulation);
|
||||
return value as unknown as SceneSnapshotIR;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export type WebEngineEditCommand =
|
||||
| { type: "deleteNonMeshData"; dataId: string }
|
||||
| { type: "setCurveControlPoints"; dataId: string; controlPoints: number[]; splineOffsets?: number[]; resolution?: number }
|
||||
| { type: "setCurveHandle"; dataId: string; pointIndex: number; side: "LEFT" | "RIGHT"; position: [number, number, number] }
|
||||
| { type: "setCurveTopology"; dataId: string; splineTypes?: NonMeshCurveSplineType[]; cyclicU?: boolean[]; cyclicV?: boolean[]; handleTypes?: number[]; handlePoints?: number[] }
|
||||
| { type: "setCurveTopology"; dataId: string; baseRevision?: number; splineTypes?: NonMeshCurveSplineType[]; cyclicU?: boolean[]; cyclicV?: boolean[]; handleTypes?: number[]; handlePoints?: number[] }
|
||||
| { type: "setCurveSplines"; dataId: string; splineTypes: NonMeshCurveSplineType[]; splineOffsets: number[]; ordersU: number[]; controlPoints: number[]; pointWeights: number[]; cyclicU: boolean[]; handleTypes: number[]; handlePoints: number[] }
|
||||
| { type: "setSurfaceTopology"; dataId: string; splineDimensions: Array<{ u: number; v: number; orderU: number; orderV: number }>; controlPoints: number[]; pointWeights: number[]; cyclicU: boolean[]; cyclicV: boolean[] }
|
||||
| { type: "setFontBody"; dataId: string; body: string }
|
||||
|
||||
@@ -482,6 +482,35 @@ for (const offscreen of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("validates the N-016 Grease Pencil editor context transaction boundary", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/grease-pencil-editor-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.edit).toEqual([2, 5, [0, 2], [{ strokeIndex: 2, pointIndex: 1 }]]);
|
||||
expect(result.stale).toContain("REVISION_CONFLICT");
|
||||
expect(result.duplicate).toContain("GREASE_PENCIL_EDITOR_INVALID");
|
||||
expect(result.negative).toContain("GREASE_PENCIL_EDITOR_INVALID");
|
||||
});
|
||||
|
||||
test("edits N-016 Grease Pencil layers and frames from the bounded editor panel", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
|
||||
await expect(page.getByText("GreasePencilObject", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
const editor = page.getByTestId("grease-pencil-editor");
|
||||
await expect(editor).toContainText("1 layers / 1 frames / 1 strokes");
|
||||
await editor.getByLabel("Grease Pencil new layer name").fill("Browser Draft");
|
||||
await editor.getByRole("button", { name: "Add Layer" }).click();
|
||||
await expect(editor).toContainText("2 layers / 1 frames / 1 strokes");
|
||||
await editor.getByLabel("Grease Pencil layer").selectOption({ label: "Browser Draft" });
|
||||
await editor.getByRole("button", { name: "Add Frame" }).click();
|
||||
await expect(editor).toContainText("2 layers / 2 frames / 1 strokes");
|
||||
});
|
||||
|
||||
test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, string>>((resolve, reject) => {
|
||||
@@ -523,6 +552,24 @@ test("derives an N-017 source-face, barycentric and UV paint hit from a real ray
|
||||
expect(result?.normal).toEqual([0, 0, 1]);
|
||||
});
|
||||
|
||||
test("commits N-017 vertex color and weight patches from the bounded paint panel", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend);
|
||||
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByText("AttributeMeshObject", { exact: true }).click();
|
||||
await page.getByRole("button", { name: /Object Mode/ }).click();
|
||||
await page.getByRole("button", { name: "1 Vertex" }).click();
|
||||
await page.getByRole("button", { name: "Select All" }).click();
|
||||
const editor = page.getByTestId("paint-editor");
|
||||
await expect(editor).toContainText("5 selected vertices");
|
||||
await editor.getByLabel("Paint vertex color").fill("#336699");
|
||||
await editor.getByRole("button", { name: "Apply Color" }).click();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 });
|
||||
await editor.getByLabel("Paint vertex group").fill("BrowserPaint");
|
||||
await editor.getByRole("button", { name: "Apply Weight" }).click();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 });
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -714,6 +761,20 @@ test("keeps N-015 selection history bounded and rejects stale raycast hits", asy
|
||||
expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]);
|
||||
});
|
||||
|
||||
test("validates the N-015 curve gizmo interaction transaction boundary", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nonmesh-interaction-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.commit).toEqual([4, [1.25, 2, 3]]);
|
||||
expect(result.stale).toContain("REVISION_CONFLICT");
|
||||
expect(result.duplicate).toContain("CURVE_GIZMO_INVALID");
|
||||
expect(result.axis).toContain("CURVE_GIZMO_INVALID");
|
||||
});
|
||||
|
||||
test("validates bounded OpenVDB metadata, SHA and cancellation", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<{ decodedByteLength: number; outsideProject: string; cancelled: boolean }>((resolve, reject) => {
|
||||
|
||||
Reference in New Issue
Block a user