diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp index 4141a2316..07a2f6b16 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp @@ -1048,6 +1048,7 @@ bool refresh_scene_from_main(EngineState &engine, error = parsed.error; return false; } + engine.packed_assets = parsed.packed_assets; snapshot = json::parse(parsed.scene_ir); std::string merged_media_snapshot = snapshot.dump(); if (!web_engine_blend_main_merge_media_snapshot( @@ -2490,6 +2491,7 @@ std::string refresh_error; return last_error_code; } bool applied = false; + std::vector packed_image_update; std::string main_error; std::string command_feedback; std::string active_object_id = snapshot_active_object_id(snapshot); @@ -2682,7 +2684,7 @@ std::string refresh_error; applied = web_engine_blend_main_pack_image( engine->authoritative_main, command.value("imageId", "").c_str(), command.value("mimeType", "").c_str(), command.value("width", 0), command.value("height", 0), - command.value("base64", "").c_str(), main_error); + command.value("base64", "").c_str(), packed_image_update, main_error); } else if (type == "setMaterialImageNode") { applied = web_engine_blend_main_set_material_image( @@ -4046,6 +4048,9 @@ else if (type == "setMetaballElements") { set_error(WEB_ENGINE_INVALID_ARGUMENT, main_error.c_str()); return last_error_code; } + if (applied && type == "packImage" && !packed_image_update.empty()) { + engine->packed_assets[command.value("imageId", "")] = std::move(packed_image_update); + } if (applied && type == "setObjectSelection" && command.value("active", false)) { snapshot["activeObjectId"] = active_object_id; if (snapshot.contains("editorWorkflow")) { diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp index 5f42b975b..b01d60ca0 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp @@ -15097,7 +15097,7 @@ bool web_engine_blend_main_import_image(WebBlendMainState *state, } image->source = IMA_SRC_FILE; image->type = IMA_TYPE_IMAGE; - if (ImageTile *tile = static_cast(image->tiles.first)) { + if (ImageTile *tile = BKE_image_get_tile(image, 0)) { tile->gen_x = width; tile->gen_y = height; tile->gen_depth = 32; @@ -15119,6 +15119,7 @@ bool web_engine_blend_main_pack_image(WebBlendMainState *state, const int width, const int height, const char *base64, + std::vector &packed_bytes, std::string &error) { if (state == nullptr || state->main == nullptr || image_id == nullptr || mime_type == nullptr || @@ -15145,13 +15146,15 @@ bool web_engine_blend_main_pack_image(WebBlendMainState *state, error = "packImage base64 payload is empty or exceeds 256 MiB"; return false; } + packed_bytes = decoded; if (width > 0 && height > 0) { - if (ImageTile *tile = static_cast(image->tiles.first)) { + if (ImageTile *tile = BKE_image_get_tile(image, 0)) { tile->gen_x = width; tile->gen_y = height; tile->gen_depth = 32; } } + BKE_image_free_packedfiles(image); char *owned = MEM_new_array_uninitialized(decoded.size(), "WebEngine packed external image"); memcpy(owned, decoded.data(), decoded.size()); BKE_image_packfiles_from_mem(nullptr, image, owned, decoded.size()); diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h index 3338dcd06..51e81f379 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h +++ b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.h @@ -442,6 +442,7 @@ bool web_engine_blend_main_pack_image(WebBlendMainState *state, int width, int height, const char *base64, + std::vector &packed_bytes, std::string &error); bool web_engine_blend_main_set_material_image(WebBlendMainState *state, const char *material_id, diff --git a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md index fe5da32cc..3485482a9 100644 --- a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md +++ b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md @@ -45,21 +45,42 @@ - `AGENTS.md` 移植铁律已加入“OPFS 优先”条目。 +### 4. Texture Paint 最小 2D 画笔 + +- 新 `web/app/src/app/TexturePaintView.tsx`:读取 packed Base Color 图像,画笔绘制/擦除、颜色/大小/强度、 + 指针笔画、撤销/重做、`data-last-action`、`packImage` 一次提交;挂载在 Texture Paint 工作区的图像编辑器。 +- 本地练习图 `web/app/public/tutorial/texture-paint/re-texture-base.png` 从 RE 派生库 + `black_tiles_wall_Base_Color.jpg` 提取(1024×1024),课程中导入为本地图像并设为材质 Base Color。 +- 原生修复(web 缓存,不是桌面行为差异): + - `web_engine_blend_main_import_image` 用 `BKE_image_get_tile(image,0)` 建 tile 后再 pack, + 修复新建图像 0×0、未打包。 + - `web_engine_blend_main_pack_image` 在 pack 前 `BKE_image_free_packedfiles`,避免追加旧 packed file; + 返回新 PNG 字节。 + - `refresh_scene_from_main` 把 reader 的 `packed_assets` 同步回 `engine.packed_assets`; + `packImage` 成功后再更新对应 imageId 的缓存。修复 `requestAsset` 读旧图、重开丢笔画。 + - 历史/撤销仍保存前后 packed_assets,undo 恢复旧图。 +- 验证: + `DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome GPU_MODE=hardware npx playwright test --config playwright.config.ts tests/e2e/web-texture-paint-canvas.spec.ts --workers=1` + → **2/2 PASS(42.9s)**: + - 画布中心像素由原图变为红色(绘制)、撤销后恢复; + - OPFS 保存 → reload → 最近项目恢复 → 重开后中心像素仍为红色。 + ## 待实现(按计划顺序) -1. **Texture Paint 最小 2D 画笔**:图像选择、颜色/大小/强度、光标、笔画、`packImage` 一次提交、 - undo/redo、OPFS 保存重开;无 WASM。桌面语义参考 `editors/sculpt_paint/mesh/paint_image*.cc`。 -2. **Geometry Nodes 画布**:共享图状态、React Flow 画布、位置/rename/属性、结果面板; +1. **Geometry Nodes 画布**:共享图状态、React Flow 画布、位置/rename/属性、结果面板; 需要一次 WASM 重建(location/properties)。 -3. **Spreadsheet 交互式只读表**:数据源/过滤/行列/列重排/CSV;原生属性值读取需 WASM。 -4. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次 +2. **Spreadsheet 交互式只读表**:数据源/过滤/行列/列重排/CSV;原生属性值读取需 WASM。 +3. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次 WASM 工作。 -5. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。 -6. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。 +4. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。 +5. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。 ## 已知限制 / 未验证 -- 纹理绘制、Compositor 画布、GN 画布、Spreadsheet 尚未实现;课程/视频尚未开始。 +- 纹理绘制只支持本地 packed 图像;库链接图像(直接分配的 RE 材质贴图)被原生 + `packImage cannot modify linked library images` 拒绝。课程通过导入 RE 派生的本地 PNG 并设为 + Base Color 来绘制;后续可加“Make Local”入口。 +- Compositor 画布、GN 画布、Spreadsheet 尚未实现;课程/视频尚未开始。 - 本轮只验证 Chromium + RTX 4060;未验证其他浏览器。 - 既有保存下载型 spec 尚未迁移;在迁移完成前不要把这些旧 spec 的失败当作产品回归。 - 远端首次 wasm 加载 154–193 秒;课程录制只做本地,页面需提示首载时间。 diff --git a/tests/golden/TEXTURE-PAINT-UI-20261113.png b/tests/golden/TEXTURE-PAINT-UI-20261113.png new file mode 100644 index 000000000..a7ef70a96 Binary files /dev/null and b/tests/golden/TEXTURE-PAINT-UI-20261113.png differ diff --git a/web/app/public/engine-manifest.json b/web/app/public/engine-manifest.json index 0f0fb18d9..594f8c700 100644 --- a/web/app/public/engine-manifest.json +++ b/web/app/public/engine-manifest.json @@ -21,7 +21,7 @@ "wasm": { "fileName": "web_engine.wasm", "url": "/vendor/blender/web_engine.wasm", - "sha256": "31550b374cc0127c9b5468cebe65be37b8558c1604a28e99ec73a34be96c13f3" + "sha256": "955d87a1ef28a89f68bdb562236e4425adbc040c7508af211857d7c780c42868" } } }, diff --git a/web/app/public/tutorial/texture-paint/re-texture-base.png b/web/app/public/tutorial/texture-paint/re-texture-base.png new file mode 100644 index 000000000..8d9d2bfca Binary files /dev/null and b/web/app/public/tutorial/texture-paint/re-texture-base.png differ diff --git a/web/app/public/vendor/blender/web_engine.wasm b/web/app/public/vendor/blender/web_engine.wasm index a5f3c421f..a60d36ef1 100644 Binary files a/web/app/public/vendor/blender/web_engine.wasm and b/web/app/public/vendor/blender/web_engine.wasm differ diff --git a/web/app/public/wasm-module-manifest.json b/web/app/public/wasm-module-manifest.json index 382714eb3..e195e4a7d 100644 --- a/web/app/public/wasm-module-manifest.json +++ b/web/app/public/wasm-module-manifest.json @@ -10,7 +10,7 @@ "single": { "fileName": "web_engine.wasm", "url": "/vendor/blender/web_engine.wasm", - "sha256": "31550b374cc0127c9b5468cebe65be37b8558c1604a28e99ec73a34be96c13f3" + "sha256": "955d87a1ef28a89f68bdb562236e4425adbc040c7508af211857d7c780c42868" }, "pthread": { "fileName": "web_engine.wasm", @@ -27,7 +27,7 @@ "single": { "fileName": "web_engine.wasm", "url": "/vendor/blender/web_engine.wasm", - "sha256": "31550b374cc0127c9b5468cebe65be37b8558c1604a28e99ec73a34be96c13f3" + "sha256": "955d87a1ef28a89f68bdb562236e4425adbc040c7508af211857d7c780c42868" }, "pthread": { "fileName": "web_engine.wasm", @@ -44,7 +44,7 @@ "single": { "fileName": "web_engine.wasm", "url": "/vendor/blender/web_engine.wasm", - "sha256": "31550b374cc0127c9b5468cebe65be37b8558c1604a28e99ec73a34be96c13f3" + "sha256": "955d87a1ef28a89f68bdb562236e4425adbc040c7508af211857d7c780c42868" }, "pthread": { "fileName": "web_engine.wasm", @@ -61,7 +61,7 @@ "single": { "fileName": "web_engine.wasm", "url": "/vendor/blender/web_engine.wasm", - "sha256": "31550b374cc0127c9b5468cebe65be37b8558c1604a28e99ec73a34be96c13f3" + "sha256": "955d87a1ef28a89f68bdb562236e4425adbc040c7508af211857d7c780c42868" }, "pthread": { "fileName": "web_engine.wasm", diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 2735ebbd3..60ce94f20 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -34,6 +34,7 @@ import {WasmPythonConsole, type PythonConsoleDraft} from './WasmPythonConsole'; import {PythonSessionClient, type PythonBridgeMethod, type PythonSessionState, type EmbeddedPythonHost} from '../engine-client/PythonSessionClient'; import {WasmUVView} from './WasmUVView'; import {WasmImageView} from './WasmImageView'; +import {TexturePaintView} from './TexturePaintView'; import {NDOFControl} from './NDOFControl'; import type {NativeSessionCommand} from '../../../protocol/native-workflow'; import type { NativeWorkflowContext, NativeWorkflowInput } from '../../../protocol/native-workflow'; @@ -3993,6 +3994,7 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab useEffect(() => () => { for (const file of nativeFiles) URL.revokeObjectURL(file.url); }, [nativeFiles]); const [editorWorkflow, setEditorWorkflow] = useState(null); const changeNativeModeRef = useRef<(mode: Extract['mode']) => Promise>(() => Promise.resolve()); + const cachePackedAssetsRef = useRef<((projectId: string, scene: SceneSnapshotIR) => Promise) | null>(null); const uvAutoEditAttemptRef = useRef(''); const [selectedObjectIds, setSelectedObjectIds] = useState>(() => new Set()); const [meshSelection, setMeshSelection] = useState({ meshId: null, mode: "FACE", indices: new Set() }); @@ -5703,7 +5705,14 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); failureCode = "IMPORT_MAIN_TRANSACTION_FAILED"; const applied = await applyEditCommand(ioMediaCommands.importImage(file.name.replace(/\.[^.]+$/, "") || "Image", - file.type === "image/jpeg" ? "image/jpeg" : "image/png", bitmap.width, bitmap.height, btoa(binary))); + file.type === "image/jpeg" ? "image/jpeg" : "image/png", bitmap.width, bitmap.height, btoa(binary)), (next) => { + const refresh = cachePackedAssetsRef.current; + if (!refresh) return; + const project = projectIdRef.current; + void refresh(project, next).then((assets) => { + if (projectIdRef.current === project) setGPUTextureAssets(assets); + }); + }); if (!applied) { failUserAction(identity, failureCode); return; @@ -6214,6 +6223,30 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab } return gpuAssets; }; + cachePackedAssetsRef.current = cachePackedAssets; + + const packPaintedImage = async (imageId: string, png: Uint8Array, width: number, height: number): Promise => { + let binary = ""; + for (let offset = 0; offset < png.length; offset += 0x8000) binary += String.fromCharCode(...png.subarray(offset, offset + 0x8000)); + const applied = await applyEditCommand({ type: "packImage", imageId, mimeType: "image/png", width, height, base64: btoa(binary) }, (next) => { + const project = projectIdRef.current; + void cachePackedAssets(project, next).then((assets) => { + if (projectIdRef.current === project) setGPUTextureAssets(assets); + }); + }); + return applied; + }; + const refreshPaintedAssets = async (): Promise => { + const client = webClientRef.current; + const project = projectIdRef.current; + if (!client) return; + const current = await client.snapshot(); + if (projectIdRef.current !== project) return; + const assets = await cachePackedAssets(project, current.snapshot); + if (projectIdRef.current === project) setGPUTextureAssets(assets); + }; + const undoPaintedImage = async (): Promise => applyEditCommand({ type: "undo" }); + const redoPaintedImage = async (): Promise => applyEditCommand({ type: "redo" }); const importSceneFile = async (file: File, format: IOSceneFormat): Promise => { const identity = nextUserActionIdentity("OPEN"); @@ -8167,7 +8200,7 @@ else: }); }}/> : null} {!(localOnly && snapshot && gpuSurfaceOpen) ? editNativeArea(area,'SET_ACTIVE_AREA')}> - {localOnly || workspace === "UV Editing" || workspace === "Texture Paint" || workspace === "Shading" || workspace === "Compositing" ? {workspace==='UV Editing'?editUVSelection(intent,fallbackUVArea.id) : undefined}/>:} : null} + {localOnly || workspace === "UV Editing" || workspace === "Texture Paint" || workspace === "Shading" || workspace === "Compositing" ? {workspace==='UV Editing'?editUVSelection(intent,fallbackUVArea.id) : undefined}/>:workspace==='Texture Paint'?:} : null} {localOnly?<> void packExternalImage(imageId, file)} textureAssets={gpuTextureAssets} activeMaterialId={activeMaterialId} onSavePreview={(material, png) => void saveMaterialPreview(material, png)}/>fileInputRef.current?.click()} onSearch={()=>dispatchUI({type:'toggleOperatorSearch',open:true})}/>fileInputRef.current?.click()} onSearch={()=>dispatchUI({type:'toggleOperatorSearch',open:true})}/>:null} {workspace === "Rendering" ? { const assets = await cachePackedAssets(projectIdRef.current, scene); setGPUTextureAssets(assets); }} /> diff --git a/web/app/src/app/TexturePaintView.tsx b/web/app/src/app/TexturePaintView.tsx new file mode 100644 index 000000000..2522a3e24 --- /dev/null +++ b/web/app/src/app/TexturePaintView.tsx @@ -0,0 +1,214 @@ +import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import type { SceneSnapshotIR } from "../../../protocol/scene-ir"; +import type { GPUTextureAsset } from "../../../protocol/render-assets"; + +export interface TexturePaintViewProps { + snapshot: SceneSnapshotIR | null; + assets: GPUTextureAsset[]; + disabled?: boolean; + onPackImage: (imageId: string, png: Uint8Array, width: number, height: number) => Promise; + onUndo: () => Promise; + onRedo: () => Promise; + onRefreshAssets: () => Promise; +} + +type BrushMode = "DRAW" | "ERASE"; + +export function TexturePaintView({ snapshot, assets, disabled = false, onPackImage, onUndo, onRedo, onRefreshAssets }: TexturePaintViewProps) { + const displayRef = useRef(null); + const paintRef = useRef(null); + const drawing = useRef(false); + const last = useRef<{ x: number; y: number } | null>(null); + const [imageId, setImageId] = useState(""); + const [mode, setMode] = useState("DRAW"); + const [color, setColor] = useState("#ffffff"); + const [size, setSize] = useState(42); + const [strength, setStrength] = useState(1); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState("选择图像后在画布上绘制"); + const [lastAction, setLastAction] = useState(""); + + const activeMaterial = useMemo(() => { + const node = snapshot?.nodes.find((candidate) => candidate.id === snapshot.activeObjectId); + const mesh = snapshot?.meshes.find((candidate) => candidate.id === node?.dataId); + const materialId = mesh?.materialSlotIds?.[0]; + return snapshot?.materials.find((candidate) => candidate.id === materialId); + }, [snapshot]); + const preferredImageId = activeMaterial?.textureBindings?.find((binding) => binding.usage === "BASE_COLOR")?.imageId ?? ""; + const images = useMemo( + () => (snapshot?.images ?? []).filter((image) => image.packed && assets.some((asset) => asset.imageId === image.id)), + [snapshot, assets], + ); + const selectedImage = useMemo(() => snapshot?.images.find((image) => image.id === imageId), [snapshot, imageId]); + const asset = useMemo( + () => assets.find((candidate) => candidate.imageId === imageId && candidate.usage === "BASE_COLOR") ?? assets.find((candidate) => candidate.imageId === imageId), + [assets, imageId], + ); + + useEffect(() => { + if (images.some((image) => image.id === imageId)) return; + setImageId(images.some((image) => image.id === preferredImageId) ? preferredImageId : images[0]?.id ?? ""); + }, [images, preferredImageId, imageId]); + + useEffect(() => { + let cancelled = false; + paintRef.current = null; + if (!asset) return () => { cancelled = true; }; + void (async () => { + try { + const bitmap = await createImageBitmap(new Blob([asset.data], { type: asset.mimeType })); + if (cancelled) { bitmap.close(); return; } + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("2D canvas unavailable"); + context.drawImage(bitmap, 0, 0); + bitmap.close(); + paintRef.current = canvas; + const display = displayRef.current; + if (display) { + display.width = canvas.width; + display.height = canvas.height; + display.getContext("2d")?.drawImage(canvas, 0, 0); + } + setStatus(`${asset.imageId} · ${canvas.width}×${canvas.height} · 已加载`); + } + catch (error) { + setStatus(`图像加载失败:${error instanceof Error ? error.message : String(error)}`); + } + })(); + return () => { cancelled = true; }; + }, [asset]); + + const point = (event: ReactPointerEvent): { x: number; y: number } | null => { + const canvas = event.currentTarget; + const rect = canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + return { + x: (event.clientX - rect.left) * canvas.width / rect.width, + y: (event.clientY - rect.top) * canvas.height / rect.height, + }; + }; + + const paintSegment = (from: { x: number; y: number }, to: { x: number; y: number }): void => { + const canvas = paintRef.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + context.save(); + context.globalCompositeOperation = mode === "ERASE" ? "destination-out" : "source-over"; + context.globalAlpha = strength; + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = size; + context.lineCap = "round"; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(from.x, from.y); + context.lineTo(to.x, to.y); + context.stroke(); + context.restore(); + const display = displayRef.current; + if (display) { + const displayContext = display.getContext("2d"); + displayContext?.clearRect(0, 0, display.width, display.height); + displayContext?.drawImage(canvas, 0, 0); + } + }; + + const commit = async (): Promise => { + const canvas = paintRef.current; + if (!canvas || !imageId) return; + setBusy(true); + try { + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + if (!blob) throw new Error("PNG 编码失败"); + const ok = await onPackImage(imageId, new Uint8Array(await blob.arrayBuffer()), canvas.width, canvas.height); + if (ok) await onRefreshAssets(); + setStatus(ok ? "笔画已提交到主数据(一次撤销步)" : "笔画未提交,请查看引擎状态"); + setLastAction(ok ? "笔画已提交" : "笔画未提交"); + } + catch (error) { + setStatus(`提交失败:${error instanceof Error ? error.message : String(error)}`); + } + finally { setBusy(false); } + }; + + const undo = async (): Promise => { + setBusy(true); + try { + const ok = await onUndo(); + await onRefreshAssets(); + setStatus(ok ? "已撤销上一笔" : "撤销失败"); + setLastAction(ok ? "已撤销" : "撤销失败"); + } finally { setBusy(false); } + }; + const redo = async (): Promise => { + setBusy(true); + try { + const ok = await onRedo(); + await onRefreshAssets(); + setStatus(ok ? "已重做上一笔" : "重做失败"); + setLastAction(ok ? "已重做" : "重做失败"); + } finally { setBusy(false); } + }; + + return ( +
+
+ 纹理绘制 + + + +
+
+
+ + +
+ + + + {status} + {lastAction ? {lastAction} : null} +
+
+ {asset + ? { + if (disabled || busy || !paintRef.current) return; + const start = point(event); + if (!start) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + drawing.current = true; + last.current = start; + paintSegment(start, start); + }} + onPointerMove={(event) => { + if (!drawing.current) return; + const next = point(event); + if (!next || !last.current) return; + paintSegment(last.current, next); + last.current = next; + }} + onPointerUp={(event) => { + if (!drawing.current) return; + drawing.current = false; + last.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + void commit(); + }} + onPointerCancel={() => { drawing.current = false; last.current = null; }} + /> + :
先在 Shading 工作区给当前对象分配带 Base Color 贴图的材质,再回到 Texture Paint。
} +
+
+ ); +} diff --git a/web/app/src/app/wasm-editors.css b/web/app/src/app/wasm-editors.css index 84366c86e..7f3d17ed7 100644 --- a/web/app/src/app/wasm-editors.css +++ b/web/app/src/app/wasm-editors.css @@ -66,3 +66,15 @@ .wasm-shader-external label { display: flex; align-items: center; gap: 8px; justify-content: space-between; padding: 2px 0; } .wasm-shader-external label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wasm-shader-external input[type=file] { max-width: 190px; } + +.texture-paint-view { height: 100%; min-height: 0; display: flex; flex-direction: column; background: #292929; color: #ddd; } +.texture-paint-view > header { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 5px; border-bottom: 1px solid #555; } +.texture-paint-view > header select { min-width: 0; max-width: 220px; color: #ddd; background: #252525; border: 1px solid #505050; border-radius: 2px; } +.texture-paint-view > header button { padding: 3px 7px; color: #ddd; background: #454545; border-radius: 2px; } +.texture-paint-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 5px; border-bottom: 1px solid #444; } +.texture-paint-controls label { display: flex; align-items: center; gap: 4px; } +.texture-paint-controls input[type="range"] { width: 74px; accent-color: #4775a3; } +.texture-paint-controls output { flex-basis: 100%; color: #aaa; } +.texture-paint-canvas-wrap { position: relative; flex: 1; min-height: 0; overflow: auto; display: grid; place-items: center; background: #202020; } +.texture-paint-canvas-wrap canvas { max-width: 100%; max-height: 100%; display: block; cursor: crosshair; touch-action: none; background: #303030; } +.texture-paint-empty { padding: 20px; color: #aaa; text-align: center; } diff --git a/web/app/src/vendor/blender/web_engine.wasm b/web/app/src/vendor/blender/web_engine.wasm index a5f3c421f..a60d36ef1 100644 Binary files a/web/app/src/vendor/blender/web_engine.wasm and b/web/app/src/vendor/blender/web_engine.wasm differ diff --git a/web/tests/e2e/web-texture-paint-canvas.spec.ts b/web/tests/e2e/web-texture-paint-canvas.spec.ts new file mode 100644 index 000000000..bebcb2c55 --- /dev/null +++ b/web/tests/e2e/web-texture-paint-canvas.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend"); +const GLB = path.resolve(HERE, "../../../re/猴头.glb"); +const TEXTURE = path.resolve(HERE, "../../app/public/tutorial/texture-paint/re-texture-base.png"); + +test.use({ viewport: { width: 1600, height: 900 } }); + +async function openStudioAndMonkey(page: import("@playwright/test").Page): Promise { + await page.goto("/?ui=web"); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO); + await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 120_000 }); + await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/); + await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB); + await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/); +} + +async function clickWorkspace(page: import("@playwright/test").Page, name: string): Promise { + await page.locator(".workspace-tabs button", { hasText: name }).evaluate((element) => (element as HTMLElement).click()); +} + +async function assignReMaterialAndLocalTexture(page: import("@playwright/test").Page): Promise { + await clickWorkspace(page, "Shading"); + await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 }); + await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1"); + await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"]').count(), { timeout: 60_000 }).toBe(6); + await page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first().dblclick(); + await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 120_000 }) + .toContain("material:black_tiles_wall"); + await page.locator('.property-section input[type="file"][accept="image/png,image/jpeg"]').first().setInputFiles(TEXTURE); + await expect.poll(async () => (await page.locator('select[aria-label="材质图片"] option').allTextContents()).some((text) => text.includes("re-texture-base")), { timeout: 60_000 }) + .toBe(true); + await page.locator('select[aria-label="材质图片"]').first().selectOption({ label: "re-texture-base" }); + await page.waitForTimeout(2_000); +} + +async function openTexturePaint(page: import("@playwright/test").Page): Promise { + await clickWorkspace(page, "Texture Paint"); + await page.locator(".texture-paint-view").waitFor({ timeout: 60_000 }); + await page.locator('[data-testid="texture-paint-canvas"]').waitFor({ timeout: 60_000 }); + await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-image-id"), { timeout: 60_000 }) + .toContain("re-texture-base"); + await expect.poll(async () => page.locator(".texture-paint-controls output").first().innerText(), { timeout: 60_000 }) + .toContain("已加载"); +} + +async function centerPixel(page: import("@playwright/test").Page): Promise<[number, number, number, number] | null> { + return page.evaluate(() => { + const canvas = document.querySelector('[data-testid="texture-paint-canvas"]'); + const context = canvas?.getContext("2d"); + if (!canvas || !context) return null; + const data = context.getImageData(Math.floor(canvas.width * 0.5), Math.floor(canvas.height * 0.5), 1, 1).data; + return [data[0], data[1], data[2], data[3]]; + }); +} + +async function paintRedThroughCenter(page: import("@playwright/test").Page): Promise { + await page.locator('[aria-label="画笔颜色"]').fill("#ff0000"); + await page.locator('[aria-label="画笔大小"]').fill("120"); + const box = await page.locator('[data-testid="texture-paint-canvas"]').boundingBox(); + if (!box) throw new Error("texture paint canvas has no box"); + await page.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.5); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.5, { steps: 10 }); + await page.mouse.up(); + await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-last-action"), { timeout: 60_000 }) + .toBe("笔画已提交"); +} + +test("Texture Paint paints a local image, commits one stroke and undoes it", async ({ page }) => { + test.setTimeout(300_000); + await openStudioAndMonkey(page); + await assignReMaterialAndLocalTexture(page); + await openTexturePaint(page); + const before = await centerPixel(page); + await paintRedThroughCenter(page); + const after = await centerPixel(page); + expect(after?.[0]).toBeGreaterThan(200); + expect(after?.[1]).toBeLessThan(40); + expect(after?.[2]).toBeLessThan(40); + await page.getByRole("button", { name: "撤销笔画", exact: true }).evaluate((element) => (element as HTMLElement).click()); + await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-last-action"), { timeout: 60_000 }).toBe("已撤销"); + const undone = await centerPixel(page); + expect(undone?.[0]).toBe(before?.[0]); + expect(undone?.[1]).toBe(before?.[1]); + expect(undone?.[2]).toBe(before?.[2]); +}); + +test("Texture Paint stroke survives an OPFS save and reopen", async ({ page }) => { + test.setTimeout(360_000); + await openStudioAndMonkey(page); + await assignReMaterialAndLocalTexture(page); + await openTexturePaint(page); + await paintRedThroughCenter(page); + await page.locator('button[aria-label="保存项目"]').evaluate((element) => (element as HTMLElement).click()); + await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-status", "SUCCEEDED", { timeout: 60_000 }); + await page.reload(); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await expect.poll(async () => page.locator('[data-testid="recent-projects"] option').count(), { timeout: 60_000 }).toBeGreaterThan(1); + await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 }); + const discard = page.getByRole("button", { name: "不保存,继续", exact: true }); + if (await discard.count()) await discard.click(); + await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/); + await openTexturePaint(page); + const pixel = await centerPixel(page); + expect(pixel?.[0]).toBeGreaterThan(200); + expect(pixel?.[1]).toBeLessThan(40); + expect(pixel?.[2]).toBeLessThan(40); +});