From 3da1dfc8045f3f8b8a4e740c4f5c80db0ac14acd Mon Sep 17 00:00:00 2001 From: mes123456 Date: Wed, 12 Aug 2026 19:31:41 -0400 Subject: [PATCH] Advance bounded editor and cache workflows --- docs/status/N-016.md | 8 +- docs/status/N-017.md | 11 +- docs/status/N-018.md | 7 +- docs/status/N-025.md | 4 + docs/status/parity-ledger.json | 14 +-- docs/status/release-evidence.json | 52 ++++---- tools/web/check-scripting-isolation.mjs | 63 ++++++++++ web/app/src/app/App.tsx | 45 ++++++- .../grease-pencil-editor-test.worker.ts | 13 +- .../src/workers/paint-schema-test.worker.ts | 25 +++- .../workers/physics-simulation-test.worker.ts | 13 ++ web/app/src/workers/storage.worker.ts | 36 +++++- web/app/src/workers/web-engine.worker.ts | 1 + web/package.json | 1 + web/protocol/grease-pencil-editor.ts | 98 ++++++++++++++- web/protocol/paint.ts | 116 ++++++++++++++++++ web/protocol/physics-simulation.ts | 56 +++++++++ web/protocol/web-engine.ts | 2 +- web/tests/e2e/smoke.spec.ts | 48 +++++++- 19 files changed, 563 insertions(+), 50 deletions(-) create mode 100644 tools/web/check-scripting-isolation.mjs diff --git a/docs/status/N-016.md b/docs/status/N-016.md index 3bb406b0..d0c07690 100644 --- a/docs/status/N-016.md +++ b/docs/status/N-016.md @@ -24,12 +24,16 @@ point selection 和 revision,拒绝 stale/重复/超 1M selection;Properties 面板已连接真实 Main layer create/remove、当前 frame insert/remove 和整帧 clear transaction。完整 2D stroke/point 画布选择和 dope sheet integration 仍阻断。 +7. N-016-D(点编辑部分):Properties 面板可在当前 layer/frame 选择实际 stroke/point,执行 + 有界 X 轴平移;协议先校验 point identity、revision、有限坐标和 1M 点预算,再保留 radius、 + opacity、vertex color、cyclic、material index 并合并为一个 `setGreasePencilStrokes` Main + transaction。它是点级整帧原子写回,不等同于完整 2D 画布选择或连续 gizmo。 ## 仍然阻断 - N-016-C:material datablock 事务、onion fade/range 完整语义和完整 Grease Pencil modifier reader。 -- N-016-D:2D editor、stroke/point selection、timeline/dope integration、gizmo 和 worker restart; - 3D current-frame stroke viewport 已完成。 +- N-016-D:2D 画布 raycast/marquee、多点可视选择、timeline/dope integration、连续 gizmo 和 + worker restart;3D current-frame stroke viewport 与 Properties 单点原子平移已完成。 - N-016-E:desktop drawing hash、Chromium 像素 golden、GLB/USD loss report 和 OPFS。 ## 验收 diff --git a/docs/status/N-017.md b/docs/status/N-017.md index 9fa5e344..c3997157 100644 --- a/docs/status/N-017.md +++ b/docs/status/N-017.md @@ -21,13 +21,18 @@ 7. Properties paint 面板消费 viewport 的真实 VERT selection,可向选中顶点提交 `POINT` `WebPaintColor` 和 vertex group weight;每次操作只发一个既有 Main transaction,继续使用 revision、undo/redo、save/reopen 边界。它不是 PBVH brush 或 texture paint。 +8. N-017-A/C(协议边界):有界 CPU brush 对候选顶点执行 smoothstep falloff、遮挡标记过滤和 + front-face 法线门;它不宣称 PBVH 加速或桌面 brush 等价。`UdimTilePatchIR` 约束 1001-1999 + tile、RGBA8、色彩空间、尺寸/256 MiB 预算、revision、base/result SHA-256,并在内存中验证 + 原子 range patch;尚未绑定 Blender packed image/UDIM Main 写回与保存。 ## 仍然阻断 -- N-017-A:PBVH 加速结构、遮挡/背面选择和 brush falloff 对桌面语义的对应;基础 Mesh/UV - raycast hit 已完成。 +- N-017-A:真实 PBVH 加速结构、由深度缓冲生成的遮挡标记,以及 brush falloff 与桌面语义的 + golden 对照;基础 Mesh/UV raycast hit 和有界 CPU 候选 falloff 已完成。 - N-017-B:limit/clean、已验证拓扑映射上的 mirror、桌面 brush/falloff 对照。 -- N-017-C:packed/UDIM tile transaction、色彩空间、dirty tile 和原子保存。 +- N-017-C:Blender packed/UDIM tile Main transaction、dirty tile、色彩转换和原子保存;当前 + 只有内容哈希绑定的浏览器内存 patch 边界。 - N-017-D/E:armature golden、seam bleed、mask/selection、GPU dispose、quota、坏图和 UI。 ## 验收 diff --git a/docs/status/N-018.md b/docs/status/N-018.md index dfad031a..266561c4 100644 --- a/docs/status/N-018.md +++ b/docs/status/N-018.md @@ -20,11 +20,16 @@ cache playback、WASM solver 和 bake job 未实现) Particle/Hair 数据;只输出实际存在的 system,绑定 owner stable ID、有界 settings、collision dependency 和浏览器可复算 SHA-256。当前 fixture 实证覆盖禁用 Cloth、Soft Body 和 Collision 依赖,save/reopen 保持一致;未用合成 system 填充其余 family。 +7. N-018-C(浏览器格式边界):版本化 `BTF1` 二进制帧可解码有界 object transform,校验 + frame、对象 stable ID、精确字节长度、有限 translation、单位 quaternion、非零 scale 和重复 + ID。该格式仅是浏览器自有的 cache playback primitive,不是 Blender desktop bake family + payload,因此 capability inventory 的 `cachePlayback` 仍为 `BLOCKED`。 ## 仍然阻断 - N-018-A/B:从真实 Main 提取完整 family settings、collection/effector/collision 依赖。 -- N-018-C:desktop bake payload 的 family decoder、depsgraph frame playback 和 100 帧 golden。 +- N-018-C:desktop bake payload 的逐 family decoder、与 depsgraph/viewport 的 frame playback + 连接和 100 帧 golden;浏览器自有 transform frame decoder 已完成但不替代 Blender bake。 - N-018-D:逐 family WASM solver 初始化、线程、内存和确定性验证。 - N-018-E:bake start/cancel/commit、服务端 job、故障恢复、进度与 UI。 diff --git a/docs/status/N-025.md b/docs/status/N-025.md index f96fbe9a..72f0a635 100644 --- a/docs/status/N-025.md +++ b/docs/status/N-025.md @@ -16,6 +16,9 @@ 5. N-025-A(Main reader):从 Blender `Text`/`TextLine` 重建最多 1 MiB、65,536 行的完整 UTF-8 内嵌来源,记录项目相对外部路径、`use_module` autorun 请求和 SHA-256;所有来源均为 只读且 `executionStatus=BLOCKED`,autorun 请求按默认拒绝处理,不引入 Python 执行入口。 +6. `test:scripting-isolation` 独立验证未批准 key 返回 `SCRIPT_SIGNATURE_INVALID`,批准 key + 仍返回 `SCRIPT_SANDBOX_UNAVAILABLE`,server job 返回 `SERVER_JOB_UNAVAILABLE`;测试不创建 + 本地执行器,也不把签名校验误报为 sandbox。 ## 仍然阻断 @@ -28,5 +31,6 @@ ```bash npm --prefix web run test:script-main-reader +npm --prefix web run test:scripting-isolation WEB_TEST_PORT=5325 npm --prefix web run test:e2e -- --grep "N-025 script" ``` diff --git a/docs/status/parity-ledger.json b/docs/status/parity-ledger.json index 4cec992b..7a6b7162 100644 --- a/docs/status/parity-ledger.json +++ b/docs/status/parity-ledger.json @@ -19,8 +19,8 @@ "name": "Grease Pencil", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", "D-editor-main-layer-frame-panel-partial"], - "blockedSlices": ["C-material-modifier-full-semantics", "D-full-2d-stroke-point-editor-dope-gizmo-restart", "E-desktop-browser-golden-export-opfs"], + "completedSlices": ["A-schema-budget", "A-main-reader", "B-layer-frame-stroke-transaction-partial", "C-point-radius-opacity-color-cyclic-material-partial", "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", "D-editor-main-layer-frame-panel-partial", "D-single-point-revision-bound-main-translation"], + "blockedSlices": ["C-material-modifier-full-semantics", "D-full-2d-canvas-multipoint-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", "B-selected-vertex-color-weight-ui-transaction-partial"], - "blockedSlices": ["A-pbvh-occlusion-falloff", "B-clean-mirror-desktop-brush", "C-packed-udim-color-dirty-atomic", "D-E-mask-selection-gpu-quota-golden-ui"], + "completedSlices": ["A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", "A-bounded-cpu-candidate-falloff", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", "B-selected-vertex-color-weight-ui-transaction-partial", "C-udim-memory-range-patch-hash-boundary"], + "blockedSlices": ["A-real-pbvh-depth-occlusion-desktop-falloff", "B-clean-mirror-desktop-brush", "C-blender-packed-udim-dirty-atomic-save", "D-E-mask-selection-gpu-quota-golden-ui"], "acceptance": ["web:test:paint-roundtrip", "web:e2e:N-017 paint"], "dependencies": ["N-016"] }, @@ -39,7 +39,7 @@ "name": "Physics and simulation", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-family-capability-inventory", "A-authoritative-main-physics-reader-partial", "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate"], + "completedSlices": ["A-family-capability-inventory", "A-authoritative-main-physics-reader-partial", "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", "C-content-addressed-frame-range-read-hash-gate", "C-browser-transform-frame-binary-decoder"], "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"] @@ -109,9 +109,9 @@ "name": "Scripting and platform", "status": "BLOCKED", "roadmapStatus": "planned", - "completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "C-server-source-hash-job-gate", "D-platform-capability-report"], + "completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "B-approved-key-still-sandbox-blocked-audit", "C-server-source-hash-job-gate", "D-platform-capability-report"], "blockedSlices": ["A", "B", "C", "D", "E"], - "acceptance": ["web:test:script-main-reader", "web:e2e:N-025 script"], + "acceptance": ["web:test:script-main-reader", "web:test:scripting-isolation", "web:e2e:N-025 script"], "dependencies": ["N-024"] }, { diff --git a/docs/status/release-evidence.json b/docs/status/release-evidence.json index 343d1ada..74fa1785 100644 --- a/docs/status/release-evidence.json +++ b/docs/status/release-evidence.json @@ -1,8 +1,8 @@ { "schemaVersion": 3, "source": "docs/status/parity-ledger.json", - "sourceSha256": "086c3882eeabfa9f5bb746aca8da6294bff3415299a91106cc5cb40167907156", - "generatedAt": "2026-08-12T21:11:21.310Z", + "sourceSha256": "bc1de45b23987518f38d629361693bad977a49da69b509ade1bee0aee36e272f", + "generatedAt": "2026-08-12T23:22:09.909Z", "families": [ { "id": "N-015", @@ -86,11 +86,12 @@ "C-bounded-previous-next-onion-preview", "D-current-frame-stroke-preview-main-offscreen-chromium", "D-editor-layer-frame-selection-context-partial", - "D-editor-main-layer-frame-panel-partial" + "D-editor-main-layer-frame-panel-partial", + "D-single-point-revision-bound-main-translation" ], "blockedSlices": [ "C-material-modifier-full-semantics", - "D-full-2d-stroke-point-editor-dope-gizmo-restart", + "D-full-2d-canvas-multipoint-dope-gizmo-restart", "E-desktop-browser-golden-export-opfs" ], "acceptance": [ @@ -110,14 +111,16 @@ "completedSlices": [ "A-stroke-hit-weight-schema-budget-partial", "A-three-raycast-source-face-barycentric-uv", + "A-bounded-cpu-candidate-falloff", "B-main-vertex-color-partial", "B-main-vertex-weight-normalize-partial", - "B-selected-vertex-color-weight-ui-transaction-partial" + "B-selected-vertex-color-weight-ui-transaction-partial", + "C-udim-memory-range-patch-hash-boundary" ], "blockedSlices": [ - "A-pbvh-occlusion-falloff", + "A-real-pbvh-depth-occlusion-desktop-falloff", "B-clean-mirror-desktop-brush", - "C-packed-udim-color-dirty-atomic", + "C-blender-packed-udim-dirty-atomic-save", "D-E-mask-selection-gpu-quota-golden-ui" ], "acceptance": [ @@ -139,7 +142,8 @@ "B-settings-dependency-cache-manifest-partial", "B-cloth-softbody-collision-settings-sha256-save-reopen", "C-exact-frame-selection-gate", - "C-content-addressed-frame-range-read-hash-gate" + "C-content-addressed-frame-range-read-hash-gate", + "C-browser-transform-frame-binary-decoder" ], "blockedSlices": [ "A-B-full-family-settings-effectors-cache-binding", @@ -328,6 +332,7 @@ "A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", + "B-approved-key-still-sandbox-blocked-audit", "C-server-source-hash-job-gate", "D-platform-capability-report" ], @@ -340,6 +345,7 @@ ], "acceptance": [ "web:test:script-main-reader", + "web:test:scripting-isolation", "web:e2e:N-025 script" ], "dependencies": [ @@ -435,7 +441,7 @@ ], "command": "npm --prefix web run release:sbom", "exitCode": 0, - "durationMs": 352, + "durationMs": 361, "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", @@ -453,8 +459,8 @@ ], "command": "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser", "exitCode": 0, - "durationMs": 137892, - "output": "D profiles at the protocol boundary (1.0s)\n ✓ 71 tests/e2e/smoke.spec.ts:1826:1 › normalizes skin influences and rejects shape-key loss explicitly (1.0s)\n ✓ 72 tests/e2e/smoke.spec.ts:1851:1 › remaps skin weights and shape keys through the native Collapse worker path (1.3s)\n ✓ 73 tests/e2e/smoke.spec.ts:1907:1 › reports GLB export blockers before any binary export (1.0s)\n ✓ 74 tests/e2e/smoke.spec.ts:1919:1 › blocks Shader graphs that cannot be mapped to glTF PBR (978ms)\n ✓ 75 tests/e2e/smoke.spec.ts:1946:1 › maps bounded RGB and Value Shader constants to glTF PBR factors (981ms)\n ✓ 76 tests/e2e/smoke.spec.ts:1979:1 › exports a local SceneIR mesh as a standards-shaped GLB (992ms)\n ✓ 77 tests/e2e/smoke.spec.ts:2012:1 › keeps per-vertex UV and color attributes in GLB output (978ms)\n ✓ 78 tests/e2e/smoke.spec.ts:2039:1 › evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2054:1 › embeds local textures and exports glTF skin and animation records (999ms)\n ✓ 80 tests/e2e/smoke.spec.ts:2104:1 › reports lightweight budget violations without altering usage (1.0s)\n ✓ 81 tests/e2e/smoke.spec.ts:2122:1 › aggregates project, collection, object and LOD budgets without double counting LOD (979ms)\n ✓ 82 tests/e2e/smoke.spec.ts:2148:1 › round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 83 tests/e2e/smoke.spec.ts:2172:1 › blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 84 tests/e2e/smoke.spec.ts:2215:1 › extracts Blender packed image bytes through the local asset request API (1.3s)\n ✓ 85 tests/e2e/smoke.spec.ts:2260:1 › matches Blender Depsgraph deformation golden within the declared error budget (1.3s)\n ✓ 86 tests/e2e/smoke.spec.ts:2299:1 › evaluates the full Blender Depsgraph or reports its safe capability gate (1.4s)\n ✓ 87 tests/e2e/smoke.spec.ts:2359:1 › exports layered Action keyframes through SceneIR (1.3s)\n ✓ 88 tests/e2e/smoke.spec.ts:2400:1 › patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 89 tests/e2e/smoke.spec.ts:2424:1 › renders through the capability-gated OffscreenCanvas worker (1.3s)\n ✓ 90 tests/e2e/smoke.spec.ts:2437:1 › coalesces linked mesh objects into a raycastable instance group (1.9s)\n ✓ 91 tests/e2e/smoke.spec.ts:2445:1 › returns structured gates for the undeclared capability protocols (1.2s)\n ✓ 92 tests/e2e/smoke.spec.ts:2504:1 › exposes PBR-007 to PBR-012 renderer security gates (1.0s)\n ✓ 93 tests/e2e/smoke.spec.ts:2538:1 › transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 94 tests/e2e/smoke.spec.ts:2547:1 › uses the same packed texture payload in the OffscreenCanvas renderer (1.5s)\n\n 94 passed (2.2m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] › tests/e2e/cross-browser.spec.ts:6:1 › boots the offline engine, renders SceneIR and performs a Main edit (2.6s)\n ✓ 2 [chromium] › tests/e2e/cross-browser.spec.ts:32:1 › keeps content-addressed asset recovery available in Chromium (1.1s)\n ✓ 3 [chromium] › tests/e2e/cross-browser.spec.ts:51:1 › keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.5s)\n\n[WebServer] (node:1291271) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1291283) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1295654) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1295666) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "durationMs": 147877, + "output": "and LOD profiles at the protocol boundary (1.1s)\n ✓ 73 tests/e2e/smoke.spec.ts:1866:1 › normalizes skin influences and rejects shape-key loss explicitly (1.1s)\n ✓ 74 tests/e2e/smoke.spec.ts:1891:1 › remaps skin weights and shape keys through the native Collapse worker path (1.4s)\n ✓ 75 tests/e2e/smoke.spec.ts:1947:1 › reports GLB export blockers before any binary export (1.1s)\n ✓ 76 tests/e2e/smoke.spec.ts:1959:1 › blocks Shader graphs that cannot be mapped to glTF PBR (1.0s)\n ✓ 77 tests/e2e/smoke.spec.ts:1986:1 › maps bounded RGB and Value Shader constants to glTF PBR factors (1.0s)\n ✓ 78 tests/e2e/smoke.spec.ts:2019:1 › exports a local SceneIR mesh as a standards-shaped GLB (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2052:1 › keeps per-vertex UV and color attributes in GLB output (1.0s)\n ✓ 80 tests/e2e/smoke.spec.ts:2079:1 › evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.1s)\n ✓ 81 tests/e2e/smoke.spec.ts:2094:1 › embeds local textures and exports glTF skin and animation records (1.1s)\n ✓ 82 tests/e2e/smoke.spec.ts:2144:1 › reports lightweight budget violations without altering usage (1.0s)\n ✓ 83 tests/e2e/smoke.spec.ts:2162:1 › aggregates project, collection, object and LOD budgets without double counting LOD (1.0s)\n ✓ 84 tests/e2e/smoke.spec.ts:2188:1 › round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 85 tests/e2e/smoke.spec.ts:2212:1 › blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 86 tests/e2e/smoke.spec.ts:2255:1 › extracts Blender packed image bytes through the local asset request API (1.3s)\n ✓ 87 tests/e2e/smoke.spec.ts:2300:1 › matches Blender Depsgraph deformation golden within the declared error budget (1.4s)\n ✓ 88 tests/e2e/smoke.spec.ts:2339:1 › evaluates the full Blender Depsgraph or reports its safe capability gate (1.4s)\n ✓ 89 tests/e2e/smoke.spec.ts:2399:1 › exports layered Action keyframes through SceneIR (1.3s)\n ✓ 90 tests/e2e/smoke.spec.ts:2440:1 › patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 91 tests/e2e/smoke.spec.ts:2464:1 › renders through the capability-gated OffscreenCanvas worker (1.3s)\n ✓ 92 tests/e2e/smoke.spec.ts:2477:1 › coalesces linked mesh objects into a raycastable instance group (1.9s)\n ✓ 93 tests/e2e/smoke.spec.ts:2485:1 › returns structured gates for the undeclared capability protocols (1.2s)\n ✓ 94 tests/e2e/smoke.spec.ts:2544:1 › exposes PBR-007 to PBR-012 renderer security gates (1.1s)\n ✓ 95 tests/e2e/smoke.spec.ts:2578:1 › transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 96 tests/e2e/smoke.spec.ts:2587:1 › uses the same packed texture payload in the OffscreenCanvas renderer (1.9s)\n\n 96 passed (2.3m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] › tests/e2e/cross-browser.spec.ts:6:1 › boots the offline engine, renders SceneIR and performs a Main edit (2.8s)\n ✓ 2 [chromium] › tests/e2e/cross-browser.spec.ts:32:1 › keeps content-addressed asset recovery available in Chromium (1.2s)\n ✓ 3 [chromium] › tests/e2e/cross-browser.spec.ts:51:1 › keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.7s)\n\n[WebServer] (node:1350053) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1350065) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1355845) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1355857) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", "artifactSha256": [ "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c" ] @@ -466,8 +472,8 @@ ], "command": "npm --prefix web run test:release-performance", "exitCode": 0, - "durationMs": 86656, - "output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"outputTriangles\":89999,\"elapsedMs\":85598,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":519,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.2532260000007227 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.07346400000096764 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.06714100000681356 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 0.033462000006693415 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.5294449999928474 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4542700000019977 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.41858699999284 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.3971560000063619 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.3646670000016456 msecs. Success: true", + "durationMs": 89206, + "output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"outputTriangles\":89999,\"elapsedMs\":88134,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":535,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.2166599999909522 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.10756300001230557 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.04437800000596326 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 1.4113389999984065 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.4725370000087423 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4998959999938961 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.49847900000168 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.4405399999959627 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.4891829999978654 msecs. Success: true", "artifactSha256": [ "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c" ] @@ -479,7 +485,7 @@ ], "command": "npm --prefix web run test:malicious-blends", "exitCode": 0, - "durationMs": 509, + "durationMs": 561, "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" @@ -492,8 +498,8 @@ ], "command": "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library", "exitCode": 0, - "durationMs": 3993, - "output": "> blender-web-editor@0.1.0 test:asset-library\n> playwright test --config playwright.config.ts -g \"N-023 asset\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:672:1 › validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.2s)\n\n[WebServer] (node:1296131) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1296143) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", + "durationMs": 4132, + "output": "> blender-web-editor@0.1.0 test:asset-library\n> playwright test --config playwright.config.ts -g \"N-023 asset\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:711:1 › validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.3s)\n\n[WebServer] (node:1356360) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1356372) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)", "artifactSha256": [ "6169748c5bf78a8e101196e964db67b8a65c2ba95bb3b4a54e052721be01a7bf" ] @@ -503,8 +509,8 @@ "fields": [], "command": "npm --prefix web run test:release-package", "exitCode": 0, - "durationMs": 4738, - "output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 44 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-awSIju8U.js 242.93 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CALwrj_3.js 884.99 kB │ gzip: 238.44 kB\n\n✓ built in 598ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=31928384\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", + "durationMs": 4871, + "output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 45 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-hjSYzFAQ.js 36.77 kB\ndist/assets/web-engine.worker-BpNH8d2z.js 243.09 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-B3UcZYVN.js 890.91 kB │ gzip: 239.96 kB\n\n✓ built in 561ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=31935334\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", "artifactSha256": [ "8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30", "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c" @@ -518,12 +524,12 @@ ], "command": "npm --prefix web run release:offline", "exitCode": 0, - "durationMs": 35842, - "output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 44 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-awSIju8U.js 242.93 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CALwrj_3.js 884.99 kB │ gzip: 238.44 kB\n\n✓ built in 582ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7537432 source=205687508 sha256=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7537432 source=205687508 sha256=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac\noffline-reproducibility-ok binary=b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac source=8fd6799861efbee5d8dcc1702adc97ff5612f5da646776f727bc97573db13585\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", + "durationMs": 36624, + "output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 45 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-hjSYzFAQ.js 36.77 kB\ndist/assets/web-engine.worker-BpNH8d2z.js 243.09 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-sUS61MK9.wasm 15,032.79 kB │ gzip: 3,557.59 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-B3UcZYVN.js 890.91 kB │ gzip: 239.96 kB\n\n✓ built in 563ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7542083 source=205692822 sha256=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7542083 source=205692822 sha256=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b\noffline-reproducibility-ok binary=2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b source=ee2cbbe62f42abae959bd5e3d1aa4cc5bee491ae23a85fa50b7519dc6a2549b0\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", "artifactSha256": [ - "b94dbeaf0550339918d24201a68642e7f792607461bd707d7434bbe5feeccdac", - "8fd6799861efbee5d8dcc1702adc97ff5612f5da646776f727bc97573db13585", - "46c691c2ec192486527ac5b3b94091f9f0e53c1a2185bf18ec3276001167f948" + "2669cf265c02d463c5ade98bae4d671dfc443f1973b6a9fb77ee29613923287b", + "ee2cbbe62f42abae959bd5e3d1aa4cc5bee491ae23a85fa50b7519dc6a2549b0", + "3d807f874e9703322a69318c856ff493dd7489357f06dc78f3496c7a46a30612" ] } ] diff --git a/tools/web/check-scripting-isolation.mjs b/tools/web/check-scripting-isolation.mjs new file mode 100644 index 00000000..4a0d54ae --- /dev/null +++ b/tools/web/check-scripting-isolation.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "../../web/node_modules/typescript/lib/typescript.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "scripting-isolation-")); +const require = createRequire(import.meta.url); + +try { + for (const name of ["asset-path", "capability-gates", "scripting-platform"]) { + const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8"); + const result = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + fileName: `${name}.ts`, + }); + const output = result.outputText + .replace('require("./asset-path")', 'require("./asset-path.cjs")') + .replace('require("./capability-gates")', 'require("./capability-gates.cjs")'); + fs.writeFileSync(path.join(temporary, `${name}.cjs`), output); + } + + const { gateScriptExecution, gateServerScriptJob } = require(path.join(temporary, "scripting-platform.cjs")); + const digest = "a".repeat(64); + const manifest = { + schemaVersion: 1, + scripts: [{ + id: "clean", + name: "Clean", + entryPath: "scripts/clean.py", + sourceSha256: digest, + publisher: "local", + signature: "b".repeat(128), + keyId: "approved-key", + permissions: ["READ_MAIN"], + dependencies: [], + cpuMs: 1000, + memoryBytes: 64 * 1024 * 1024, + wallMs: 2000, + network: false, + autorun: false, + driverExpressions: false, + addonInstall: false, + }], + }; + + const unsigned = gateScriptExecution(manifest, "clean", new Set()); + const approved = gateScriptExecution(manifest, "clean", new Set(["approved-key"])); + const server = gateServerScriptJob({ scriptId: "clean", sourceSha256: digest }, manifest, digest); + assert.equal(unsigned.status, "BLOCKED"); + assert.equal(unsigned.issues[0]?.code, "SCRIPT_SIGNATURE_INVALID"); + assert.equal(approved.status, "BLOCKED"); + assert.equal(approved.issues[0]?.code, "SCRIPT_SANDBOX_UNAVAILABLE"); + assert.equal(server.status, "BLOCKED"); + assert.equal(server.issues[0]?.code, "SERVER_JOB_UNAVAILABLE"); + process.stdout.write("scripting-isolation-ok status=BLOCKED approved-key=sandbox-unavailable server=unavailable execution=disabled\n"); +} +finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 1e388411..80a37244 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -21,9 +21,19 @@ 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 { applyGreasePencilPointTranslation } from "../../../protocol/grease-pencil-editor"; import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema"; import "./app-shell.css"; +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + const code = "code" in error && typeof error.code === "string" ? `${error.code}: ` : ""; + return `${code}${error.message}`; + } + return String(error); +} + interface AreaProps { className?: string; editor: EditorType; @@ -218,6 +228,9 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo const [paintGroup, setPaintGroup] = useState("WebPaint"); const [greasePencilLayerId, setGreasePencilLayerId] = useState(""); const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer"); + const [greasePencilStrokeIndex, setGreasePencilStrokeIndex] = useState(0); + const [greasePencilPointIndex, setGreasePencilPointIndex] = useState(0); + const [greasePencilPointDeltaX, setGreasePencilPointDeltaX] = useState(0.1); const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId); const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined; const activeGreasePencil = activeNode?.dataId ? snapshot?.greasePencils?.find((data) => data.id === activeNode.dataId) : undefined; @@ -239,6 +252,34 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo if (!activeGreasePencil) setGreasePencilLayerId(""); else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? ""); }, [activeGreasePencil, greasePencilLayerId]); + const activeGreasePencilFrame = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.find((entry) => entry.frame === (snapshot?.frame.current ?? 1)); + const activeGreasePencilStroke = activeGreasePencilFrame?.drawing.strokes[greasePencilStrokeIndex]; + const activeGreasePencilPoint = activeGreasePencilStroke?.points?.[greasePencilPointIndex]; + const translateGreasePencilPoint = (): void => { + if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return; + const result = applyGreasePencilPointTranslation({ + schemaVersion: 1, + revision: snapshot.revision, + dataId: activeGreasePencil.id, + layerId: greasePencilLayerId, + frame: activeGreasePencilFrame.frame, + onionSkinning: activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.onionSkinning ?? false, + selectedStrokeIndices: [greasePencilStrokeIndex], + selectedPoints: [{ strokeIndex: greasePencilStrokeIndex, pointIndex: greasePencilPointIndex }], + }, activeGreasePencilFrame.drawing.strokes, { + type: "TRANSLATE_POINTS", + revision: snapshot.revision, + translation: [greasePencilPointDeltaX, 0, 0], + }); + onCommand({ + type: "setGreasePencilStrokes", + dataId: activeGreasePencil.id, + layerId: greasePencilLayerId, + frame: activeGreasePencilFrame.frame, + baseRevision: snapshot.revision, + strokes: result.strokes, + }); + }; const toggleDelimit = (value: SimplifyDelimit): void => { setDelimit((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value]); }; @@ -280,7 +321,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo {activeNode ?

Object & Hierarchy

: null}

Viewport Display

{activeMesh ?

UV Maps

: null} - {activeGreasePencil ?

Grease Pencil

{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes
: null} + {activeGreasePencil ?

Grease Pencil

{activeGreasePencilFrame ? <>
{activeGreasePencilPoint ? {activeGreasePencilPoint.position.join(", ")} : null} : null}{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes
: null} {activeMesh && activeNode ?

Paint

{selectedVertexIndices.length} selected vertices
: null} {activeMesh ?

Material Slots

@@ -523,7 +564,7 @@ export function App() { } } } catch (error) { - setEngineStatus(`Engine: command failed${error instanceof Error ? ` (${error.message})` : ""}`); + setEngineStatus(`Engine: command failed (${errorMessage(error)})`); } }; diff --git a/web/app/src/workers/grease-pencil-editor-test.worker.ts b/web/app/src/workers/grease-pencil-editor-test.worker.ts index 6200bd5c..bcc81864 100644 --- a/web/app/src/workers/grease-pencil-editor-test.worker.ts +++ b/web/app/src/workers/grease-pencil-editor-test.worker.ts @@ -1,4 +1,4 @@ -import { applyGreasePencilEditorEdit, parseGreasePencilEditor } from "../../../protocol/grease-pencil-editor"; +import { applyGreasePencilEditorEdit, applyGreasePencilPointTranslation, parseGreasePencilEditor } from "../../../protocol/grease-pencil-editor"; self.onmessage = () => { const result: Record = {}; @@ -11,5 +11,16 @@ self.onmessage = () => { try { applyGreasePencilEditorEdit(base, { type: "SET_LAYER", revision: 7, layerId: "layer:Other" }); } catch (error) { result.stale = error instanceof Error ? error.message : String(error); } try { parseGreasePencilEditor({ ...base, selectedPoints: [{ strokeIndex: 1, pointIndex: 1 }, { strokeIndex: 1, pointIndex: 1 }] }); } catch (error) { result.duplicate = error instanceof Error ? error.message : String(error); } try { parseGreasePencilEditor({ ...base, selectedStrokeIndices: [-1] }); } catch (error) { result.negative = error instanceof Error ? error.message : String(error); } + try { + const editor = { ...base, selectedPoints: [{ strokeIndex: 0, pointIndex: 1 }] }; + const translated = applyGreasePencilPointTranslation(editor, [{ cyclic: false, materialIndex: 0, points: [ + { position: [0, 0, 0], radius: 0.1, opacity: 1 }, + { position: [1, 2, 3], radius: 0.2, opacity: 0.8, vertexColor: [1, 0, 0, 1] }, + ] }], { type: "TRANSLATE_POINTS", revision: 0, translation: [0.5, -1, 2] }); + result.translation = [translated.editor.revision, translated.strokes[0].points[0].position, translated.strokes[0].points[1]]; + } catch (error) { result.translation = error instanceof Error ? error.message : String(error); } + try { + applyGreasePencilPointTranslation({ ...base, selectedPoints: [{ strokeIndex: 9, pointIndex: 1 }] }, [{ points: [{ position: [0, 0, 0] }] }], { type: "TRANSLATE_POINTS", revision: 0, translation: [1, 0, 0] }); + } catch (error) { result.missingPoint = error instanceof Error ? error.message : String(error); } self.postMessage(result); }; diff --git a/web/app/src/workers/paint-schema-test.worker.ts b/web/app/src/workers/paint-schema-test.worker.ts index b8c4852b..476e656f 100644 --- a/web/app/src/workers/paint-schema-test.worker.ts +++ b/web/app/src/workers/paint-schema-test.worker.ts @@ -1,4 +1,4 @@ -import { PAINT_BUDGET, parsePaintStroke, parseWeightPatch } from "../../../protocol/paint"; +import { PAINT_BUDGET, applyUdimTilePatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch } from "../../../protocol/paint"; const base = { schemaVersion: 1, @@ -11,12 +11,29 @@ const base = { color: [1, 0.25, 0, 1], }; -self.onmessage = () => { - const result: Record = {}; +self.onmessage = async () => { + const result: Record = {}; try { result.valid = parsePaintStroke(base).mode; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); } try { parsePaintStroke({ ...base, samples: [{ position: [0, 0, 0], barycentric: [0.1, 0.1, 0.1] }] }); } catch (error) { result.hit = error instanceof Error ? error.message : String(error); } try { parsePaintStroke({ ...base, mode: "WEIGHT", vertexGroup: "Group", samples: new Array(PAINT_BUDGET.maxSamples + 1).fill(base.samples[0]) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); } try { result.weight = parseWeightPatch({ schemaVersion: 1, objectId: "object:Paint", revision: 3, vertexGroup: "Group", indices: [0, 1], values: [0.25, 0.75], normalize: true }).vertexGroup; } catch (error) { result.weight = error instanceof Error ? error.message : String(error); } + try { + result.brush = computePaintBrushWeights([ + { index: 2, position: [0, 0, 0], normal: [0, 0, 1] }, + { index: 1, position: [1, 0, 0], normal: [0, 0, 1] }, + { index: 3, position: [0.5, 0, 0], normal: [0, 0, 1], occluded: true }, + { index: 4, position: [3, 0, 0], normal: [0, 0, 1] }, + ], [0, 0, 0], 2, 0.8, { frontFaceOnly: true, viewDirection: [0, 0, -1] }); + } catch (error) { result.brush = error instanceof Error ? error.message : String(error); } + try { + const baseTile = new Uint8Array(16); + const hash = async (bytes: Uint8Array) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer)), (value) => value.toString(16).padStart(2, "0")).join(""); + const expected = baseTile.slice(); expected.set([10, 20, 30, 255], 4); + const patch = { schemaVersion: 1, textureAssetId: "image:Paint", tile: 1001, revision: 2, width: 2, height: 2, format: "RGBA8", colorSpace: "SRGB", baseSha256: await hash(baseTile), resultSha256: await hash(expected), byteOffset: 4, bytes: new Uint8Array([10, 20, 30, 255]) }; + parseUdimTilePatch(patch); + result.udim = Array.from(await applyUdimTilePatch(baseTile, patch, 2)); + try { await applyUdimTilePatch(baseTile, patch, 3); } catch (error) { result.udimRevision = error instanceof Error ? error.message : String(error); } + try { await applyUdimTilePatch(new Uint8Array(16).fill(1), patch, 2); } catch (error) { result.udimStale = error instanceof Error ? error.message : String(error); } + } catch (error) { result.udim = error instanceof Error ? error.message : String(error); } self.postMessage(result); }; - diff --git a/web/app/src/workers/physics-simulation-test.worker.ts b/web/app/src/workers/physics-simulation-test.worker.ts index 3d958da7..b3d576f1 100644 --- a/web/app/src/workers/physics-simulation-test.worker.ts +++ b/web/app/src/workers/physics-simulation-test.worker.ts @@ -1,4 +1,5 @@ import { + decodeBrowserTransformCacheFrame, PHYSICS_FAMILIES, PHYSICS_SIMULATION_BUDGET, gatePhysicsExecution, @@ -61,5 +62,17 @@ self.onmessage = () => { result.solver = gatePhysicsExecution("FLUID", "LOCAL_SOLVER").issues[0]?.code; result.manifest = gatePhysicsExecution("RIGID_BODY", "CACHE_MANIFEST").status; result.familyCount = PHYSICS_FAMILIES.length; + try { + const bytes = new ArrayBuffer(16 + 72); + const view = new DataView(bytes); + view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, 7, true); view.setUint32(12, 1, true); + const id = new TextEncoder().encode("object:Cloth"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id); + [1, 2, 3, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true)); + const decoded = decodeBrowserTransformCacheFrame(bytes); + result.browserPlayback = [decoded.frame, decoded.objects[0].objectId, decoded.objects[0].translation]; + view.setFloat32(48 + 3 * 4, 2, true); + try { decodeBrowserTransformCacheFrame(bytes); } catch (error) { result.browserRotation = error instanceof Error ? error.message : String(error); } + } + catch (error) { result.browserPlayback = error instanceof Error ? error.message : String(error); } self.postMessage(result); }; diff --git a/web/app/src/workers/storage.worker.ts b/web/app/src/workers/storage.worker.ts index b7e3f695..4b3f4da3 100644 --- a/web/app/src/workers/storage.worker.ts +++ b/web/app/src/workers/storage.worker.ts @@ -13,6 +13,10 @@ const scope = self as unknown as { const projectTransactions = new Map>(); let opfsUsable: boolean | undefined; +interface WorkerLockManager { + request(name: string, callback: () => Promise): Promise; +} + async function useOpfsForProject(projectId: string): Promise { if (opfsUsable !== undefined) return opfsUsable; const workerNavigator = (self as unknown as { navigator?: Navigator }).navigator; @@ -33,7 +37,10 @@ async function useOpfsForProject(projectId: string): Promise { async function withProjectTransaction(projectId: string, operation: () => Promise): Promise { projectLayout(projectId); const previous = projectTransactions.get(projectId) ?? Promise.resolve(); - const result = previous.catch(() => undefined).then(operation); + const result = previous.catch(() => undefined).then(() => { + const locks = (self as unknown as { navigator?: { locks?: WorkerLockManager } }).navigator?.locks; + return locks ? locks.request(`blender-web-project:${projectId}`, operation) : operation(); + }); const tail = result.then(() => undefined, () => undefined); projectTransactions.set(projectId, tail); try { @@ -197,6 +204,33 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf const useOpfs = await useOpfsForProject(projectId); const layout = useOpfs ? await ensureProjectLayout(projectId) : projectLayout(projectId); const sha256 = await sha256Hex(buffer); + const existing = faultAt ? undefined : await readProjectRow(projectId); + if (existing && existing.revision > revision) { + throw new Error(`PROJECT_REVISION_CONFLICT: committed revision ${existing.revision} is newer than ${revision}`); + } + if (existing?.revision === revision) { + if (existing.bytes !== buffer.byteLength || existing.sha256 !== sha256) { + throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} already has different content`); + } + if (existing.backend === "opfs" && useOpfs) { + const recovered = await recoverProjectBlend(projectId); + if (!recovered.manifest || recovered.manifest.revision !== revision || recovered.manifest.bytes !== existing.bytes || recovered.manifest.sha256 !== sha256) { + throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} metadata does not match the OPFS commit`); + } + } + else if (existing.backend !== "indexeddb" || useOpfs || !existing.buffer || await sha256Hex(existing.buffer) !== sha256) { + throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} backend does not match the committed project`); + } + return { + projectId, + bytes: existing.bytes, + revision, + persisted: true, + backend: existing.backend, + scenePath: existing.scenePath, + sha256, + }; + } if (useOpfs) { const committed = await writeProjectBlend(projectId, revision, buffer, undefined, faultAt); if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch"); diff --git a/web/app/src/workers/web-engine.worker.ts b/web/app/src/workers/web-engine.worker.ts index 458e90e7..e218729c 100644 --- a/web/app/src/workers/web-engine.worker.ts +++ b/web/app/src/workers/web-engine.worker.ts @@ -329,6 +329,7 @@ function assertFutureCapability(payload: Extract 1_000_000 || !Array.isArray(payload.strokes) || payload.strokes.length > 1_000_000) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil stroke transaction is invalid"); + if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Grease Pencil stroke base revision does not match the current SceneIR"); let points = 0; for (const [strokeIndex, stroke] of payload.strokes.entries()) { if (!stroke || typeof stroke !== "object" || !Array.isArray(stroke.points) || stroke.points.length === 0 || stroke.points.length > 1_000_000 || (stroke.cyclic !== undefined && typeof stroke.cyclic !== "boolean") || (stroke.materialIndex !== undefined && (!Number.isSafeInteger(stroke.materialIndex) || stroke.materialIndex < 0))) throw report("GREASE_PENCIL_SCHEMA_INVALID", `Grease Pencil stroke ${strokeIndex} is invalid`); diff --git a/web/package.json b/web/package.json index c4034b06..e6eed531 100644 --- a/web/package.json +++ b/web/package.json @@ -45,6 +45,7 @@ "test:editor-main-reader": "node ../tools/web/check-editor-main-reader.mjs", "test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"", "test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs", + "test:scripting-isolation": "node ../tools/web/check-scripting-isolation.mjs", "test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"", "test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"", "test:cross-browser-smoke": "npm run test:browser-smoke", diff --git a/web/protocol/grease-pencil-editor.ts b/web/protocol/grease-pencil-editor.ts index 3d06160c..79fd331b 100644 --- a/web/protocol/grease-pencil-editor.ts +++ b/web/protocol/grease-pencil-editor.ts @@ -17,7 +17,26 @@ 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[] }; + | { type: "SET_SELECTION"; revision: number; strokeIndices: number[]; points: GreasePencilPointSelectionIR[] } + | { type: "TRANSLATE_POINTS"; revision: number; translation: [number, number, number] }; + +export interface GreasePencilEditablePointIR { + position: [number, number, number]; + radius?: number; + opacity?: number; + vertexColor?: [number, number, number, number]; +} + +export interface GreasePencilEditableStrokeIR { + cyclic?: boolean; + materialIndex?: number; + points: GreasePencilEditablePointIR[]; +} + +export interface GreasePencilPointEditResultIR { + editor: GreasePencilEditorIR; + strokes: GreasePencilEditableStrokeIR[]; +} function fail(path: string, message: string): never { throw new Error(`GREASE_PENCIL_EDITOR_INVALID: ${path} ${message}`); } function record(value: unknown, path: string): Record { @@ -32,6 +51,14 @@ 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 finite(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 1_000_000) fail(path, "must be finite and bounded"); + return value; +} +function tuple(value: unknown, length: number, path: string): number[] { + if (!Array.isArray(value) || value.length !== length) fail(path, `must contain ${length} numbers`); + return value.map((item, index) => finite(item, `${path}[${index}]`)); +} function nonnegativeIndex(value: unknown, path: string): number { const result = integer(value, path); if (result < 0) fail(path, "must be non-negative"); @@ -87,6 +114,75 @@ export function applyGreasePencilEditorEdit(value: unknown, editValue: unknown): 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") }); + case "TRANSLATE_POINTS": + tuple(edit.translation, 3, "edit.translation"); + if (editor.selectedPoints.length === 0) fail("editor.selectedPoints", "must contain at least one point for translation"); + return parseGreasePencilEditor({ ...editor, revision }); default: fail("edit.type", "is unsupported"); } } + +function parseEditablePoint(value: unknown, path: string): GreasePencilEditablePointIR { + const point = record(value, path); + const result: GreasePencilEditablePointIR = { position: tuple(point.position, 3, `${path}.position`) as [number, number, number] }; + if (point.radius !== undefined) { + result.radius = finite(point.radius, `${path}.radius`); + if (result.radius < 0) fail(`${path}.radius`, "must be non-negative"); + } + if (point.opacity !== undefined) { + result.opacity = finite(point.opacity, `${path}.opacity`); + if (result.opacity < 0 || result.opacity > 1) fail(`${path}.opacity`, "must be in [0,1]"); + } + if (point.vertexColor !== undefined) { + result.vertexColor = tuple(point.vertexColor, 4, `${path}.vertexColor`) as [number, number, number, number]; + if (result.vertexColor.some((component) => component < 0 || component > 1)) fail(`${path}.vertexColor`, "must be in [0,1]"); + } + return result; +} + +function parseEditableStrokes(value: unknown): GreasePencilEditableStrokeIR[] { + if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail("strokes", "exceeds the stroke budget"); + let pointCount = 0; + return value.map((item, strokeIndex) => { + const stroke = record(item, `strokes[${strokeIndex}]`); + if (!Array.isArray(stroke.points) || stroke.points.length === 0) fail(`strokes[${strokeIndex}].points`, "must be a non-empty array"); + pointCount += stroke.points.length; + if (pointCount > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail("strokes", "exceeds the point budget"); + if (stroke.cyclic !== undefined && typeof stroke.cyclic !== "boolean") fail(`strokes[${strokeIndex}].cyclic`, "must be boolean"); + if (stroke.materialIndex !== undefined) nonnegativeIndex(stroke.materialIndex, `strokes[${strokeIndex}].materialIndex`); + return { + ...(stroke.cyclic !== undefined ? { cyclic: stroke.cyclic } : {}), + ...(stroke.materialIndex !== undefined ? { materialIndex: stroke.materialIndex as number } : {}), + points: stroke.points.map((point, pointIndex) => parseEditablePoint(point, `strokes[${strokeIndex}].points[${pointIndex}]`)), + }; + }); +} + +export function applyGreasePencilPointTranslation( + editorValue: unknown, + strokesValue: unknown, + editValue: unknown, +): GreasePencilPointEditResultIR { + const editor = parseGreasePencilEditor(editorValue); + const strokes = parseEditableStrokes(strokesValue); + const edit = record(editValue, "edit"); + if (edit.type !== "TRANSLATE_POINTS") fail("edit.type", "must be TRANSLATE_POINTS"); + if (edit.revision !== editor.revision) throw new Error("REVISION_CONFLICT: Grease Pencil editor state is stale"); + const translation = tuple(edit.translation, 3, "edit.translation") as [number, number, number]; + if (editor.selectedPoints.length === 0) fail("editor.selectedPoints", "must contain at least one point for translation"); + const selected = new Set(editor.selectedPoints.map((point) => `${point.strokeIndex}:${point.pointIndex}`)); + for (const point of editor.selectedPoints) { + if (!strokes[point.strokeIndex]?.points[point.pointIndex]) fail("editor.selectedPoints", `references missing point ${point.strokeIndex}:${point.pointIndex}`); + } + const translated = strokes.map((stroke, strokeIndex) => ({ + ...stroke, + points: stroke.points.map((point, pointIndex) => selected.has(`${strokeIndex}:${pointIndex}`) ? { + ...point, + position: point.position.map((component, axis) => finite(component + translation[axis], `translated[${strokeIndex}][${pointIndex}][${axis}]`)) as [number, number, number], + } : { ...point, position: [...point.position] as [number, number, number] }), + })); + return { + editor: parseGreasePencilEditor({ ...editor, revision: editor.revision + 1 }), + strokes: translated, + }; +} diff --git a/web/protocol/paint.ts b/web/protocol/paint.ts index 7046f06d..ac19ed3d 100644 --- a/web/protocol/paint.ts +++ b/web/protocol/paint.ts @@ -42,6 +42,30 @@ export interface WeightPatchIR { mirror?: boolean; } +export interface PaintBrushVertexIR { + index: number; + position: [number, number, number]; + normal?: [number, number, number]; + occluded?: boolean; +} + +export interface PaintBrushWeightIR { index: number; weight: number } + +export interface UdimTilePatchIR { + schemaVersion: 1; + textureAssetId: string; + tile: number; + revision: number; + width: number; + height: number; + format: "RGBA8"; + colorSpace: "SRGB" | "LINEAR"; + baseSha256: string; + resultSha256: string; + byteOffset: number; + bytes: Uint8Array; +} + function fail(path: string, message: string, budget = false): never { throw new Error(`${budget ? "PAINT_BUDGET_EXCEEDED" : "PAINT_SCHEMA_INVALID"}: ${path} ${message}`); } @@ -73,6 +97,14 @@ function string(value: unknown, path: string, allowEmpty = false): string { return value; } +const SHA256 = /^[a-f0-9]{64}$/; + +function digest(value: unknown, path: string): string { + const result = string(value, path); + if (!SHA256.test(result)) fail(path, "must be a lowercase SHA-256 digest"); + return result; +} + function parseHit(value: unknown, path: string): PaintHitIR { const hit = record(value, path); const position = tuple(hit.position, 3, `${path}.position`) as [number, number, number]; @@ -146,3 +178,87 @@ export function parseWeightPatch(value: unknown): WeightPatchIR { return result; } +export function computePaintBrushWeights( + verticesValue: unknown, + centerValue: unknown, + radiusValue: unknown, + strengthValue: unknown, + options: { ignoreOccluded?: boolean; frontFaceOnly?: boolean; viewDirection?: [number, number, number] } = {}, +): PaintBrushWeightIR[] { + if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true); + const center = tuple(centerValue, 3, "center") as [number, number, number]; + const radius = finite(radiusValue, "radius"); + const strength = finite(strengthValue, "strength"); + if (radius <= 0 || radius > 100_000) fail("radius", "is outside the bounded range"); + if (strength < 0 || strength > 1) fail("strength", "must be in [0,1]"); + const viewDirection = options.viewDirection ?? [0, 0, -1]; + tuple(viewDirection, 3, "viewDirection"); + const result: PaintBrushWeightIR[] = []; + const seen = new Set(); + for (const [vertexIndex, item] of verticesValue.entries()) { + const vertex = record(item, `vertices[${vertexIndex}]`); + const index = integer(vertex.index, `vertices[${vertexIndex}].index`); + if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex"); + seen.add(index); + const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number]; + if (vertex.occluded !== undefined && typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean"); + if (options.ignoreOccluded !== false && vertex.occluded === true) continue; + if (vertex.normal !== undefined) { + const normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number]; + if (options.frontFaceOnly && normal[0] * viewDirection[0] + normal[1] * viewDirection[1] + normal[2] * viewDirection[2] >= 0) continue; + } + const distance = Math.hypot(position[0] - center[0], position[1] - center[1], position[2] - center[2]); + if (distance > radius) continue; + const normalized = distance / radius; + const smoothstep = 1 - normalized * normalized * (3 - 2 * normalized); + const weight = Math.max(0, Math.min(1, strength * smoothstep)); + if (weight > 0) result.push({ index, weight }); + } + return result.sort((left, right) => left.index - right.index); +} + +export function parseUdimTilePatch(value: unknown): UdimTilePatchIR { + const patch = record(value, "udimPatch"); + if (patch.schemaVersion !== 1 || patch.format !== "RGBA8" || (patch.colorSpace !== "SRGB" && patch.colorSpace !== "LINEAR")) fail("udimPatch", "has an unsupported schema or pixel format"); + const tile = integer(patch.tile, "udimPatch.tile"); + if (tile < 1001 || tile > 1999) fail("udimPatch.tile", "must be in the supported UDIM range [1001,1999]"); + const width = integer(patch.width, "udimPatch.width"); + const height = integer(patch.height, "udimPatch.height"); + if (width < 1 || height < 1 || width > 16_384 || height > 16_384) fail("udimPatch", "dimensions are outside the bounded range"); + const byteOffset = integer(patch.byteOffset, "udimPatch.byteOffset"); + if (!(patch.bytes instanceof Uint8Array) || patch.bytes.byteLength === 0 || patch.bytes.byteLength > PAINT_BUDGET.maxTextureTileBytes) fail("udimPatch.bytes", "exceeds the tile patch budget", true); + const tileBytes = width * height * 4; + if (tileBytes > PAINT_BUDGET.maxTextureTileBytes || byteOffset > tileBytes - patch.bytes.byteLength) fail("udimPatch.bytes", "is outside the RGBA8 tile range", tileBytes > PAINT_BUDGET.maxTextureTileBytes); + return { + schemaVersion: 1, + textureAssetId: string(patch.textureAssetId, "udimPatch.textureAssetId"), + tile, + revision: integer(patch.revision, "udimPatch.revision"), + width, + height, + format: "RGBA8", + colorSpace: patch.colorSpace, + baseSha256: digest(patch.baseSha256, "udimPatch.baseSha256"), + resultSha256: digest(patch.resultSha256, "udimPatch.resultSha256"), + byteOffset, + bytes: patch.bytes.slice(), + }; +} + +async function sha256(bytes: Uint8Array): Promise { + const digestBytes = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer); + return Array.from(new Uint8Array(digestBytes), (value) => value.toString(16).padStart(2, "0")).join(""); +} + +export async function applyUdimTilePatch(tileValue: unknown, patchValue: unknown, currentRevision: number): Promise { + if (!(tileValue instanceof Uint8Array)) fail("tile", "must be Uint8Array"); + const patch = parseUdimTilePatch(patchValue); + if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("currentRevision", "must be a non-negative safe integer"); + if (patch.revision !== currentRevision) throw new Error("REVISION_CONFLICT: UDIM tile patch is stale"); + if (tileValue.byteLength !== patch.width * patch.height * 4) fail("tile", "length does not match the declared dimensions"); + if (await sha256(tileValue) !== patch.baseSha256) throw new Error("PAINT_TILE_HASH_MISMATCH: UDIM tile base digest is stale"); + const result = tileValue.slice(); + result.set(patch.bytes, patch.byteOffset); + if (await sha256(result) !== patch.resultSha256) throw new Error("PAINT_TILE_HASH_MISMATCH: UDIM tile result digest does not match the patch"); + return result; +} diff --git a/web/protocol/physics-simulation.ts b/web/protocol/physics-simulation.ts index 6b01db71..606e7daf 100644 --- a/web/protocol/physics-simulation.ts +++ b/web/protocol/physics-simulation.ts @@ -61,6 +61,19 @@ export interface PhysicsFamilyCapabilityIR { serverJob: "BLOCKED"; } +export interface BrowserTransformCacheObjectIR { + objectId: string; + translation: [number, number, number]; + rotationQuaternion: [number, number, number, number]; + scale: [number, number, number]; +} + +export interface BrowserTransformCacheFrameIR { + schemaVersion: 1; + frame: number; + objects: BrowserTransformCacheObjectIR[]; +} + export class PhysicsSimulationValidationError extends Error { readonly code: ErrorCode; @@ -237,3 +250,46 @@ export function selectPhysicsCacheFrame(system: PhysicsSystemIR, requestedFrame: } return { cacheKey: cache.cacheKey, frame: requestedFrame }; } + +const BROWSER_TRANSFORM_CACHE_MAGIC = 0x31465442; // BTF1 +const BROWSER_TRANSFORM_CACHE_HEADER_BYTES = 16; +const BROWSER_TRANSFORM_CACHE_OBJECT_BYTES = 72; + +export function decodeBrowserTransformCacheFrame(value: ArrayBuffer): BrowserTransformCacheFrameIR { + if (!(value instanceof ArrayBuffer) || value.byteLength < BROWSER_TRANSFORM_CACHE_HEADER_BYTES) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame is truncated"); + } + const view = new DataView(value); + if (view.getUint32(0, true) !== BROWSER_TRANSFORM_CACHE_MAGIC || view.getUint16(4, true) !== 1 || view.getUint16(6, true) !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES) { + throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", "Unsupported browser transform cache frame schema"); + } + const frameNumber = view.getInt32(8, true); + const count = view.getUint32(12, true); + if (frameNumber < -1_000_000 || frameNumber > 1_000_000 || count > PHYSICS_SIMULATION_BUDGET.maxSystems || value.byteLength !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES + count * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES) { + throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame length or count is invalid"); + } + const decoder = new TextDecoder("utf-8", { fatal: true }); + const objects: BrowserTransformCacheObjectIR[] = []; + const ids = new Set(); + for (let index = 0; index < count; index += 1) { + const offset = BROWSER_TRANSFORM_CACHE_HEADER_BYTES + index * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES; + const idLength = view.getUint8(offset); + if (idLength === 0 || idLength > 31) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid ID length`); + let objectId: string; + try { objectId = decoder.decode(new Uint8Array(value, offset + 1, idLength)); } + catch { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has invalid UTF-8`); } + if (!objectId.startsWith("object:") || ids.has(objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid or duplicate ID`); + ids.add(objectId); + const numbers = Array.from({ length: 10 }, (_, component) => view.getFloat32(offset + 32 + component * 4, true)); + if (numbers.some((component) => !Number.isFinite(component))) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has non-finite transforms`); + const quaternionLength = Math.hypot(numbers[3], numbers[4], numbers[5], numbers[6]); + if (Math.abs(quaternionLength - 1) > 1e-3 || numbers.slice(7).some((component) => component === 0)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid rotation or scale`); + objects.push({ + objectId, + translation: numbers.slice(0, 3) as [number, number, number], + rotationQuaternion: numbers.slice(3, 7) as [number, number, number, number], + scale: numbers.slice(7, 10) as [number, number, number], + }); + } + return { schemaVersion: 1, frame: frameNumber, objects }; +} diff --git a/web/protocol/web-engine.ts b/web/protocol/web-engine.ts index 3caaf7d7..d244c5e5 100644 --- a/web/protocol/web-engine.ts +++ b/web/protocol/web-engine.ts @@ -118,7 +118,7 @@ export type WebEngineEditCommand = | { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM" } | { type: "insertGreasePencilFrame"; dataId: string; layerId: string; frame: number; duration?: number } | { type: "removeGreasePencilFrame"; dataId: string; layerId: string; frame: number } - | { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> } + | { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; baseRevision?: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> } | { type: "setVertexColors"; meshId: string; attributeName: string; domain: "POINT" | "CORNER"; indices: number[]; colors: number[] } | { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean } | { type: "setLightProperties"; dataId: string; properties: { color?: [number, number, number]; energy?: number; exposure?: number; temperature?: number; useTemperature?: boolean; castsShadow?: boolean; radius?: number; spotAngle?: number; spotBlend?: number; areaSize?: number; areaSizeY?: number; areaSpread?: number; sunAngle?: number } } diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index b3dfd0df..2c5a60c6 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -183,6 +183,26 @@ test("serializes concurrent saves for one project without journal races", async expect(result).toEqual({ revision: 9, bytes: [9, 9, 9, 9], recovered: false }); }); +test("deduplicates identical project revisions across independent StorageWorkers", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const { StorageClient } = await import("/src/storage/StorageClient.ts"); + const first = new StorageClient(); + const second = new StorageClient(); + const projectId = `save-cross-worker-e2e-${Date.now()}`; + const bytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 7]).buffer; + const saves = await Promise.all([ + first.saveProject(projectId, 7, bytes.slice(0)), + second.saveProject(projectId, 7, bytes.slice(0)), + ]); + const restored = await second.readProject(projectId); + first.terminate(); + second.terminate(); + return { revisions: saves.map((save) => save.revision), bytes: Array.from(new Uint8Array(restored.buffer)), recovered: restored.recovered }; + }); + expect(result).toEqual({ revisions: [7, 7], bytes: [0x42, 0x4c, 0x45, 0x4e, 0x44, 7], recovered: false }); +}); + test("content-addresses assets, deduplicates them, and rediscovers them after worker restart", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { @@ -494,6 +514,8 @@ test("validates the N-016 Grease Pencil editor context transaction boundary", as expect(result.stale).toContain("REVISION_CONFLICT"); expect(result.duplicate).toContain("GREASE_PENCIL_EDITOR_INVALID"); expect(result.negative).toContain("GREASE_PENCIL_EDITOR_INVALID"); + expect(result.translation).toEqual([1, [0, 0, 0], { position: [1.5, 1, 5], radius: 0.2, opacity: 0.8, vertexColor: [1, 0, 0, 1] }]); + expect(result.missingPoint).toContain("GREASE_PENCIL_EDITOR_INVALID"); }); test("edits N-016 Grease Pencil layers and frames from the bounded editor panel", async ({ page }) => { @@ -511,11 +533,22 @@ test("edits N-016 Grease Pencil layers and frames from the bounded editor panel" 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 }) => { +test("moves an N-016 Grease Pencil point through one revision-bound Main transaction", async ({ page }) => { await page.goto("/"); - const result = await page.evaluate(() => new Promise>((resolve, reject) => { + await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); + await page.getByText("GreasePencilObject", { exact: true }).click(); + const editor = page.getByTestId("grease-pencil-editor"); + await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1.5, 0, 0"); + await editor.getByLabel("Grease Pencil point X delta").fill("0.5"); + await editor.getByRole("button", { name: "Move Point" }).click(); + await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1, 0, 0", { timeout: 20_000 }); +}); + +test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/paint-schema-test.worker.ts", { type: "module" }); - worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; + worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); @@ -523,6 +556,10 @@ test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({ expect(result.weight).toBe("Group"); expect(result.hit).toContain("PAINT_SCHEMA_INVALID"); expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED"); + expect(result.brush).toEqual([{ index: 1, weight: 0.4 }, { index: 2, weight: 0.8 }]); + expect((result.udim as number[]).slice(4, 8)).toEqual([10, 20, 30, 255]); + expect(result.udimRevision).toContain("REVISION_CONFLICT"); + expect(result.udimStale).toContain("PAINT_TILE_HASH_MISMATCH"); }); test("derives an N-017 source-face, barycentric and UV paint hit from a real raycast", async ({ page }) => { @@ -587,6 +624,8 @@ test("validates the N-018 physics capability and cache manifests without claimin expect(result.missingFrame).toContain("PHYSICS_CACHE_FRAME_MISMATCH"); expect(result.solver).toBe("PHYSICS_SOLVER_UNAVAILABLE"); expect(result.manifest).toBe("READY"); + expect(result.browserPlayback).toEqual([7, "object:Cloth", [1, 2, 3]]); + expect(result.browserRotation).toContain("PHYSICS_CACHE_FRAME_MISMATCH"); }); test("maps N-019 Scene exposure and light shadow metadata without using legacy World exposure", async ({ page }) => { @@ -866,7 +905,8 @@ test("autosaves a dirty project without starting a download", async ({ page }) = await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "已保存" }).click(); - await expect(page.getByRole("button", { name: "已保存" })).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole("button", { name: "未保存" })).toBeVisible(); + await expect(page.getByRole("button", { name: "已保存" })).toBeVisible({ timeout: 15_000 }); }); test("persists an operation log entry with an inverse payload", async ({ page }) => {